typr 1.1.4 → 1.3.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
@@ -1,26 +1,92 @@
1
1
  require 'io/console'
2
- require 'readline'
3
2
  require 'unicode/display_width'
4
3
 
4
+ ## Terminal control: raw key input, line editing, cursor movement, ANSI colors
5
+ # and terminal geometry. Widgets get the instance helpers via `include Typr`;
6
+ # class-level helpers (Typr.width, Typr.read_key, ...) drive the whole screen.
7
+
5
8
  module Typr
6
9
 
10
+ # Raw /dev/tty used for key input; falls back to $stdin when unavailable.
7
11
  INPUT = (IO.new IO.sysopen("/dev/tty", "r")) rescue $stdin
8
- `infocmp -L1`.split.each{ |info| key,value = info[0..-2].split("=")
9
- if value
10
- value.gsub! '\E', '\e'
11
- value += "\\" if value[-1] == "\\"
12
- eval "%s=\"%s\"" % [key.upcase,value] if key
13
- end }
14
12
 
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
44
+
45
+ # Common key aliases and the line-erase escape sequence.
46
+ ERASE_LINE = "\e[K"
47
+ # Reset foreground/background to the terminal defaults.
48
+ ORIG_COLORS = "\e[39;49m"
15
49
  KEY_ESCAPE = "\e"
16
50
  KEY_RETURN = CARRIAGE_RETURN
17
51
  KEY_TAB = "\t"
18
52
  KEY_PAGEDOWN = KEY_NPAGE
19
53
  KEY_PAGEUP = KEY_PPAGE
20
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
+
77
+ # Text attributes (reset, bold, italic, ...) and named 8/256-color tables.
21
78
  MODES = %i[ reset bold italic underline slow fast invert ]
22
79
  COLORS = %i[ black red green yellow blue magenta cyan white ]
80
+ COLOR_MAP = {
81
+ brown: 130, orange: 208, lime: 118, pink: 218,
82
+ maroon: 52, navy: 18, teal: 30, olive: 100,
83
+ coral: 203, tan: 180,
84
+ dark_red: 88, dark_green: 22, dark_yellow: 58,
85
+ dark_blue: 18, dark_magenta: 89, dark_cyan: 30
86
+ }
23
87
 
88
+ # Pad or clip +str+ to exactly +max+ cells, honoring +align+ (:left/:right),
89
+ # trimming from +side+, with an optional trailing +fade+.
24
90
  def prepare str, max, align=:left, side=:right, fade=false
25
91
  stop = width = real_size( str )
26
92
  unless str.ascii_only? and width == str.size
