typr 1.1.2 → 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,285 +1,365 @@
1
- #!/usr/bin/ruby
2
-
3
1
  require 'io/console'
4
- require 'readline'
5
2
  require 'unicode/display_width'
6
3
 
7
- module Typr
8
- # alias :show :print
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
+
8
+ module Typr
9
+
10
+ # Raw /dev/tty used for key input; falls back to $stdin when unavailable.
11
+ INPUT = (IO.new IO.sysopen("/dev/tty", "r")) rescue $stdin
9
12
 
10
- INPUT = (IO.new IO.sysopen "/dev/tty", "r")
11
- `infocmp -L1`.split.each{ |info| key,value = info[0..-2].split("=")
13
+ # KEY_* escape sequences come from the terminfo database (infocmp -L1).
14
+ `infocmp -L1`.split.each{ |info| key,value = info[0..-2].split("=")
12
15
  if value
13
- # p key + value if key[/dis/]
14
- # value[1] = ?e if value[0,2] == "\\E"
15
16
  value.gsub! '\E', '\e'
16
- # value = '\x' + value[/\d+/].to_i(8).to_s(16).upcase if value[/\\\d+/]
17
- # value = "\\x" + value[/\d+/].to_i(8).to_s(16).upcase if value[/\\\d+/]
18
17
  value += "\\" if value[-1] == "\\"
19
- # eval "%s=\'%s\'" % [key.upcase,value] if key
20
- eval "%s=\"%s\"" % [key.upcase,value] if key
21
- #and key.start_with? "key_"
18
+ eval "%s=\"%s\"" % [key.upcase,value] if key
22
19
  end }
23
-
20
+
21
+ # Common key aliases and the line-erase escape sequence.
22
+ ERASE_LINE = "\e[K"
24
23
  KEY_ESCAPE = "\e"
25
24
  KEY_RETURN = CARRIAGE_RETURN
26
25
  KEY_TAB = "\t"
27
- KEY_ENTER = CARRIAGE_RETURN
28
26
  KEY_PAGEDOWN = KEY_NPAGE
29
27
  KEY_PAGEUP = KEY_PPAGE
30
- KEY_INSERT = KEY_IC
31
- KEY_DELETE = KEY_DC
32
28
 
33
- MODES = %i[ reset bold faint italic underline slow fast invert ]
29
+ # Text attributes (reset, bold, italic, ...) and named 8/256-color tables.
30
+ MODES = %i[ reset bold italic underline slow fast invert ]
34
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
+ }
35
39
 
40
+ # Pad or clip +str+ to exactly +max+ cells, honoring +align+ (:left/:right),
41
+ # trimming from +side+, with an optional trailing +fade+.
36
42
  def prepare str, max, align=:left, side=:right, fade=false
37
43
  stop = width = real_size( str )
38
- # unless str[0..max-1].ascii_only? and width == str.size
39
44
  unless str.ascii_only? and width == str.size
40
45
  stop = width = 0
41
46
  str.each_char do |c|
