typr 1.1.4 → 1.2.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,10 +1,16 @@
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
12
+
13
+ # KEY_* escape sequences come from the terminfo database (infocmp -L1).
8
14
  `infocmp -L1`.split.each{ |info| key,value = info[0..-2].split("=")
9
15
  if value
10
16
  value.gsub! '\E', '\e'
@@ -12,15 +18,27 @@ module Typr
12
18
  eval "%s=\"%s\"" % [key.upcase,value] if key
13
19
  end }
14
20
 
21
+ # Common key aliases and the line-erase escape sequence.
22
+ ERASE_LINE = "\e[K"
15
23
  KEY_ESCAPE = "\e"
16
24
  KEY_RETURN = CARRIAGE_RETURN
17
25
  KEY_TAB = "\t"
18
26
  KEY_PAGEDOWN = KEY_NPAGE
19
27
  KEY_PAGEUP = KEY_PPAGE
20
28
 
29
+ # Text attributes (reset, bold, italic, ...) and named 8/256-color tables.
21
30
  MODES = %i[ reset bold italic underline slow fast invert ]
22
31
  COLORS = %i[ black red green yellow blue magenta cyan white ]
32
+ COLOR_MAP = {
33
+ brown: 130, orange: 208, lime: 118, pink: 218,
34
+ maroon: 52, navy: 18, teal: 30, olive: 100,
35
+ coral: 203, tan: 180,
36
+ dark_red: 88, dark_green: 22, dark_yellow: 58,
37
+ dark_blue: 18, dark_magenta: 89, dark_cyan: 30
38
+ }
23
39
 
40
+ # Pad or clip +str+ to exactly +max+ cells, honoring +align+ (:left/:right),
41
+ # trimming from +side+, with an optional trailing +fade+.
24
42
  def prepare str, max, align=:left, side=:right, fade=false
25
43
  stop = width = real_size( str )
26
44
  unless str.ascii_only? and width == str.size