@@ -30,7 +96,7 @@ module Typr
30
96
  width -= str[stop..-1][/^\x1b\[[^m]+m|/].size-1
31
97
  stop += 1
32
98
  elsif (cwidth = (c.ascii_only? ? 1 : Unicode::DisplayWidth.of(c))) +
33
- width >= max
99
+ width > max
34
100
  break
35
101
  else width += cwidth; stop += 1 end
36
102
  end
@@ -40,12 +106,55 @@ module Typr
40
106
  str.reverse! if align == :right
41
107
  str.join
42
108
  else
43
- str = str[side == :left ? -max..-1 : 0..max-1]
109
+ str = clip str, max, side
44
110
  str = fade str, side, fade if fade
45
111
  return str
46
112
  end
47
113
  end
48
114
 
115
+ # Truncate +str+ to at most +max+ cells, keeping ANSI color tokens intact
116
+ # and clipping from +side+ (:left/:right).
117
+ def clip str, max, side
118
+ tokens = str.scan(/(\e\[[0-9;]*m)|([^\e]+)/).flatten.compact
119
+ tokens.reverse! if side == :left
120
+ kept, used = [], 0
121
+ tokens.each do |token|
122
+ if token[0] == ?\e
123
+ if side == :left
124
+ kept << token unless kept.empty?
125
+ break if used >= max
126
+ else
127
+ kept << token if used < max
128
+ end
129
+ next
130
+ end
131
+ break if used >= max
132
+ size = Unicode::DisplayWidth.of token
133
+ if used + size <= max
134
+ kept << token
135
+ used += size
136
+ elsif used < max
137
+ need = max - used
138
+ chars = token.chars
139
+ chars.reverse! if side == :left
140
+ width = 0
141
+ out = ""
142
+ chars.each do |char|
143
+ width += Unicode::DisplayWidth.of char
144
+ break if width > need
145
+ out << char
146
+ end
147
+ out.reverse! if side == :left
148
+ kept << out
149
+ used = max
150
+ end
151
+ end
152
+ kept.reverse!.join if side == :left
153
+ kept.join
154
+ end
155
+
156
+ # Blend the first (or last) +num+ characters of +str+ into greys to hint
157
+ # at clipping; +side+ selects which end fades.
49
158
  def fade str, side=:left, num=3
50
159
  isleft = side == :left
51
160
  chars = str[ isleft ? 0..num : -num..-1 ]
@@ -57,9 +166,14 @@ module Typr
57
166
  str[0..-num-1] + chars )
58
167
  end
59
168
 
169
+ # Strip ANSI escape sequences from +str+.
60
170
  def printables str; str.gsub(/\x1b\[[^m]+m|/,'') end
171
+
172
+ # Display width of +str+ after stripping ANSI escapes.
61
173
  def real_size str; Unicode::DisplayWidth.of( printables(str) ) end
62
174
 
175
+ # Coerce a string into Integer, Float, or Boolean when it matches those
176
+ # forms (yes/no, true/false); otherwise return it unchanged.
63
177
  def coerce_type value
64
178
  s = value.to_s
65
179
  return nil if s.empty? or /\A(?:nil|NULL)\z/i.match(s)
@@ -74,12 +188,19 @@ module Typr
74
188
  end
75
189
  end
76
190
 
77
- def move x=0,y=0; show move_code( x, y ) end
191
+ # Move the cursor to column +x+, row +y+ (0-based); move_code returns the
192
+ # escape sequence without writing it.
193
+ def move x=0,y=0; draw move_code( x, y ) end
78
194
  def move_code x=0,y=0; "\e[%i;%if" % [ y+1, x+1 ] end
79
195
 
80
- def mode name; show mode_code(name) end
196
+ # Apply a text attribute (+mode+); mode_code returns the escape sequence
197
+ # without writing it.
198
+ def mode name; draw mode_code(name) end
81
199
  def mode_code name; if id = MODES.index(name.to_s) then "\e[#{ id }m" end end
82
200
 
201
+ # Build the ANSI code for +color+ (a Symbol name, greyN, Integer palette id,
202
+ # or [r,g,b] / [fg,bg] Array), optionally as a background; when false the
203
+ # color is emitted as a foreground.
83
204
  def color_code color, bg=false
84
205
  color = color.to_sym if color.is_a? String
85
206
  case color
@@ -87,6 +208,8 @@ module Typr
87
208
  if id = COLORS.index(color); "\e[#{ id + (bg ? 40 : 30) }m"
88
209
  elsif color[/^gr[ae]y\d{,2}$/]
89
210
  "\e[%i;5;%im" % [bg ? 48 : 38, 232 + (color[/\d+/].to_f/100*23).to_i]
211
+ elsif id = COLOR_MAP[color]
212
+ "\e[%i;5;%im" % [bg ? 48 : 38, id]
90
213
  end
91
214
  when Integer; "\e[%i;5;%im" % [ bg ? 48 : 38, color]
92
215
  when Array; case color.count
@@ -96,31 +219,147 @@ module Typr
96
219
  end
97
220
  end
98
221
 
222
+ # Read the current background/foreground color pair.
99
223
  def get_background; $color[1] end
100
224
  def get_foreground; $color[0] end
101
- def foreground c=$default[0]; $color[0]=c; show color_code(c) end
102
- def background c=$default[1]; $color[1]=c; show color_code(c,true) end
225
+
226
+ # Set and apply the foreground/background color; +color+ sets both from a
227
+ # [fg, bg] pair, a single color, or the module default.
228
+ def foreground c=$default[0]; $color[0]=c; draw color_code(c) end
229
+ def background c=$default[1]; $color[1]=c; draw color_code(c,true) end
103
230
  def color c=$default
104
231
  c = [c] unless c.is_a? Array and c.count == 2
105
232
  foreground c[0] if c[0]
106
233
  background c[1] if c[1]
107
234
  end
108
235
 
109
- def show str; $>.print str end
236
+ # Write raw +str+ to stdout.
237
+ def draw str; $>.print str end
238
+
239
+ # Clear the whole screen (:screen) or just the current line (:line).
110
240
  def self.clear mode=:screen
111
- $>.print( { screen: CLEAR_SCREEN, line: DELETE_LINE }[mode] )
241
+ $>.print( { screen: CLEAR_SCREEN, line: ERASE_LINE }[mode] )
112
242
  end
113
243
 
114
- def self.read obj, prompt=''
115
- case obj
116
- when :key
117
- return INPUT.raw{ |tty| tty.sysread 6 } rescue nil unless INPUT.tty?
118
- INPUT.raw{ |tty| tty.sysread 6 } rescue return
119
- when :line
120
- $stdin.tty? ? (line=Readline.readline prompt; print CURSOR_INVISIBLE; line) : $stdin.gets&.chomp
244
+ ## Reads a single keypress in raw mode.
245
+ #
246
+ # Returns the key sequence as a String (e.g. "a" or "\e[A" for up-arrow),
247
+ # or a Typr::Mouse for a mouse report, or nil when stdin is not a tty or
248
+ # input is unavailable.
249
+
250
+ def self.read_key
251
+ read = ->(tty) do
252
+ str = tty.sysread 6
253
+ if str.start_with?("\e[<")
254
+ # SGR mouse reports (CSI < b ; x ; y M/m) can exceed six bytes.
255
+ str << tty.sysread(1) until str[-1].ord >= 0x40
256
+ elsif str.start_with?("\e[M") and str.size < 6
257
+ str << tty.sysread(6 - str.size)
258
+ end
259
+ str
260
+ end
261
+ raw = (INPUT.raw{ |tty| read.call tty } rescue nil)
262
+ return unless raw
263
+ decode_mouse(raw) || raw
264
+ end
265
+
266
+ ## Decode a raw input string into a {Typr::Mouse} event, or nil when +raw+
267
+ # is not a mouse report. Handles both SGR (CSI < b ; x ; y M/m) and the
268
+ # legacy X10 (CSI M + three bytes) encodings.
269
+
270
+ def self.decode_mouse raw
271
+ if (m = raw.match(/\A\e\[<(\d+);(\d+);(\d+)([Mm])\z/))
272
+ code, x, y, final = m[1].to_i, m[2].to_i, m[3].to_i, m[4]
273
+ elsif raw.start_with?("\e[M")
274
+ code, x, y = raw.getbyte(3) - 32, raw.getbyte(4) - 32, raw.getbyte(5) - 32
275
+ final = code == 3 ? ?m : ?M
276
+ else
277
+ return nil
121
278
  end
279
+ modifiers = code & 28
280
+ base = code & ~28
281
+ motion = (base & 32) != 0
282
+ wheel = base >= 64
283
+ button = wheel ? base - 64 + 4 : base & 3
284
+ action = if motion then :motion
285
+ elsif wheel then final == ?m ? :release : :press
286
+ else ( final == ?m or code == 3 ) ? :release : :press end
287
+ Mouse.new button, x, y, action, modifiers
122
288
  end
123
289
 
290
+ ## Interactive line editor used for string prompts (search, %str, ...).
291
+ #
292
+ # Renders +prompt+ and the query at (left, top) and edits it with
293
+ # arrow keys, ctrl-arrow word jumps, home/end, delete and backspace.
294
+ # Returns the query on enter, nil on escape. A block may inspect each
295
+ # keypress after it is applied; a non-nil return short-circuits the
296
+ # editor and becomes its return value. When stdin is not a tty, the
297
+ # query is read from a single line of piped input instead.
298
+ #
299
+ # Typr.read_line "/" do |key, query, cursor|
300
+ # filter query
301
+ # nil
302
+ # end
303
+
304
+ def self.read_line prompt='', left: 0, top: 0, initial: '', &block
305
+ return $stdin.gets&.chomp unless $stdin.tty?
306
+ query, cursor = initial.dup, initial.length
307
+ loop do
308
+ $>.print "\e[%i;%if" % [ top + 1, left + 1 ]
309
+ $>.print ERASE_LINE
310
+ $>.print prompt + query
311
+ $>.print "\e[%i;%if" % [ top + 1, left + 1 +
312
+ text_width( prompt + query[0...cursor] ) ]
313
+ key = read_key
314
+ return nil if key.nil? or key == KEY_ESCAPE
315
+ return query if key == KEY_RETURN or key == "\n"
316
+ case key
317
+ when KEY_LEFT, "\e[D"; cursor -= 1 if cursor > 0
318
+ when KEY_RIGHT, "\e[C"; cursor += 1 if cursor < query.length
319
+ when "\e[1;5D", "\eOd"; cursor = word_prev query, cursor
320
+ when "\e[1;5C", "\eOc"; cursor = word_next query, cursor
321
+ when KEY_HOME; cursor = 0
322
+ when KEY_END; cursor = query.length
323
+ when KEY_DC, "\e[3~"; query.slice!(cursor, 1) if cursor < query.length
324
+ when KEY_BACKSPACE, "\b"
325
+ if cursor > 0
326
+ query.slice!(cursor - 1, 1)
327
+ cursor -= 1
328
+ end
329
+ else
330
+ if key.is_a?(String) and key.each_char.all?{ |char| char.ord.between?(32, 126) }
331
+ query.insert(cursor, key)
332
+ cursor += key.length
333
+ end
334
+ end
335
+ cursor = 0 if cursor < 0
336
+ cursor = query.length if cursor > query.length
337
+ result = block.call( key, query, cursor ) if block
338
+ return result unless result.nil?
339
+ end
340
+ end
341
+
342
+ # Display width of +str+ after stripping ANSI escapes.
343
+ def self.text_width str
344
+ Unicode::DisplayWidth.of( str.gsub(/\x1b\[[^m]+m/, '') )
345
+ end
346
+
347
+ # Move the cursor back to the start of the previous word in +str+.
348
+ def self.word_prev str, cursor
349
+ cursor -= 1 while cursor > 0 and str[cursor - 1] == ' '
350
+ cursor -= 1 while cursor > 0 and str[cursor - 1] != ' '
351
+ cursor
352
+ end
353
+
354
+ # Move the cursor forward to the start of the next word in +str+.
355
+ def self.word_next str, cursor
356
+ cursor += 1 while cursor < str.length and str[cursor] == ' '
357
+ cursor += 1 while cursor < str.length and str[cursor] != ' '
358
+ cursor
359
+ end
360
+
361
+ # Query the terminal for the current [row, column] via DSR; [0, 0] when
362
+ # stdin is not a tty.
124
363
  def self.position
125
364
  return [0, 0] unless $stdin.tty?
126
365
  result = ''
@@ -134,25 +373,39 @@ module Typr
134
373
  result[/[\d;]+/].split(?;).map &:to_i
135
374
  end
136
375
 
376
+ # Terminal [rows, cols] (defaults to 24x80 when undetectable); width/height
377
+ # return the usable last column/row.
137
378
  def self.size; IO.console&.winsize || [24, 80] end
138
379
  def self.width; size.last-1 end
139
380
  def self.height; size.first - 1 end
381
+
382
+ # Current cursor row/column (see position).
140
383
  def self.row; position.first end
141
384
  def self.column; position.last end
385
+
386
+ # Call +block+ whenever the terminal is resized (SIGWINCH).
142
387
  def self.on_resize &block
143
388
  trap(:WINCH, &block)
144
389
  end
390
+ # Enter interactive mode: seed the default colors, hide the cursor, enable
391
+ # key-mode (application) escapes and mouse reporting when stdin is a tty.
145
392
  def self.init default=[ :white, :black ]
146
- $color = $default = default
393
+ $default = default.dup
394
+ $color = $default.dup
147
395
  if $stdin.tty?
148
396
  print CURSOR_INVISIBLE
149
- system "tput smkx"
397
+ print KEYPAD_XMIT if defined?(KEYPAD_XMIT)
398
+ print MOUSE_ON
150
399
  end
151
400
  end
401
+ # Restore the terminal: show the cursor, reset keypad/colors, disable mouse
402
+ # reporting and clear.
152
403
  def self.exit;
153
404
  extend self
154
405
  if $stdin.tty?
155
406
  $>.print CURSOR_NORMAL;
407
+ $>.print KEYPAD_LOCAL if defined?(KEYPAD_LOCAL)
408
+ $>.print MOUSE_OFF
156
409
  $>.print ORIG_COLORS; color; clear
157
410
  end
158
411
  end
@@ -174,10 +427,10 @@ begin
174
427
  foreground color.to_sym;
175
428
  background colors[x].to_sym;
176
429
  move x*slice,y
177
- show chars
430
+ draw chars
178
431
  }
179
432
  }
180
- (0...256).each{ |i| background i; show ' ' }
433
+ (0...256).each{ |i| background i; draw ' ' }
181
434
 
182
435
  move 0, Typr.height
183
436
  color [:white, :black]
@@ -185,7 +438,7 @@ begin
185
438
  move 15, Typr.height
186
439
  color [fg,bg]
187
440
  print " %s / %s " % [fg,bg]
188
- key = Typr.read(:key)
441
+ key = Typr.read_key
189
442
  case key
190
443
  when KEY_UP; fg+=1
191
444
  when KEY_DOWN; fg-=1
data/lib/text.rb CHANGED
@@ -4,7 +4,7 @@ require_relative 'stack.rb'
4
4
  module Typr
5
5
 
6
6
  # A vertically scrollable text viewer with word-wrapping, header bar,
7
- # row selection via hint characters, and search.
7
+ # row selection via hint characters, and pager-style search (/ n p).
8
8
  #
9
9
  # Accepts String, IO, or StringIO input. Content is split into display
10
10
  # rows by newlines and line width, then rendered as a sliding window.
@@ -17,8 +17,8 @@ module Typr
17
17
  # top: 2, left: 0.1, right: 0.9, bottom: -4,
18
18
  # border: :round,
19
19
  # colors: { header: [:yellow, :grey30] }
20
- # )
21
- # text.draw
20
+ # )
21
+ # text.show
22
22
 
23
23
  class Text < Stack
24
24
 
@@ -68,6 +68,7 @@ module Typr
68
68
  end
69
69
  end
70
70
  @lines << @data.length unless @lines.last == @data.length
71
+ highlight if @re
71
72
  end
72
73
  alias :<< :build
73
74
 
@@ -80,7 +81,14 @@ module Typr
80
81
  def print row=nil
81
82
  header = ( row == :header )
82
83
  return super unless row and @lines[ row + 1 ] unless header
83
- show prepare( ( header ? @header : @page[ row-@start ] ), width )
84
+ text = ( header ? @header : @page[ row-@start ] )
85
+ if row.is_a?(Integer) and @search[:matches].include? row
86
+ background @colors[:search]
87
+ draw prepare( text, width )
88
+ background
89
+ else
90
+ draw prepare( text, width )
91
+ end
84
92
  end
85
93
 
86
94
  # Returns word-boundary column offsets for a given page row.
@@ -95,9 +103,9 @@ module Typr
95
103
  # Full draw cycle: rebuilds on resize, computes visible lines,
96
104
  # and renders the viewport with borders and hints.
97
105
  #
98
- # text.draw
106
+ # text.show
99
107
 
100
- def draw
108
+ def show
101
109
  (build; @lastwidth = width) if width != @lastwidth
102
110
  range = @lines[@start..@start+height]
103
111
  page = StringIO.new @data[range.first..range.last]
@@ -111,6 +119,62 @@ module Typr
111
119
 
112
120
  def rows; @lines.count end
113
121
 
122
+ ##
123
+ # Pager-style search over the buffer, modeled after less/vim.
124
+ #
125
+ # With a +query+ (String, matched literally, or Regexp) it stores the
126
+ # pattern, highlights every matching line, and scrolls to the first
127
+ # match from the current position. +dir+ is +:forward+ or +:backward+.
128
+ # Returns the matched line id, or nil when nothing matches.
129
+ #
130
+ # With no argument it prompts interactively for a pattern at the bottom
131
+ # of the screen (pressing Return searches, Escape aborts).
132
+ #
133
+ # Matching is smart-case: lowercase queries are case-insensitive;
134
+ # a query containing an uppercase letter is case-sensitive.
135
+ #
136
+ # text.search "needle" # => 12 (or nil)
137
+ # text.search /^TODO/ # => 3
138
+ # text.search "needle", :backward # search upward
139
+
140
+ def search query=nil, dir=:forward
141
+ return search_prompt if query.nil?
142
+ @search[:pattern] = query
143
+ @search[:dir] = dir
144
+ @search[:last] = nil
145
+ @re = query.is_a?(Regexp) ? query :
146
+ Regexp.new( Regexp.escape( query ),
147
+ ( query[/[A-Z]/] ? 0 : Regexp::IGNORECASE ) )
148
+ highlight
149
+ step
150
+ end
151
+
152
+ ## Jump to the next match of the last search (wrap-around). Returns its line id or nil.
153
+
154
+ def search_next; @search[:dir] = :forward; step end
155
+
156
+ ## Jump to the previous match of the last search (wrap-around). Returns its line id or nil.
157
+
158
+ def search_prev; @search[:dir] = :backward; step end
159
+
160
+ ## Recompute the set of lines containing the current pattern.
161
+
162
+ def highlight
163
+ @search[:matches] = @re ?
164
+ @lines[0...-1].each_index.select{ |i|
165
+ @data[ @lines[i] ... @lines[i+1] ][ @re ] } : []
166
+ return
167
+ end
168
+
169
+ ## Clear the search pattern and all match highlights.
170
+
171
+ def highlight_clear
172
+ @re = @search[:pattern] = nil
173
+ @search[:matches] = []
174
+ @search[:last] = nil
175
+ return
176
+ end
177
+
114
178
  # Creates a new Text widget.
115
179
  #
116
180
  # text = Typr::Text.new(input: "hello", header: "Title")
@@ -119,11 +183,69 @@ module Typr
119
183
  def initialize args={}
120
184
  @tabspace = 2
121
185
  @data, @lines, @tail = "", [0], ""
186
+ @search = { pattern: nil, dir: :forward, last: nil, matches: [] }
122
187
  super args
123
- @colors = { header: :yellow }.merge( @colors || {} )
188
+ @colors = { header: :yellow, search: :grey20 }.merge( @colors || {} )
189
+ @keymap = { search: ?/, search_next: ?n, search_prev: ?p }.merge @keymap
124
190
  @lastwidth = width
125
191
  self << @input
126
192
  end
193
+
194
+ # Reset state: +:search+ clears the pattern and highlights; +:all+ resets
195
+ # position, selection, and search; else falls through to {Stack#reset}.
196
+ def reset type=:all
197
+ case type
198
+ when :search; highlight_clear
199
+ when :all; reset [:position, :selection, :search]; return
200
+ end
201
+ super
202
+ end
203
+
204
+ private
205
+
206
+ # Re-apply search for a live query (empty clears the highlights).
207
+ def live_search query
208
+ query.empty? ? highlight_clear : search(query)
209
+ end
210
+
211
+ # Move to the next/previous match from the current position; returns the
212
+ # matched line id or nil when there are none.
213
+ def step
214
+ matches = @search[:matches]
215
+ return nil if matches.empty?
216
+ last = @search[:last]
217
+ if @search[:dir] == :backward
218
+ id = matches.reverse.find{ |m| last and m < last }
219
+ id ||= ( last ? matches.last : matches.reverse.find{ |m| m <= @start } || matches.last )
220
+ else
221
+ id = matches.find{ |m| last and m > last }
222
+ id ||= ( last ? matches.first : matches.find{ |m| m >= @start } || matches.first )
223
+ end
224
+ @search[:last] = id
225
+ @start = id
226
+ return id
227
+ end
228
+
229
+ # Interactive `/` prompt via Typr.read_line; Escape restores the previous
230
+ # search state, Enter searches (empty restores the last pattern).
231
+ def search_prompt
232
+ query = @search[:pattern].is_a?(String) ? @search[:pattern].dup : ''
233
+ saved = { re: @re, pattern: @search[:pattern],
234
+ matches: @search[:matches].dup, last: @search[:last], start: @start }
235
+ result = Typr.read_line '/', left: left, top: Typr.height - 1, initial: query,
236
+ &->(key, query, _) {
237
+ live_search query
238
+ nil
239
+ }
240
+ if result
241
+ return search(result) unless result.empty?
242
+ return search(@search[:pattern]) if @search[:pattern]
243
+ end
244
+ @re = saved[:re]; @search[:pattern] = saved[:pattern]
245
+ @search[:matches] = saved[:matches]; @search[:last] = saved[:last]
246
+ @start = saved[:start]
247
+ return
248
+ end
127
249
  end
128
250
 
129
251
  end
data/lib/typr.rb CHANGED
@@ -1,8 +1,7 @@
1
1
  # typr is a Ruby library for building interactive terminal-based user interfaces.
2
- # It provides layout primitives, widgets, and event-driven interaction built on
3
- # top of curses.
2
+ # It provides layout primitives, widgets, and event-driven interaction.
4
3
  #
5
- # == Widgets
4
+ # == Classes
6
5
  #
7
6
  # * Typr::Grid - Sortable, filterable table with formatted columns and row selection
8
7
  # * Typr::Text - Scrollable text viewer with word/line picking and search
@@ -13,16 +12,16 @@
13
12
  #
14
13
  # == Terminal Control
15
14
  #
16
- # Typr.init # Initialize terminal (hide cursor, enable key mode)
15
+ # Typr.init # Initialize terminal (hide cursor, enable key mode and mouse reporting)
17
16
  # Typr.clear # Clear entire screen
18
17
  # Typr.clear :line # Clear current line only
19
- # key = Typr.read(:key) # Read a single keypress
20
- # line = Typr.read(:line) # Read a line of text input
18
+ # key = Typr.read_key # Read a single keypress
19
+ # line = Typr.read_line "> " # Edit a line of text (enter to accept, esc to abort)
21
20
  # Typr.exit # Restore terminal state
22
21
  #
23
22
  # == Layout Properties
24
23
  #
25
- # All widgets accept +left+, +top+, +right+, +bottom+ as:
24
+ # All classes accept +left+, +top+, +right+, +bottom+ as:
26
25
  # Integer - absolute position
27
26
  # Float - fraction of terminal (0.0..1.0)
28
27
  # Proc - evaluated each layout pass
@@ -38,8 +37,8 @@
38
37
  # header: ['Name', 'Age'],
39
38
  # format: [:max, :right]
40
39
  # )
41
- # grid.draw
42
- # key = Typr.read(:key)
40
+ # grid.show
41
+ # key = Typr.read_key
43
42
  # Typr.exit
44
43
  #
45
44
  # == Installation