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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -0
- data/README.md +68 -37
- data/example/grid +4 -4
- data/example/text +7 -4
- data/lib/browser.rb +106 -78
- data/lib/frontend.rb +7 -3
- data/lib/graphical.rb +10 -13
- data/lib/grid.rb +69 -14
- data/lib/line.rb +57 -27
- data/lib/space.rb +25 -5
- data/lib/stack.rb +34 -10
- data/lib/terminal.rb +280 -27
- data/lib/text.rb +129 -7
- data/lib/typr.rb +8 -9
- data/share/mimetypes +1298 -1
- data/share/terminfo +691 -0
- data/typr.gemspec +4 -3
- metadata +3 -8
- data/Gemfile +0 -10
- data/lib/curses.rb +0 -120
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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:
|
|
241
|
+
$>.print( { screen: CLEAR_SCREEN, line: ERASE_LINE }[mode] )
|
|
112
242
|
end
|
|
113
243
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
$
|
|
393
|
+
$default = default.dup
|
|
394
|
+
$color = $default.dup
|
|
147
395
|
if $stdin.tty?
|
|
148
396
|
print CURSOR_INVISIBLE
|
|
149
|
-
|
|
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
|
-
|
|
430
|
+
draw chars
|
|
178
431
|
}
|
|
179
432
|
}
|
|
180
|
-
(0...256).each{ |i| background i;
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
106
|
+
# text.show
|
|
99
107
|
|
|
100
|
-
def
|
|
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
|
|
3
|
-
# top of curses.
|
|
2
|
+
# It provides layout primitives, widgets, and event-driven interaction.
|
|
4
3
|
#
|
|
5
|
-
# ==
|
|
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.
|
|
20
|
-
# line = Typr.
|
|
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
|
|
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.
|
|
42
|
-
# key = Typr.
|
|
40
|
+
# grid.show
|
|
41
|
+
# key = Typr.read_key
|
|
43
42
|
# Typr.exit
|
|
44
43
|
#
|
|
45
44
|
# == Installation
|