typr 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/terminal.rb CHANGED
@@ -10,22 +10,70 @@ module Typr
10
10
  # Raw /dev/tty used for key input; falls back to $stdin when unavailable.
11
11
  INPUT = (IO.new IO.sysopen("/dev/tty", "r")) rescue $stdin
12
12
 
13
- # KEY_* escape sequences come from the terminfo database (infocmp -L1).
14
- `infocmp -L1`.split.each{ |info| key,value = info[0..-2].split("=")
15
- if value
16
- value.gsub! '\E', '\e'
17
- value += "\\" if value[-1] == "\\"
18
- eval "%s=\"%s\"" % [key.upcase,value] if key
19
- end }
13
+ # KEY_* escapes and keypad sequences are precompiled into share/terminfo by
14
+ # bin/build_terminfo, so the runtime needs no ncurses/infocmp (e.g. Termux).
15
+ TERMINFO = begin
16
+ eval File.read(File.expand_path("../share/terminfo", __dir__))
17
+ rescue StandardError
18
+ {}
19
+ end
20
+ # Known-good fallback so KEY_* are always defined, even for TERM=dumb.
21
+ DEFAULT_KEYS = { "key_up" => "\eOA", "key_down" => "\eOB", "key_left" => "\eOD",
22
+ "key_right" => "\eOC", "key_home" => "\eOH", "key_end" => "\eOF",
23
+ "key_backspace" => "\x7f", "key_dc" => "\e[3~", "key_npage" => "\e[6~",
24
+ "key_ppage" => "\e[5~", "carriage_return" => "\r",
25
+ "keypad_xmit" => "\e[?1h\e=", "keypad_local" => "\e[?1l\e>",
26
+ "cursor_invisible" => "\e[?25l", "cursor_normal" => "\e[?25h",
27
+ "clear_screen" => "\e[H\e[2J", "user7" => "\e[6n" }
28
+ term = ENV["TERM"].to_s
29
+ entry = nil
30
+ [ term, term.sub(/-.*/, ""), "xterm" ].each do |name|
31
+ found = TERMINFO[name]
32
+ if found && found.any? { |cap, _| cap.start_with?("key_") }
33
+ entry = found
34
+ break
35
+ end
36
+ end
37
+ entry ||= {}
38
+ # Fill gaps with safe ANSI defaults, but never fabricate keypad modes:
39
+ # those must match the terminal exactly or arrows get misread.
40
+ DEFAULT_KEYS.each do |cap, seq|
41
+ next if cap.start_with?("keypad_") && !entry[cap]
42
+ const_set cap.upcase, entry[cap] || seq
43
+ end
20
44
 
21
45
  # Common key aliases and the line-erase escape sequence.
22
46
  ERASE_LINE = "\e[K"
47
+ # Reset foreground/background to the terminal defaults.
48
+ ORIG_COLORS = "\e[39;49m"
23
49
  KEY_ESCAPE = "\e"
24
50
  KEY_RETURN = CARRIAGE_RETURN
25
51
  KEY_TAB = "\t"
26
52
  KEY_PAGEDOWN = KEY_NPAGE
27
53
  KEY_PAGEUP = KEY_PPAGE
28
54
 
55
+ # Enable/disable mouse reporting. Universal private modes, independent of
56
+ # terminfo: 1000 = button press/release/wheel, 1006 = SGR coordinates.
57
+ MOUSE_ON = "\e[?1000h\e[?1006h"
58
+ MOUSE_OFF = "\e[?1000l\e[?1006l"
59
+
60
+ # A mouse event decoded from the input stream. +x+ / +y+ are 1-based
61
+ # terminal coordinates; +button+ is 0=left, 1=middle, 2=right, or 4-7 for
62
+ # the wheel (up/down/left/right); +modifiers+ holds the SGR shift/alt/ctrl
63
+ # bits. Non-widget code can test `key.is_a?(Typr::Mouse)`.
64
+ Mouse = Struct.new(:button, :x, :y, :action, :modifiers) do
65
+ def press?; action == :press end
66
+ def release?; action == :release end
67
+ def wheel?; button.between?(4, 7) end
68
+ def wheel_up?; button == 4 end
69
+ def wheel_down?; button == 5 end
70
+ def left?; button == 0 end
71
+ def middle?; button == 1 end
72
+ def right?; button == 2 end
73
+ def to_s; "%s button=%i x=%i y=%i%s" % [ action, button, x, y,
74
+ ( modifiers > 0 ? " modifiers=#{modifiers}" : "" ) ] end
75
+ end
76
+
29
77
  # Text attributes (reset, bold, italic, ...) and named 8/256-color tables.