42
- if c == ?\e
47
+ if c == ?\e
43
48
  width -= str[stop..-1][/^\x1b\[[^m]+m|/].size-1
44
49
  stop += 1
45
50
  elsif (cwidth = (c.ascii_only? ? 1 : Unicode::DisplayWidth.of(c))) +
46
- width >= max
51
+ width > max
47
52
  break
48
53
  else width += cwidth; stop += 1 end
49
- end
54
+ end
50
55
  end
51
- # return
52
- if ( space = ( max - width ) ) > 0 #.clamp 0, max
56
+ if ( space = ( max - width ) ) > 0
53
57
  str = [ str[0..stop-1], " " * space ]
54
58
  str.reverse! if align == :right
55
59
  str.join
56
- else #case align
57
- # when :left; str = str[0..max-1]
58
- # when :right;
59
- str = str[side == :left ? -max..-1 : 0..max-1]
60
- # end
61
- # str = fade str, align, str.length / 8 + 1 if fade
60
+ else
61
+ str = clip str, max, side
62
62
  str = fade str, side, fade if fade
63
63
  return str
64
64
  end
65
- # return str.join
66
65
  end
67
-
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.
68
110
  def fade str, side=:left, num=3
69
111
  isleft = side == :left
70
- # chars = (side == ? 0..num : (-num-1..-2).to_a.reverse)
71
112
  chars = str[ isleft ? 0..num : -num..-1 ]
72
113
  chars = chars.chars.map.with_index do |char,id|
73
114
  id = chars.size-id unless isleft
74
115
  color_code( "grey#{(80/chars.size)*id+10}".to_sym ) + char
75
116
  end.join
76
- # chars.each.with_index do |pos,id|
77
- # str.insert pos, color_code( "grey#{(80/chars.size)*id+10}".to_sym )
78
- # str.insert pos, color_code( "grey#{(100/id+3)*10}".to_sym )
79
- # end
80
117
  return ( isleft ? chars + color_code($color[0]) + str[num+1..-1] :
81
118
  str[0..-num-1] + chars )
82
-
83
- #+ color_code( $color[1] )
84
- # return str
85
- # length.times{|i|
86
- # color [ ("grey"+((i+3)*10).to_s ).to_sym, @colors[:default][1] ]
87
- # show @directory[-space+i] }
88
- # color @colors[:default]
89
- # show @directory[-space+6..-1]
90
119
  end
91
- # def printables str; str.dump.gsub(/\\e\[.+m|\"/, '') end
120
+
121
+ # Strip ANSI escape sequences from +str+.
92
122
  def printables str; str.gsub(/\x1b\[[^m]+m|/,'') end
93
- # def real_size str; printables(str).size end
123
+
124
+ # Display width of +str+ after stripping ANSI escapes.
94
125
  def real_size str; Unicode::DisplayWidth.of( printables(str) ) end
95
-
96
- # $>.print "\e[%iJ" % %w[down line screen].index(mode.to_s)
97
- def move x=0,y=0; show move_code( x, y ) end
126
+
127
+ # Coerce a string into Integer, Float, or Boolean when it matches those
128
+ # forms (yes/no, true/false); otherwise return it unchanged.
129
+ def coerce_type value
130
+ s = value.to_s
131
+ return nil if s.empty? or /\A(?:nil|NULL)\z/i.match(s)
132
+ case value
133
+ when /^-?[\d]+$/ then s.to_i
134
+ when /^-?\d*[\.\,]\d+$/ then s.to_f
135
+ when /\byes\z/i then true
136
+ when /\bno(ot)?\z/i then false
137
+ when /true\z/i then true
138
+ when /false\z/i then false
139
+ else value
140
+ end
141
+ end
142
+
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
98
146
  def move_code x=0,y=0; "\e[%i;%if" % [ y+1, x+1 ] end
99
-
100
- def mode name; show mode_code(name) end
101
- def mode_code name; if id = MODES.index(name.to_s) then "\e[#{ id }m" end end
102
-
147
+
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
151
+ def mode_code name; if id = MODES.index(name.to_s) then "\e[#{ id }m" end end
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.
103
156
  def color_code color, bg=false
104
157
  color = color.to_sym if color.is_a? String
105
158
  case color
106
- when Symbol; #color = color.to_s
159
+ when Symbol;
107
160
  if id = COLORS.index(color); "\e[#{ id + (bg ? 40 : 30) }m"
108
161
  elsif color[/^gr[ae]y\d{,2}$/]
109
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]
110
165
  end
111
166
  when Integer; "\e[%i;5;%im" % [ bg ? 48 : 38, color]
112
167
  when Array; case color.count
113
- when 3; "\e[%i;2;%i;%i;%im" % [ bg ? 48 : 38, *color ]
168
+ when 3; "\e[%i;2;%i;%i;%im" % [ bg ? 48 : 38, *color ]
114
169
  when 2; color_code( color[0] ) + color_code( color[1], true )
115
170
  end
116
171
  end
117
172
  end
118
-
119
- def get_background; $color[1] end
173
+
174
+ # Read the current background/foreground color pair.
175
+ def get_background; $color[1] end
120
176
  def get_foreground; $color[0] end
121
- def foreground c=$default[0]; $color[0]=c; show color_code(c) end
122
- def background c=$default[1]; $color[1]=c; show color_code(c,true) end
123
- # def color c=$default
124
- # c = [c] unless c.is_a? Array and c.count == 2
125
- # show color_code(c)
126
- # 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
127
182
  def color c=$default
128
183
  c = [c] unless c.is_a? Array and c.count == 2
129
- foreground c[0] if c[0]
130
- background c[1] if c[1]
184
+ foreground c[0] if c[0]
185
+ background c[1] if c[1]
186
+ end
187
+
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).
192
+ def self.clear mode=:screen
193
+ $>.print( { screen: CLEAR_SCREEN, line: ERASE_LINE }[mode] )
131
194
  end
