Module: Typr
- Included in:
- Space
- Defined in:
- lib/terminal.rb,
lib/grid.rb,
lib/line.rb,
lib/text.rb,
lib/space.rb,
lib/stack.rb,
lib/browser.rb
Overview
Terminal control: raw key input, line editing, cursor movement, ANSI colors
and terminal geometry. Widgets get the instance helpers via include Typr;
class-level helpers (Typr.width, Typr.read_key, ...) drive the whole screen.
Defined Under Namespace
Classes: Browser, Grid, Line, Mouse, Space, Stack, Text
Constant Summary collapse
- MIMETYPES =
eval File.read( __dir__ + "/../share/mimetypes" )
- INPUT =
Raw /dev/tty used for key input; falls back to $stdin when unavailable.
(IO.new IO.sysopen("/dev/tty", "r")) rescue $stdin
- TERMINFO =
KEY_* escapes and keypad sequences are precompiled into share/terminfo by bin/build_terminfo, so the runtime needs no ncurses/infocmp (e.g. Termux).
begin eval File.read(File.("../share/terminfo", __dir__)) rescue StandardError {} end
- DEFAULT_KEYS =
Known-good fallback so KEY_* are always defined, even for TERM=dumb.
{ "key_up" => "\eOA", "key_down" => "\eOB", "key_left" => "\eOD", "key_right" => "\eOC", "key_home" => "\eOH", "key_end" => "\eOF", "key_backspace" => "\x7f", "key_dc" => "\e[3~", "key_npage" => "\e[6~", "key_ppage" => "\e[5~", "carriage_return" => "\r", "keypad_xmit" => "\e[?1h\e=", "keypad_local" => "\e[?1l\e>", "cursor_invisible" => "\e[?25l", "cursor_normal" => "\e[?25h", "clear_screen" => "\e[H\e[2J", "user7" => "\e[6n" }
- ERASE_LINE =
Common key aliases and the line-erase escape sequence.
"\e[K"- ORIG_COLORS =
Reset foreground/background to the terminal defaults.
"\e[39;49m"- KEY_ESCAPE =
"\e"- KEY_RETURN =
CARRIAGE_RETURN- KEY_TAB =
"\t"- KEY_PAGEDOWN =
KEY_NPAGE- KEY_PAGEUP =
KEY_PPAGE- MOUSE_ON =
Enable/disable mouse reporting. Universal private modes, independent of terminfo: 1000 = button press/release/wheel, 1006 = SGR coordinates.
"\e[?1000h\e[?1006h"- MOUSE_OFF =
"\e[?1000l\e[?1006l"- MODES =
Text attributes (reset, bold, italic, ...) and named 8/256-color tables.
%i[ reset bold italic underline slow fast invert ]
- COLORS =
%i[ black red green yellow blue magenta cyan white ]
- COLOR_MAP =
{ brown: 130, orange: 208, lime: 118, pink: 218, maroon: 52, navy: 18, teal: 30, olive: 100, coral: 203, tan: 180, dark_red: 88, dark_green: 22, dark_yellow: 58, dark_blue: 18, dark_magenta: 89, dark_cyan: 30 }
Class Method Summary collapse
-
.clear(mode = :screen) ⇒ Object
Clear the whole screen (:screen) or just the current line (:line).
- .column ⇒ Object
-
.decode_mouse(raw) ⇒ Object
Decode a raw input string into a Mouse event, or nil when
rawis not a mouse report. -
.exit ⇒ Object
Restore the terminal: show the cursor, reset keypad/colors, disable mouse reporting and clear.
- .height ⇒ Object
-
.init(default = [ :white, :black ]) ⇒ Object
Enter interactive mode: seed the default colors, hide the cursor, enable key-mode (application) escapes and mouse reporting when stdin is a tty.
-
.on_resize(&block) ⇒ Object
Call
blockwhenever the terminal is resized (SIGWINCH). -
.position ⇒ Object
Query the terminal for the current [row, column] via DSR; [0, 0] when stdin is not a tty.
-
.read_key ⇒ Object
Reads a single keypress in raw mode.
-
.read_line(prompt = '', left: 0, top: 0, initial: '', &block) ⇒ Object
Interactive line editor used for string prompts (search, %str, ...).
-
.row ⇒ Object
Current cursor row/column (see position).
-
.size ⇒ Object
Terminal [rows, cols] (defaults to 24x80 when undetectable); width/height return the usable last column/row.
-
.slice_width(str, offset, width) ⇒ Object
Visible substring of
strstarting at display-widthoffset, at mostwidthcells wide. -
.text_width(str) ⇒ Object
Display width of
strafter stripping ANSI escapes. - .width ⇒ Object
-
.word_next(str, cursor) ⇒ Object
Move the cursor forward to the start of the next word in
str. -
.word_prev(str, cursor) ⇒ Object
Move the cursor back to the start of the previous word in
str.
Instance Method Summary collapse
- #background(c = ) ⇒ Object
-
#clip(str, max, side) ⇒ Object
Truncate
strto at mostmaxcells, keeping ANSI color tokens intact and clipping fromside(:left/:right). -
#coerce_type(value) ⇒ Object
Coerce a string into Integer, Float, or Boolean when it matches those forms (yes/no, true/false); otherwise return it unchanged.
- #color(c = $default) ⇒ Object
-
#color_code(color, bg = false) ⇒ Object
Build the ANSI code for
color(a Symbol name, greyN, Integer palette id, or [r,g,b] / [fg,bg] Array), optionally as a background; when false the color is emitted as a foreground. -
#draw(str) ⇒ Object
Write raw
strto stdout. -
#fade(str, side = :left, num = 3) ⇒ Object
Blend the first (or last)
numcharacters ofstrinto greys to hint at clipping;sideselects which end fades. -
#foreground(c = ) ⇒ Object
Set and apply the foreground/background color;
colorsets both from a [fg, bg] pair, a single color, or the module default. -
#get_background ⇒ Object
Read the current background/foreground color pair.
- #get_foreground ⇒ Object
-
#mode(name) ⇒ Object
Apply a text attribute (+mode+); mode_code returns the escape sequence without writing it.
- #mode_code(name) ⇒ Object
-
#move(x = 0, y = 0) ⇒ Object
Move the cursor to column
x, rowy(0-based); move_code returns the escape sequence without writing it. - #move_code(x = 0, y = 0) ⇒ Object
-
#prepare(str, max, align = :left, side = :right, fade = false) ⇒ Object
Pad or clip
strto exactlymaxcells, honoringalign(:left/:right), trimming fromside, with an optional trailingfade. -
#real_size(str) ⇒ Object
Display width of
strafter stripping ANSI escapes and control chars. -
#sanitize(str) ⇒ Object
Strip ANSI escape sequences and control characters (except \n, \t) from shell-generated text, leaving only printable content.
Class Method Details
.clear(mode = :screen) ⇒ Object
Clear the whole screen (:screen) or just the current line (:line).
245 246 247 |
# File 'lib/terminal.rb', line 245 def self.clear mode=:screen $>.print( { screen: CLEAR_SCREEN, line: ERASE_LINE }[mode] ) end |
.column ⇒ Object
421 |
# File 'lib/terminal.rb', line 421 def self.column; position.last end |
.decode_mouse(raw) ⇒ Object
Decode a raw input string into a Mouse event, or nil when raw
is not a mouse report. Handles both SGR (CSI < b ; x ; y M/m) and the
legacy X10 (CSI M + three bytes) encodings.
279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 |
# File 'lib/terminal.rb', line 279 def self.decode_mouse raw if (m = raw.match(/\A\e\[<(\d+);(\d+);(\d+)([Mm])\z/)) code, x, y, final = m[1].to_i, m[2].to_i, m[3].to_i, m[4] elsif raw.start_with?("\e[M") code, x, y = raw.getbyte(3) - 32, raw.getbyte(4) - 32, raw.getbyte(5) - 32 final = code == 3 ? ?m : ?M else return nil end modifiers = code & 28 base = code & ~28 motion = (base & 32) != 0 wheel = base >= 64 = wheel ? base - 64 + 4 : base & 3 action = if motion then :motion elsif wheel then final == ?m ? :release : :press else ( final == ?m or code == 3 ) ? :release : :press end Mouse.new , x, y, action, modifiers end |
.exit ⇒ Object
Restore the terminal: show the cursor, reset keypad/colors, disable mouse reporting and clear.
440 441 442 443 444 445 446 447 448 |
# File 'lib/terminal.rb', line 440 def self.exit; extend self if $stdin.tty? $>.print CURSOR_NORMAL; $>.print KEYPAD_LOCAL if defined?(KEYPAD_LOCAL) $>.print MOUSE_OFF $>.print ORIG_COLORS; color; clear end end |
.height ⇒ Object
417 |
# File 'lib/terminal.rb', line 417 def self.height; size.first - 1 end |
.init(default = [ :white, :black ]) ⇒ Object
Enter interactive mode: seed the default colors, hide the cursor, enable key-mode (application) escapes and mouse reporting when stdin is a tty.
429 430 431 432 433 434 435 436 437 |
# File 'lib/terminal.rb', line 429 def self.init default=[ :white, :black ] $default = default.dup $color = $default.dup if $stdin.tty? print CURSOR_INVISIBLE print KEYPAD_XMIT if defined?(KEYPAD_XMIT) print MOUSE_ON end end |
.on_resize(&block) ⇒ Object
Call block whenever the terminal is resized (SIGWINCH).
424 425 426 |
# File 'lib/terminal.rb', line 424 def self.on_resize &block trap(:WINCH, &block) end |
.position ⇒ Object
Query the terminal for the current [row, column] via DSR; [0, 0] when stdin is not a tty.
400 401 402 403 404 405 406 407 408 409 410 411 |
# File 'lib/terminal.rb', line 400 def self.position return [0, 0] unless $stdin.tty? result = '' $stdin.raw do |stdin| $stdout << USER7 $stdout.flush until (char = stdin.getc) == 'R' result << char if char end end result[/[\d;]+/].split(?;).map &:to_i end |
.read_key ⇒ Object
Reads a single keypress in raw mode.
Returns the key sequence as a String (e.g. "a" or "\e[A" for up-arrow), or a Typr::Mouse for a mouse report, or nil when stdin is not a tty or input is unavailable.
255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 |
# File 'lib/terminal.rb', line 255 def self.read_key read = ->(tty) do str = tty.sysread 6 if str.start_with?("\e[M") and str.size < 6 str << tty.sysread(6 - str.size) # X10 mouse reports elsif str.start_with?("\e[") # CSI sequences (arrows, SGR mouse, ...) end with a byte >= 0x40. str << tty.sysread(1) until str[-1].ord >= 0x40 elsif str.start_with?("\eO") and str.size < 3 str << tty.sysread(3 - str.size) # SS3 (application) cursor keys elsif str == "\e" and IO.select([tty], nil, nil, 0.03) str << tty.read_nonblock(6) rescue nil # alt+key continuation end str end raw = (INPUT.raw{ |tty| read.call tty } rescue nil) return unless raw decode_mouse(raw) || raw end |
.read_line(prompt = '', left: 0, top: 0, initial: '', &block) ⇒ Object
Interactive line editor used for string prompts (search, %str, ...).
Renders prompt and the query at (left, top) and edits it with
arrow keys, ctrl-arrow word jumps, home/end, delete and backspace.
Returns the query on enter, nil on escape. A block may inspect each
keypress after it is applied; a non-nil return short-circuits the
editor and becomes its return value. When stdin is not a tty, the
query is read from a single line of piped input instead.
Typr.read_line "/" do |key, query, cursor|
filter query
nil
end
313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 |
# File 'lib/terminal.rb', line 313 def self.read_line prompt='', left: 0, top: 0, initial: '', &block return $stdin.gets&.chomp unless $stdin.tty? query, cursor = initial.dup, initial.length $>.print CURSOR_NORMAL begin loop do $>.print "\e[%i;%if" % [ top + 1, left + 1 ] $>.print ERASE_LINE before = text_width( prompt + query[0...cursor] ) offset = [ left + before - Typr.width, 0 ].max $>.print slice_width( prompt + query, offset, Typr.width - left + 1 ) $>.print "\e[%i;%if" % [ top + 1, left + 1 + before - offset ] key = read_key return nil if key.nil? or key == KEY_ESCAPE return query if key == KEY_RETURN or key == "\n" case key when KEY_LEFT, "\e[D"; cursor -= 1 if cursor > 0 when KEY_RIGHT, "\e[C"; cursor += 1 if cursor < query.length when "\e[1;5D", "\eOd"; cursor = word_prev query, cursor when "\e[1;5C", "\eOc"; cursor = word_next query, cursor when KEY_HOME; cursor = 0 when KEY_END; cursor = query.length when KEY_DC, "\e[3~"; query.slice!(cursor, 1) if cursor < query.length when KEY_BACKSPACE, "\b" if cursor > 0 query.slice!(cursor - 1, 1) cursor -= 1 end else if key.is_a?(String) and key.each_char.all?{ |char| char.ord.between?(32, 126) } query.insert(cursor, key) cursor += key.length end end cursor = 0 if cursor < 0 cursor = query.length if cursor > query.length result = block.call( key, query, cursor ) if block return result unless result.nil? end ensure $>.print CURSOR_INVISIBLE end end |
.row ⇒ Object
Current cursor row/column (see position).
420 |
# File 'lib/terminal.rb', line 420 def self.row; position.first end |
.size ⇒ Object
Terminal [rows, cols] (defaults to 24x80 when undetectable); width/height return the usable last column/row.
415 |
# File 'lib/terminal.rb', line 415 def self.size; IO.console&.winsize || [24, 80] end |
.slice_width(str, offset, width) ⇒ Object
Visible substring of str starting at display-width offset, at most
width cells wide. ANSI escapes are zero-width and carried through so
color state applies inside the window.
365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 |
# File 'lib/terminal.rb', line 365 def self.slice_width str, offset, width vis = 0 slice = +"" i = 0 while i < str.length if str[i] == "\e" seq = str[i..][/\A\e(\[[0-9;?]*[ -\/]*[@-~]|\][^\a\e]*(?:\a|\e\\)|[()=>0-9])/] slice << seq if seq i += seq ? seq.length : 1 next end cell = Unicode::DisplayWidth.of(str[i]) vis += cell slice << str[i] if vis > offset and vis <= offset + width i += 1 end slice end |
.text_width(str) ⇒ Object
Display width of str after stripping ANSI escapes.
358 359 360 |
# File 'lib/terminal.rb', line 358 def self.text_width str Unicode::DisplayWidth.of( str.gsub(/\x1b\[[^m]+m/, '') ) end |
.width ⇒ Object
416 |
# File 'lib/terminal.rb', line 416 def self.width; size.last-1 end |
.word_next(str, cursor) ⇒ Object
Move the cursor forward to the start of the next word in str.
392 393 394 395 396 |
# File 'lib/terminal.rb', line 392 def self.word_next str, cursor cursor += 1 while cursor < str.length and str[cursor] == ' ' cursor += 1 while cursor < str.length and str[cursor] != ' ' cursor end |
.word_prev(str, cursor) ⇒ Object
Move the cursor back to the start of the previous word in str.
385 386 387 388 389 |
# File 'lib/terminal.rb', line 385 def self.word_prev str, cursor cursor -= 1 while cursor > 0 and str[cursor - 1] == ' ' cursor -= 1 while cursor > 0 and str[cursor - 1] != ' ' cursor end |
Instance Method Details
#background(c = ) ⇒ Object
234 |
# File 'lib/terminal.rb', line 234 def background c=$default[1]; $color[1]=c; draw color_code(c,true) end |
#clip(str, max, side) ⇒ Object
Truncate str to at most max cells, keeping ANSI color tokens intact
and clipping from side (:left/:right).
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/terminal.rb', line 117 def clip str, max, side tokens = str.scan(/(\e\[[0-9;]*m)|([^\e]+)/).flatten.compact tokens.reverse! if side == :left kept, used = [], 0 tokens.each do |token| if token[0] == ?\e if side == :left kept << token unless kept.empty? break if used >= max else kept << token if used < max end next end break if used >= max size = Unicode::DisplayWidth.of token if used + size <= max kept << token used += size elsif used < max need = max - used chars = token.chars chars.reverse! if side == :left width = 0 out = "" chars.each do |char| width += Unicode::DisplayWidth.of char break if width > need out << char end out.reverse! if side == :left kept << out used = max end end kept.reverse!.join if side == :left kept.join end |
#coerce_type(value) ⇒ Object
Coerce a string into Integer, Float, or Boolean when it matches those forms (yes/no, true/false); otherwise return it unchanged.
182 183 184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/terminal.rb', line 182 def coerce_type value s = value.to_s return nil if s.empty? or /\A(?:nil|NULL)\z/i.match(s) case value when /^-?[\d]+$/ then s.to_i when /^-?\d*[\.\,]\d+$/ then s.to_f when /\byes\z/i then true when /\bno(ot)?\z/i then false when /true\z/i then true when /false\z/i then false else value end end |
#color(c = $default) ⇒ Object
235 236 237 238 239 |
# File 'lib/terminal.rb', line 235 def color c=$default c = [c] unless c.is_a? Array and c.count == 2 foreground c[0] if c[0] background c[1] if c[1] end |
#color_code(color, bg = false) ⇒ Object
Build the ANSI code for color (a Symbol name, greyN, Integer palette id,
or [r,g,b] / [fg,bg] Array), optionally as a background; when false the
color is emitted as a foreground.
209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/terminal.rb', line 209 def color_code color, bg=false color = color.to_sym if color.is_a? String case color when Symbol; if id = COLORS.index(color); "\e[#{ id + (bg ? 40 : 30) }m" elsif color[/^gr[ae]y\d{,2}$/] "\e[%i;5;%im" % [bg ? 48 : 38, 232 + (color[/\d+/].to_f/100*23).to_i] elsif id = COLOR_MAP[color] "\e[%i;5;%im" % [bg ? 48 : 38, id] end when Integer; "\e[%i;5;%im" % [ bg ? 48 : 38, color] when Array; case color.count when 3; "\e[%i;2;%i;%i;%im" % [ bg ? 48 : 38, *color ] when 2; color_code( color[0] ) + color_code( color[1], true ) end end end |
#draw(str) ⇒ Object
Write raw str to stdout.
242 |
# File 'lib/terminal.rb', line 242 def draw str; $>.print str end |
#fade(str, side = :left, num = 3) ⇒ Object
Blend the first (or last) num characters of str into greys to hint
at clipping; side selects which end fades.
158 159 160 161 162 163 164 165 166 167 |
# File 'lib/terminal.rb', line 158 def fade str, side=:left, num=3 isleft = side == :left chars = str[ isleft ? 0..num : -num..-1 ] chars = chars.chars.map.with_index do |char,id| id = chars.size-id unless isleft color_code( "grey#{(80/chars.size)*id+10}".to_sym ) + char end.join return ( isleft ? chars + color_code($color[0]) + str[num+1..-1] : str[0..-num-1] + chars ) end |
#foreground(c = ) ⇒ Object
Set and apply the foreground/background color; color sets both from a
[fg, bg] pair, a single color, or the module default.
233 |
# File 'lib/terminal.rb', line 233 def foreground c=$default[0]; $color[0]=c; draw color_code(c) end |
#get_background ⇒ Object
Read the current background/foreground color pair.
228 |
# File 'lib/terminal.rb', line 228 def get_background; $color[1] end |
#get_foreground ⇒ Object
229 |
# File 'lib/terminal.rb', line 229 def get_foreground; $color[0] end |
#mode(name) ⇒ Object
Apply a text attribute (+mode+); mode_code returns the escape sequence without writing it.
203 |
# File 'lib/terminal.rb', line 203 def mode name; draw mode_code(name) end |
#mode_code(name) ⇒ Object
204 |
# File 'lib/terminal.rb', line 204 def mode_code name; if id = MODES.index(name.to_s) then "\e[#{ id }m" end end |
#move(x = 0, y = 0) ⇒ Object
Move the cursor to column x, row y (0-based); move_code returns the
escape sequence without writing it.
198 |
# File 'lib/terminal.rb', line 198 def move x=0,y=0; draw move_code( x, y ) end |
#move_code(x = 0, y = 0) ⇒ Object
199 |
# File 'lib/terminal.rb', line 199 def move_code x=0,y=0; "\e[%i;%if" % [ y+1, x+1 ] end |
#prepare(str, max, align = :left, side = :right, fade = false) ⇒ Object
Pad or clip str to exactly max cells, honoring align (:left/:right),
trimming from side, with an optional trailing fade.
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/terminal.rb', line 90 def prepare str, max, align=:left, side=:right, fade=false stop = width = real_size( str ) unless str.ascii_only? and width == str.size stop = width = 0 str.each_char do |c| if c == ?\e width -= str[stop..-1][/^\x1b\[[^m]+m|/].size-1 stop += 1 elsif (cwidth = (c.ascii_only? ? 1 : Unicode::DisplayWidth.of(c))) + width > max break else width += cwidth; stop += 1 end end end if ( space = ( max - width ) ) > 0 str = [ str[0..stop-1], " " * space ] str.reverse! if align == :right str.join else str = clip str, max, side str = fade str, side, fade if fade return str end end |
#real_size(str) ⇒ Object
Display width of str after stripping ANSI escapes and control chars.
178 |
# File 'lib/terminal.rb', line 178 def real_size str; Unicode::DisplayWidth.of( sanitize(str) ) end |
#sanitize(str) ⇒ Object
Strip ANSI escape sequences and control characters (except \n, \t) from shell-generated text, leaving only printable content.
171 172 173 174 175 |
# File 'lib/terminal.rb', line 171 def sanitize str str.scrub .gsub(/\e(?:\[[0-9;?]*[ -\/]*[@-~]|\][^\a\e]*(?:\a|\e\\)|[()=><0A])/, '') .gsub(/[\x00-\x08\x0b-\x1f\x7f]/, '') end |