30
78
  MODES = %i[ reset bold italic underline slow fast invert ]
31
79
  COLORS = %i[ black red green yellow blue magenta cyan white ]
@@ -118,11 +166,16 @@ module Typr
118
166
  str[0..-num-1] + chars )
119
167
  end
120
168
 
121
- # Strip ANSI escape sequences from +str+.
122
- def printables str; str.gsub(/\x1b\[[^m]+m|/,'') end
169
+ # Strip ANSI escape sequences and control characters (except \n, \t)
170
+ # from shell-generated text, leaving only printable content.
171
+ def sanitize str
172
+ str.scrub
173
+ .gsub(/\e(?:\[[0-9;?]*[ -\/]*[@-~]|\][^\a\e]*(?:\a|\e\\)|[()=><0A])/, '')
174
+ .gsub(/[\x00-\x08\x0b-\x1f\x7f]/, '')
175
+ end
123
176
 
124
- # Display width of +str+ after stripping ANSI escapes.
125
- def real_size str; Unicode::DisplayWidth.of( printables(str) ) end
177
+ # Display width of +str+ after stripping ANSI escapes and control chars.
178
+ def real_size str; Unicode::DisplayWidth.of( sanitize(str) ) end
126
179
 
127
180
  # Coerce a string into Integer, Float, or Boolean when it matches those
128
181
  # forms (yes/no, true/false); otherwise return it unchanged.
@@ -196,11 +249,51 @@ module Typr
196
249
  ## Reads a single keypress in raw mode.
197
250
  #
198
251
  # Returns the key sequence as a String (e.g. "a" or "\e[A" for up-arrow),
199
- # or nil when stdin is not a tty or input is unavailable.
252
+ # or a Typr::Mouse for a mouse report, or nil when stdin is not a tty or
253
+ # input is unavailable.
200
254
 
201
255
  def self.read_key
202
- return INPUT.raw{ |tty| tty.sysread 6 } rescue nil unless INPUT.tty?
203
- INPUT.raw{ |tty| tty.sysread 6 } rescue return
256
+ read = ->(tty) do
257
+ str = tty.sysread 6
258
+ if str.start_with?("\e[M") and str.size < 6
259
+ str << tty.sysread(6 - str.size) # X10 mouse reports
260
+ elsif str.start_with?("\e[")
261
+ # CSI sequences (arrows, SGR mouse, ...) end with a byte >= 0x40.
262
+ str << tty.sysread(1) until str[-1].ord >= 0x40
263
+ elsif str.start_with?("\eO") and str.size < 3
264
+ str << tty.sysread(3 - str.size) # SS3 (application) cursor keys
265
+ elsif str == "\e" and IO.select([tty], nil, nil, 0.03)
266
+ str << tty.read_nonblock(6) rescue nil # alt+key continuation
267
+ end
268
+ str
269
+ end
270
+ raw = (INPUT.raw{ |tty| read.call tty } rescue nil)
271
+ return unless raw
272
+ decode_mouse(raw) || raw
273
+ end
274
+
275
+ ## Decode a raw input string into a {Typr::Mouse} event, or nil when +raw+
276
+ # is not a mouse report. Handles both SGR (CSI < b ; x ; y M/m) and the
277
+ # legacy X10 (CSI M + three bytes) encodings.
278
+
279
+ def self.decode_mouse raw
280
+ if (m = raw.match(/\A\e\[<(\d+);(\d+);(\d+)([Mm])\z/))
281
+ code, x, y, final = m[1].to_i, m[2].to_i, m[3].to_i, m[4]
282
+ elsif raw.start_with?("\e[M")
283
+ code, x, y = raw.getbyte(3) - 32, raw.getbyte(4) - 32, raw.getbyte(5) - 32
284
+ final = code == 3 ? ?m : ?M
285
+ else
286
+ return nil
287
+ end
288
+ modifiers = code & 28
289
+ base = code & ~28
290
+ motion = (base & 32) != 0
291
+ wheel = base >= 64
292
+ button = wheel ? base - 64 + 4 : base & 3
293
+ action = if motion then :motion
294
+ elsif wheel then final == ?m ? :release : :press
295
+ else ( final == ?m or code == 3 ) ? :release : :press end
296
+ Mouse.new button, x, y, action, modifiers
204
297
  end
205
298
 
206
299
  ## Interactive line editor used for string prompts (search, %str, ...).
@@ -220,38 +313,44 @@ module Typr
220
313
  def self.read_line prompt='', left: 0, top: 0, initial: '', &block
221
314
  return $stdin.gets&.chomp unless $stdin.tty?
222
315
  query, cursor = initial.dup, initial.length
223
- loop do
224
- $>.print "\e[%i;%if" % [ top + 1, left + 1 ]
225
- $>.print ERASE_LINE
226
- $>.print prompt + query
227
- $>.print "\e[%i;%if" % [ top + 1, left + 1 +
228
- text_width( prompt + query[0...cursor] ) ]
229
- key = read_key
230
- return nil if key.nil? or key == KEY_ESCAPE
231
- return query if key == KEY_RETURN or key == "\n"
232
- case key
233
- when KEY_LEFT, "\e[D"; cursor -= 1 if cursor > 0
234
- when KEY_RIGHT, "\e[C"; cursor += 1 if cursor < query.length
235
- when "\e[1;5D", "\eOd"; cursor = word_prev query, cursor
236
- when "\e[1;5C", "\eOc"; cursor = word_next query, cursor
237
- when KEY_HOME; cursor = 0
238
- when KEY_END; cursor = query.length
239
- when KEY_DC, "\e[3~"; query.slice!(cursor, 1) if cursor < query.length
240
- when KEY_BACKSPACE, "\b"
241
- if cursor > 0
242
- query.slice!(cursor - 1, 1)
243
- cursor -= 1
244
- end
245
- else
246
- if key.is_a?(String) and key.each_char.all?{ |char| char.ord.between?(32, 126) }
247
- query.insert(cursor, key)
248
- cursor += key.length
249
- end
316
+ $>.print CURSOR_NORMAL
317
+ begin
318
+ loop do
319
+ $>.print "\e[%i;%if" % [ top + 1, left + 1 ]
320
+ $>.print ERASE_LINE
321
+ before = text_width( prompt + query[0...cursor] )
322
+ offset = [ left + before - Typr.width, 0 ].max
323
+ $>.print slice_width( prompt + query, offset, Typr.width - left + 1 )
324
+ $>.print "\e[%i;%if" % [ top + 1, left + 1 + before - offset ]
325
+ key = read_key
326
+ return nil if key.nil? or key == KEY_ESCAPE
327
+ return query if key == KEY_RETURN or key == "\n"
328
+ case key
329
+ when KEY_LEFT, "\e[D"; cursor -= 1 if cursor > 0
330
+ when KEY_RIGHT, "\e[C"; cursor += 1 if cursor < query.length
331
+ when "\e[1;5D", "\eOd"; cursor = word_prev query, cursor
332
+ when "\e[1;5C", "\eOc"; cursor = word_next query, cursor
333
+ when KEY_HOME; cursor = 0
334
+ when KEY_END; cursor = query.length
335
+ when KEY_DC, "\e[3~"; query.slice!(cursor, 1) if cursor < query.length
336
+ when KEY_BACKSPACE, "\b"
337
+ if cursor > 0
338
+ query.slice!(cursor - 1, 1)
339
+ cursor -= 1
340
+ end
341
+ else
342
+ if key.is_a?(String) and key.each_char.all?{ |char| char.ord.between?(32, 126) }
343
+ query.insert(cursor, key)
344
+ cursor += key.length
345
+ end
346
+ end
347
+ cursor = 0 if cursor < 0
348
+ cursor = query.length if cursor > query.length
349
+ result = block.call( key, query, cursor ) if block
350
+ return result unless result.nil?
250
351
  end
251
- cursor = 0 if cursor < 0
252
- cursor = query.length if cursor > query.length
253
- result = block.call( key, query, cursor ) if block
254
- return result unless result.nil?
352
+ ensure
353
+ $>.print CURSOR_INVISIBLE
255
354
  end
256
355
  end
257
356
 
@@ -260,6 +359,28 @@ module Typr
260
359
  Unicode::DisplayWidth.of( str.gsub(/\x1b\[[^m]+m/, '') )
261
360
  end
262
361
 
362
+ # Visible substring of +str+ starting at display-width +offset+, at most
363
+ # +width+ cells wide. ANSI escapes are zero-width and carried through so
364
+ # color state applies inside the window.
365
+ def self.slice_width str, offset, width
366
+ vis = 0
367
+ slice = +""
368
+ i = 0
369
+ while i < str.length
370
+ if str[i] == "\e"
371
+ seq = str[i..][/\A\e(\[[0-9;?]*[ -\/]*[@-~]|\][^\a\e]*(?:\a|\e\\)|[()=>0-9])/]
372
+ slice << seq if seq
373
+ i += seq ? seq.length : 1
374
+ next
375
+ end
376
+ cell = Unicode::DisplayWidth.of(str[i])
377
+ vis += cell
378
+ slice << str[i] if vis > offset and vis <= offset + width
379
+ i += 1
380
+ end
381
+ slice
382
+ end
383
+
263
384
  # Move the cursor back to the start of the previous word in +str+.
264
385
  def self.word_prev str, cursor
265
386
  cursor -= 1 while cursor > 0 and str[cursor - 1] == ' '
@@ -303,21 +424,25 @@ module Typr
303
424
  def self.on_resize &block
304
425
  trap(:WINCH, &block)
305
426
  end
306
- # Enter interactive mode: seed the default colors, hide the cursor and
307
- # enable key-mode (application) escapes when stdin is a tty.
427
+ # Enter interactive mode: seed the default colors, hide the cursor, enable
428
+ # key-mode (application) escapes and mouse reporting when stdin is a tty.
308
429
  def self.init default=[ :white, :black ]
309
430
  $default = default.dup
310
431
  $color = $default.dup
311
432
  if $stdin.tty?
312
433
  print CURSOR_INVISIBLE
313
- system "tput smkx"
434
+ print KEYPAD_XMIT if defined?(KEYPAD_XMIT)
435
+ print MOUSE_ON
314
436
  end
315
437
  end
316
- # Restore the terminal: show the cursor, reset colors and clear the screen.
438
+ # Restore the terminal: show the cursor, reset keypad/colors, disable mouse
439
+ # reporting and clear.
317
440
  def self.exit;
318
441
  extend self
319
442
  if $stdin.tty?
320
443
  $>.print CURSOR_NORMAL;
444
+ $>.print KEYPAD_LOCAL if defined?(KEYPAD_LOCAL)
445
+ $>.print MOUSE_OFF
321
446
  $>.print ORIG_COLORS; color; clear
322
447
  end
323
448
  end
data/lib/text.rb CHANGED
@@ -191,6 +191,8 @@ module Typr
191
191
  self << @input
192
192
  end
193
193
 
194
+ # Reset state: +:search+ clears the pattern and highlights; +:all+ resets
195
+ # position, selection, and search; else falls through to {Stack#reset}.
194
196
  def reset type=:all
195
197
  case type
196
198
  when :search; highlight_clear
@@ -201,10 +203,13 @@ module Typr
201
203
 
202
204
  private
203
205
 
206
+ # Re-apply search for a live query (empty clears the highlights).
204
207
  def live_search query
205
208
  query.empty? ? highlight_clear : search(query)
206
209
  end
207
210
 
211
+ # Move to the next/previous match from the current position; returns the
212
+ # matched line id or nil when there are none.
208
213
  def step
209
214
  matches = @search[:matches]
210
215
  return nil if matches.empty?
@@ -221,6 +226,8 @@ module Typr
221
226
  return id
222
227
  end
223
228
 
229
+ # Interactive `/` prompt via Typr.read_line; Escape restores the previous
230
+ # search state, Enter searches (empty restores the last pattern).
224
231
  def search_prompt
225
232
  query = @search[:pattern].is_a?(String) ? @search[:pattern].dup : ''
226
233
  saved = { re: @re, pattern: @search[:pattern],
data/lib/typr.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # typr is a Ruby library for building interactive terminal-based user interfaces.
2
2
  # It provides layout primitives, widgets, and event-driven interaction.
3
3
  #
4
- # == Widgets
4
+ # == Classes
5
5
  #
6
6
  # * Typr::Grid - Sortable, filterable table with formatted columns and row selection
7
7
  # * Typr::Text - Scrollable text viewer with word/line picking and search
@@ -12,7 +12,7 @@
12
12
  #
13
13
  # == Terminal Control
14
14
  #
15
- # Typr.init # Initialize terminal (hide cursor, enable key mode)
15
+ # Typr.init # Initialize terminal (hide cursor, enable key mode and mouse reporting)
16
16
  # Typr.clear # Clear entire screen
17
17
  # Typr.clear :line # Clear current line only
18
18
  # key = Typr.read_key # Read a single keypress
@@ -21,7 +21,7 @@
21
21
  #
22
22
  # == Layout Properties
23
23
  #
24
- # All widgets accept +left+, +top+, +right+, +bottom+ as:
24
+ # All classes accept +left+, +top+, +right+, +bottom+ as:
25
25
  # Integer - absolute position
26
26
  # Float - fraction of terminal (0.0..1.0)
27
27
  # Proc - evaluated each layout pass