@@ -30,7 +48,7 @@ module Typr
30
48
  width -= str[stop..-1][/^\x1b\[[^m]+m|/].size-1
31
49
  stop += 1
32
50
  elsif (cwidth = (c.ascii_only? ? 1 : Unicode::DisplayWidth.of(c))) +
33
- width >= max
51
+ width > max
34
52
  break
35
53
  else width += cwidth; stop += 1 end
36
54
  end
@@ -40,12 +58,55 @@ module Typr
40
58
  str.reverse! if align == :right
41
59
  str.join
42
60
  else
43
- str = str[side == :left ? -max..-1 : 0..max-1]
61
+ str = clip str, max, side
44
62
  str = fade str, side, fade if fade
45
63
  return str
46
64
  end
47
65
  end
48
66
 
67
+ # Truncate +str+ to at most +max+ cells, keeping ANSI color tokens intact
68
+ # and clipping from +side+ (:left/:right).
69
+ def clip str, max, side
70
+ tokens = str.scan(/(\e\[[0-9;]*m)|([^\e]+)/).flatten.compact
71
+ tokens.reverse! if side == :left
72
+ kept, used = [], 0
73
+ tokens.each do |token|
74
+ if token[0] == ?\e
75
+ if side == :left
76
+ kept << token unless kept.empty?
77
+ break if used >= max
78
+ else
79
+ kept << token if used < max
80
+ end
81
+ next
82
+ end
83
+ break if used >= max
84
+ size = Unicode::DisplayWidth.of token
85
+ if used + size <= max
86
+ kept << token
87
+ used += size
88
+ elsif used < max
89
+ need = max - used
90
+ chars = token.chars
91
+ chars.reverse! if side == :left
92
+ width = 0
93
+ out = ""
94
+ chars.each do |char|
95
+ width += Unicode::DisplayWidth.of char
96
+ break if width > need
97
+ out << char
98
+ end
99
+ out.reverse! if side == :left
100
+ kept << out
101
+ used = max
102
+ end
103
+ end
104
+ kept.reverse!.join if side == :left
105
+ kept.join
106
+ end
107
+
108
+ # Blend the first (or last) +num+ characters of +str+ into greys to hint
109
+ # at clipping; +side+ selects which end fades.
49
110
  def fade str, side=:left, num=3
50
111
  isleft = side == :left
51
112
  chars = str[ isleft ? 0..num : -num..-1 ]
@@ -57,9 +118,14 @@ module Typr
57
118
  str[0..-num-1] + chars )
58
119
  end
59
120
 
121
+ # Strip ANSI escape sequences from +str+.
60
122
  def printables str; str.gsub(/\x1b\[[^m]+m|/,'') end
123
+
124
+ # Display width of +str+ after stripping ANSI escapes.
61
125
  def real_size str; Unicode::DisplayWidth.of( printables(str) ) end
62
126
 
127
+ # Coerce a string into Integer, Float, or Boolean when it matches those
128
+ # forms (yes/no, true/false); otherwise return it unchanged.
63
129
  def coerce_type value
64
130
  s = value.to_s
65
131
  return nil if s.empty? or /\A(?:nil|NULL)\z/i.match(s)
@@ -74,12 +140,19 @@ module Typr
74
140
  end
75
141
  end
76
142
 
77
- def move x=0,y=0; show move_code( x, y ) end
143
+ # Move the cursor to column +x+, row +y+ (0-based); move_code returns the
144
+ # escape sequence without writing it.
145
+ def move x=0,y=0; draw move_code( x, y ) end
78
146
  def move_code x=0,y=0; "\e[%i;%if" % [ y+1, x+1 ] end
79
147
 
80
- def mode name; show mode_code(name) end
148
+ # Apply a text attribute (+mode+); mode_code returns the escape sequence
149
+ # without writing it.
150
+ def mode name; draw mode_code(name) end
81
151
  def mode_code name; if id = MODES.index(name.to_s) then "\e[#{ id }m" end end
82
152
 
153
+ # Build the ANSI code for +color+ (a Symbol name, greyN, Integer palette id,
154
+ # or [r,g,b] / [fg,bg] Array), optionally as a background; when false the
155
+ # color is emitted as a foreground.
83
156
  def color_code color, bg=false
84
157
  color = color.to_sym if color.is_a? String
85
158
  case color
@@ -87,6 +160,8 @@ module Typr
87
160
  if id = COLORS.index(color); "\e[#{ id + (bg ? 40 : 30) }m"
88
161
  elsif color[/^gr[ae]y\d{,2}$/]
89
162
  "\e[%i;5;%im" % [bg ? 48 : 38, 232 + (color[/\d+/].to_f/100*23).to_i]
163
+ elsif id = COLOR_MAP[color]
164
+ "\e[%i;5;%im" % [bg ? 48 : 38, id]
90
165
  end
91
166
  when Integer; "\e[%i;5;%im" % [ bg ? 48 : 38, color]
92
167
  when Array; case color.count
@@ -96,31 +171,111 @@ module Typr
96
171
  end
97
172
  end
98
173
 
174
+ # Read the current background/foreground color pair.
99
175
  def get_background; $color[1] end
100
176
  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
177
+
178
+ # Set and apply the foreground/background color; +color+ sets both from a
179
+ # [fg, bg] pair, a single color, or the module default.
180
+ def foreground c=$default[0]; $color[0]=c; draw color_code(c) end
181
+ def background c=$default[1]; $color[1]=c; draw color_code(c,true) end
103
182
  def color c=$default
104
183
  c = [c] unless c.is_a? Array and c.count == 2
105
184
  foreground c[0] if c[0]
106
185
  background c[1] if c[1]
107
186
  end
108
187
 
109
- def show str; $>.print str end
188
+ # Write raw +str+ to stdout.
189
+ def draw str; $>.print str end
190
+
191
+ # Clear the whole screen (:screen) or just the current line (:line).
110
192
  def self.clear mode=:screen
111
- $>.print( { screen: CLEAR_SCREEN, line: DELETE_LINE }[mode] )
193
+ $>.print( { screen: CLEAR_SCREEN, line: ERASE_LINE }[mode] )
112
194
  end
113
195
 
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
196
+ ## Reads a single keypress in raw mode.
197
+ #
198
+ # 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.
200
+
201
+ 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
204
+ end
205
+
206
+ ## Interactive line editor used for string prompts (search, %str, ...).
207
+ #
208
+ # Renders +prompt+ and the query at (left, top) and edits it with
209
+ # arrow keys, ctrl-arrow word jumps, home/end, delete and backspace.
210
+ # Returns the query on enter, nil on escape. A block may inspect each
211
+ # keypress after it is applied; a non-nil return short-circuits the
212
+ # editor and becomes its return value. When stdin is not a tty, the
213
+ # query is read from a single line of piped input instead.
214
+ #
215
+ # Typr.read_line "/" do |key, query, cursor|
216
+ # filter query
217
+ # nil
218
+ # end
219
+
220
+ def self.read_line prompt='', left: 0, top: 0, initial: '', &block
221
+ return $stdin.gets&.chomp unless $stdin.tty?
222
+ 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
250
+ 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?
121
255
  end
122
256
  end
123
257
 
258
+ # Display width of +str+ after stripping ANSI escapes.
259
+ def self.text_width str
260
+ Unicode::DisplayWidth.of( str.gsub(/\x1b\[[^m]+m/, '') )
261
+ end
262
+
263
+ # Move the cursor back to the start of the previous word in +str+.
264
+ def self.word_prev str, cursor
265
+ cursor -= 1 while cursor > 0 and str[cursor - 1] == ' '
266
+ cursor -= 1 while cursor > 0 and str[cursor - 1] != ' '
267
+ cursor
268
+ end
269
+
270
+ # Move the cursor forward to the start of the next word in +str+.
271
+ def self.word_next str, cursor
272
+ cursor += 1 while cursor < str.length and str[cursor] == ' '
273
+ cursor += 1 while cursor < str.length and str[cursor] != ' '
274
+ cursor
275
+ end
276
+
277
+ # Query the terminal for the current [row, column] via DSR; [0, 0] when
278
+ # stdin is not a tty.
124
279
  def self.position
125
280
  return [0, 0] unless $stdin.tty?
126
281
  result = ''
@@ -134,21 +289,31 @@ module Typr
134
289
  result[/[\d;]+/].split(?;).map &:to_i
135
290
  end
136
291
 
292
+ # Terminal [rows, cols] (defaults to 24x80 when undetectable); width/height
293
+ # return the usable last column/row.
137
294
  def self.size; IO.console&.winsize || [24, 80] end
138
295
  def self.width; size.last-1 end
139
296
  def self.height; size.first - 1 end
297
+
298
+ # Current cursor row/column (see position).
140
299
  def self.row; position.first end
141
300
  def self.column; position.last end
301
+
302
+ # Call +block+ whenever the terminal is resized (SIGWINCH).
142
303
  def self.on_resize &block
143
304
  trap(:WINCH, &block)
144
305
  end
306
+ # Enter interactive mode: seed the default colors, hide the cursor and
307
+ # enable key-mode (application) escapes when stdin is a tty.
145
308
  def self.init default=[ :white, :black ]
146
- $color = $default = default
309
+ $default = default.dup
310
+ $color = $default.dup
147
311
  if $stdin.tty?
148
312
  print CURSOR_INVISIBLE
149
313
  system "tput smkx"
150
314
  end
151
315
  end
316
+ # Restore the terminal: show the cursor, reset colors and clear the screen.
152
317
  def self.exit;
153
318
  extend self
154
319
  if $stdin.tty?
@@ -174,10 +339,10 @@ begin
174
339
  foreground color.to_sym;
175
340
  background colors[x].to_sym;
176
341
  move x*slice,y
177
- show chars
342
+ draw chars
178
343
  }
179
344
  }
180
- (0...256).each{ |i| background i; show ' ' }
345
+ (0...256).each{ |i| background i; draw ' ' }
181
346
 
182
347
  move 0, Typr.height
183
348
  color [:white, :black]
@@ -185,7 +350,7 @@ begin
185
350
  move 15, Typr.height
186
351
  color [fg,bg]
187
352
  print " %s / %s " % [fg,bg]
188
- key = Typr.read(:key)
353
+ key = Typr.read_key
189
354
  case key
190
355
  when KEY_UP; fg+=1
191
356
  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,62 @@ 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
+ def reset type=:all
195
+ case type
196
+ when :search; highlight_clear
197
+ when :all; reset [:position, :selection, :search]; return
198
+ end
199
+ super
200
+ end
201
+
202
+ private
203
+
204
+ def live_search query
205
+ query.empty? ? highlight_clear : search(query)
206
+ end
207
+
208
+ def step
209
+ matches = @search[:matches]
210
+ return nil if matches.empty?
211
+ last = @search[:last]
212
+ if @search[:dir] == :backward
213
+ id = matches.reverse.find{ |m| last and m < last }
214
+ id ||= ( last ? matches.last : matches.reverse.find{ |m| m <= @start } || matches.last )
215
+ else
216
+ id = matches.find{ |m| last and m > last }
217
+ id ||= ( last ? matches.first : matches.find{ |m| m >= @start } || matches.first )
218
+ end
219
+ @search[:last] = id
220
+ @start = id
221
+ return id
222
+ end
223
+
224
+ def search_prompt
225
+ query = @search[:pattern].is_a?(String) ? @search[:pattern].dup : ''
226
+ saved = { re: @re, pattern: @search[:pattern],
227
+ matches: @search[:matches].dup, last: @search[:last], start: @start }
228
+ result = Typr.read_line '/', left: left, top: Typr.height - 1, initial: query,
229
+ &->(key, query, _) {
230
+ live_search query
231
+ nil
232
+ }
233
+ if result
234
+ return search(result) unless result.empty?
235
+ return search(@search[:pattern]) if @search[:pattern]
236
+ end
237
+ @re = saved[:re]; @search[:pattern] = saved[:pattern]
238
+ @search[:matches] = saved[:matches]; @search[:last] = saved[:last]
239
+ @start = saved[:start]
240
+ return
241
+ end
127
242
  end
128
243
 
129
244
  end
data/lib/typr.rb CHANGED
@@ -1,6 +1,5 @@
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
4
  # == Widgets
6
5
  #
@@ -16,8 +15,8 @@
16
15
  # Typr.init # Initialize terminal (hide cursor, enable key mode)
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
@@ -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