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.
-
.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. -
#printables(str) ⇒ Object
Strip ANSI escape sequences from
str. -
#real_size(str) ⇒ Object
Display width of
strafter stripping ANSI escapes.
Class Method Details
.clear(mode = :screen) ⇒ Object
Clear the whole screen (:screen) or just the current line (:line).
240 241 242 |
# File 'lib/terminal.rb', line 240 def self.clear mode=:screen $>.print( { screen: CLEAR_SCREEN, line: ERASE_LINE }[mode] ) end |
.column ⇒ Object
384 |
# File 'lib/terminal.rb', line 384 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.
270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 |
# File 'lib/terminal.rb', line 270 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.
403 404 405 406 407 408 409 410 411 |
# File 'lib/terminal.rb', line 403 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
380 |
# File 'lib/terminal.rb', line 380 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.
392 393 394 395 396 397 398 399 400 |
# File 'lib/terminal.rb', line 392 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).
387 388 389 |
# File 'lib/terminal.rb', line 387 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.
363 364 365 366 367 368 369 370 371 372 373 374 |
# File 'lib/terminal.rb', line 363 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.
250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 |
# File 'lib/terminal.rb', line 250 def self.read_key read = ->(tty) do str = tty.sysread 6 if str.start_with?("\e[<") # SGR mouse reports (CSI < b ; x ; y M/m) can exceed six bytes. str << tty.sysread(1) until str[-1].ord >= 0x40 elsif str.start_with?("\e[M") and str.size < 6 str << tty.sysread(6 - str.size) 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
304 305 306 307 308 309 310 311 312 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 |
# File 'lib/terminal.rb', line 304 def self.read_line prompt='', left: 0, top: 0, initial: '', &block return $stdin.gets&.chomp unless $stdin.tty? query, cursor = initial.dup, initial.length loop do $>.print "\e[%i;%if" % [ top + 1, left + 1 ] $>.print ERASE_LINE $>.print prompt + query $>.print "\e[%i;%if" % [ top + 1, left + 1 + text_width( prompt + query[0...cursor] ) ] 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 end |
.row ⇒ Object
Current cursor row/column (see position).
383 |
# File 'lib/terminal.rb', line 383 def self.row; position.first end |
.size ⇒ Object
Terminal [rows, cols] (defaults to 24x80 when undetectable); width/height return the usable last column/row.
378 |
# File 'lib/terminal.rb', line 378 def self.size; IO.console&.winsize || [24, 80] end |
.text_width(str) ⇒ Object
Display width of str after stripping ANSI escapes.
343 344 345 |
# File 'lib/terminal.rb', line 343 def self.text_width str Unicode::DisplayWidth.of( str.gsub(/\x1b\[[^m]+m/, '') ) end |
.width ⇒ Object
379 |
# File 'lib/terminal.rb', line 379 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.
355 356 357 358 359 |
# File 'lib/terminal.rb', line 355 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.
348 349 350 351 352 |
# File 'lib/terminal.rb', line 348 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
229 |
# File 'lib/terminal.rb', line 229 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.
177 178 179 180 181 182 183 184 185 186 187 188 189 |
# File 'lib/terminal.rb', line 177 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
230 231 232 233 234 |
# File 'lib/terminal.rb', line 230 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.
204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 |
# File 'lib/terminal.rb', line 204 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.
237 |
# File 'lib/terminal.rb', line 237 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.
228 |
# File 'lib/terminal.rb', line 228 def foreground c=$default[0]; $color[0]=c; draw color_code(c) end |
#get_background ⇒ Object
Read the current background/foreground color pair.
223 |
# File 'lib/terminal.rb', line 223 def get_background; $color[1] end |
#get_foreground ⇒ Object
224 |
# File 'lib/terminal.rb', line 224 def get_foreground; $color[0] end |
#mode(name) ⇒ Object
Apply a text attribute (+mode+); mode_code returns the escape sequence without writing it.
198 |
# File 'lib/terminal.rb', line 198 def mode name; draw mode_code(name) end |
#mode_code(name) ⇒ Object
199 |
# File 'lib/terminal.rb', line 199 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.
193 |
# File 'lib/terminal.rb', line 193 def move x=0,y=0; draw move_code( x, y ) end |
#move_code(x = 0, y = 0) ⇒ Object
194 |
# File 'lib/terminal.rb', line 194 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 |
#printables(str) ⇒ Object
Strip ANSI escape sequences from str.
170 |
# File 'lib/terminal.rb', line 170 def printables str; str.gsub(/\x1b\[[^m]+m|/,'') end |
#real_size(str) ⇒ Object
Display width of str after stripping ANSI escapes.
173 |
# File 'lib/terminal.rb', line 173 def real_size str; Unicode::DisplayWidth.of( printables(str) ) end |