132
195
 
133
- def show str; $>.print str end
134
- # def enable attr; mode attr end
135
- # def disable attr=nil; mode :reset end
136
- # def refresh; end
137
- def self.clear mode=:screen
138
- $>.print( { screen: CLEAR_SCREEN, line: DELETE_LINE }[mode] )
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
139
204
  end
140
205
 
141
- def self.read obj, prompt=''
142
- case obj
143
- when :key then INPUT.raw{ |tty| tty.sysread 6 } rescue return
144
- when :line then line=Readline.readline prompt; print CURSOR_INVISIBLE;line
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?
145
255
  end
146
256
  end
147
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.
148
279
  def self.position
280
+ return [0, 0] unless $stdin.tty?
149
281
  result = ''
150
282
  $stdin.raw do |stdin|
151
- $stdout << USER7 #"\e[6n"
283
+ $stdout << USER7
152
284
  $stdout.flush
153
285
  until (char = stdin.getc) == 'R'
154
286
  result << char if char
155
287
  end
156
288
  end
157
289
  result[/[\d;]+/].split(?;).map &:to_i
158
- # m = res.match /(?<row>\d+);(?<column>\d+)/
159
- # { row: Integer(m[:row]), column: Integer(m[:column]) }
160
290
  end
161
291
 
162
- def self.size; IO.console.winsize end
292
+ # Terminal [rows, cols] (defaults to 24x80 when undetectable); width/height
293
+ # return the usable last column/row.
294
+ def self.size; IO.console&.winsize || [24, 80] end
163
295
  def self.width; size.last-1 end
164
- def self.height; size.first end
296
+ def self.height; size.first - 1 end
297
+
298
+ # Current cursor row/column (see position).
165
299
  def self.row; position.first end
166
300
  def self.column; position.last end
167
- def self.init default=[ :white, :black ]
168
- $color = $default = default
169
- # include Typr
170
- # extend self
171
- # Object.undef_method :read
172
- print CURSOR_INVISIBLE
173
- system "tput smkx"
301
+
302
+ # Call +block+ whenever the terminal is resized (SIGWINCH).
303
+ def self.on_resize &block
304
+ trap(:WINCH, &block)
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.
308
+ def self.init default=[ :white, :black ]
309
+ $default = default.dup
310
+ $color = $default.dup
311
+ if $stdin.tty?
312
+ print CURSOR_INVISIBLE
313
+ system "tput smkx"
314
+ end
174
315
  end
175
- def self.exit;
176
- # background
316
+ # Restore the terminal: show the cursor, reset colors and clear the screen.
317
+ def self.exit;
177
318
  extend self
178
-
179
- $>.print CURSOR_NORMAL;
180
- $>.print ORIG_COLORS; color; clear
181
-
319
+ if $stdin.tty?
320
+ $>.print CURSOR_NORMAL;
321
+ $>.print ORIG_COLORS; color; clear
322
+ end
182
323
  end
183
324
  end
184
325
 
185
- # include Typr
186
- # extend Typr
187
-
188
326
  if __FILE__ == $0
189
327
  begin
190
328
  include Typr
191
- Typr.init #background: :blue
329
+ Typr.init
192
330
  colors = COLORS + (1..9).map{|i| "grey#{i*10}" }
193
331
  chars = (33..126).map(&:chr)
194
332
  slice = chars.count / colors.count + 1
195
333
  characters = chars.each_slice( slice ).map(&:join)
196
- # key = read :key
197
334
  key,fg,bg = "",150,0
198
335
  Typr.clear
199
336
  loop do
200
- # move 0,0
201
- colors.each_with_index{ |color,y|
202
- characters.each_with_index{ |chars,x|
203
- foreground color.to_sym;
204
- background colors[x].to_sym;
337
+ colors.each_with_index{ |color,y|
338
+ characters.each_with_index{ |chars,x|
339
+ foreground color.to_sym;
340
+ background colors[x].to_sym;
205
341
  move x*slice,y
206
- show chars
342
+ draw chars
207
343
  }
208
344
  }
209
- (0...256).each{ |i| background i; show ' ' }
210
-
211
- move 0, Typr.height-1
345
+ (0...256).each{ |i| background i; draw ' ' }
346
+
347
+ move 0, Typr.height
212
348
  color [:white, :black]
213
- # show "KEY: #{key}"
214
349
  p "KEY: %s / %s" % [key, key]
215
- move 15, Typr.height-1
350
+ move 15, Typr.height
216
351
  color [fg,bg]
217
- print " %s / %s " % [fg,bg] #).center 20
218
- key = Typr.read(:key)
352
+ print " %s / %s " % [fg,bg]
353
+ key = Typr.read_key
219
354
  case key
220
355
  when KEY_UP; fg+=1
221
356
  when KEY_DOWN; fg-=1
222
357
  when KEY_LEFT; bg-=1
223
358
  when KEY_RIGHT; bg+=1
224
- when KEY_ESCAPE; exit
359
+ when KEY_ESCAPE; exit
225
360
  end
226
361
  end
227
362
  ensure
228
363
  Typr.exit
229
364
  end
230
365
  end
231
-
232
-
233
- # KEY_ESC = 27
234
- # KEY_TAB =
235
- # KEY_ESC = 27
236
- # A_STANDOUT = :invert
237
- # BG = :black
238
- # `infocmp -L`.read.join.split(', ').each{ |info| }
239
- # .split('=').to_h
240
-
241
- # KEY_PAGEDOWN = "\e[6~"
242
- # KEY_PAGEUP = "\e[5~"
243
- # KEY_LEFT = "\e[D"
244
- # KEY_RIGHT = "\e[C"
245
- # KEY_UP = "\e[A"
246
- # KEY_DOWN = "\e[B"
247
- # KEY_HOME = "\e[H" #xterm
248
- # KEY_END = "\e[F" #xterm
249
- # INSERT = "\e[2~" #xterm
250
- # DELETE = "\e[3~" #xterm
251
- # KEY_BACKSPACE = "\b" #xterm
252
-
253
- # case ENV["TERM"]
254
- # when
255
- # DELETE = "\e[P" #st
256
- # INSERT = "\e[4h" #st
257
- # BACKSPACE = "\x7F" #st
258
- # KEY_END = "\e[4~" #st
259
-
260
- # KEY_HOME = "\e[7~" #urxvt
261
- # KEY_END = "\e[8~" #urxvt
262
-
263
- # HIDE_CURSOR = "\e[?25l"
264
- # SHOW_CURSOR = "\e[?25h"
265
- # HEIGHT,WIDTH = IO.console.winsize
266
-
267
-
268
- # def mod key,char,extra;
269
- # if key.length == 1; key = char + key.upcase
270
- # elsif key[/~$/]; key[0..-2] + char
271
- # else key[0..-2] + extra + key[-1].downcase end
272
- # end
273
- # def shift key; mod key, "$" end
274
- # def ctrl key; mod key, "^", "O" end
275
- # def alt key; KEYMAP[:esc] + key end
276
- # def clear mode=2; IO.console.erase_screen 1 end
277
- # def mode name; "\e[%im" % [(MODES.index name.to_s)] end
278
- # def ansi arg, type, inc=0
279
- # case arg
280
- # when Integer; show "\e[%i;5;%im" % [ inc + 8, arg]
281
- # else show "\e[%im" % [ ( type.index arg.to_s ) + inc ]
282
- # end
283
- # end
284
-
285
-