try-cli 1.7.1
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +259 -0
- data/VERSION +1 -0
- data/bin/try +4 -0
- data/lib/fuzzy.rb +133 -0
- data/lib/tui.rb +892 -0
- data/try.rb +1281 -0
- metadata +53 -0
data/lib/tui.rb
ADDED
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Experimental TUI toolkit for try.rb
|
|
4
|
+
|
|
5
|
+
require "io/console"
|
|
6
|
+
#
|
|
7
|
+
# Usage pattern:
|
|
8
|
+
# include Tui::Helpers
|
|
9
|
+
# screen = Tui::Screen.new
|
|
10
|
+
# screen.header.add_line { |line| line.write << Tui::Text.bold("π Try Selector") }
|
|
11
|
+
# search_line = screen.body.add_line
|
|
12
|
+
# search_line.write_dim("Search:").write(" ")
|
|
13
|
+
# search_line.write << screen.input("Type to filterβ¦", value: query, cursor: cursor)
|
|
14
|
+
# list_line = screen.body.add_line(background: Tui::Palette::SELECTED_BG)
|
|
15
|
+
# list_line.write << Tui::Text.highlight("β ") << name
|
|
16
|
+
# list_line.right.write_dim(metadata)
|
|
17
|
+
# screen.footer.add_line { |line| line.write_dim("ββ navigate Enter select Esc cancel") }
|
|
18
|
+
# screen.flush
|
|
19
|
+
#
|
|
20
|
+
# The screen owns a single InputField (enforced by #input). Lines support
|
|
21
|
+
# independent left/right writers, truncation, and per-line backgrounds. Right
|
|
22
|
+
# writers are rendered via rwrite-style positioning (clear line + move col).
|
|
23
|
+
|
|
24
|
+
module Tui
|
|
25
|
+
@colors_enabled = ENV["NO_COLORS"].to_s.empty?
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
attr_accessor :colors_enabled
|
|
29
|
+
|
|
30
|
+
def colors_enabled?
|
|
31
|
+
@colors_enabled
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def disable_colors!
|
|
35
|
+
@colors_enabled = false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def enable_colors!
|
|
39
|
+
@colors_enabled = true
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
module ANSI
|
|
44
|
+
CLEAR_EOL = "\e[K"
|
|
45
|
+
CLEAR_EOS = "\e[J"
|
|
46
|
+
CLEAR_SCREEN = "\e[2J"
|
|
47
|
+
HOME = "\e[H"
|
|
48
|
+
HIDE = "\e[?25l"
|
|
49
|
+
SHOW = "\e[?25h"
|
|
50
|
+
CURSOR_BLINK = "\e[1 q" # Blinking block cursor
|
|
51
|
+
CURSOR_STEADY = "\e[2 q" # Steady block cursor
|
|
52
|
+
CURSOR_DEFAULT = "\e[0 q" # Reset cursor to terminal default
|
|
53
|
+
ALT_SCREEN_ON = "\e[?1049h" # Enter alternate screen buffer
|
|
54
|
+
ALT_SCREEN_OFF = "\e[?1049l" # Return to main screen buffer
|
|
55
|
+
RESET = "\e[0m"
|
|
56
|
+
RESET_FG = "\e[39m"
|
|
57
|
+
RESET_BG = "\e[49m"
|
|
58
|
+
RESET_INTENSITY = "\e[22m"
|
|
59
|
+
BOLD = "\e[1m"
|
|
60
|
+
DIM = "\e[2m"
|
|
61
|
+
|
|
62
|
+
module_function
|
|
63
|
+
|
|
64
|
+
def fg(code)
|
|
65
|
+
"\e[38;5;#{code}m"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def bg(code)
|
|
69
|
+
"\e[48;5;#{code}m"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def move_col(col)
|
|
73
|
+
"\e[#{col}G"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def sgr(*codes)
|
|
77
|
+
joined = codes.flatten.join(";")
|
|
78
|
+
"\e[#{joined}m"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
module Palette
|
|
83
|
+
HEADER = ANSI.sgr(1, "38;5;114")
|
|
84
|
+
ACCENT = ANSI.sgr(1, "38;5;214")
|
|
85
|
+
HIGHLIGHT = "\e[1;33m" # Bold yellow (matches C version)
|
|
86
|
+
MUTED = ANSI.fg(245)
|
|
87
|
+
MATCH = ANSI.sgr(1, "38;5;226")
|
|
88
|
+
INPUT_HINT = ANSI.fg(244)
|
|
89
|
+
INPUT_CURSOR_ON = "\e[7m"
|
|
90
|
+
INPUT_CURSOR_OFF = "\e[27m"
|
|
91
|
+
|
|
92
|
+
SELECTED_BG = ANSI.bg(238)
|
|
93
|
+
DANGER_BG = ANSI.bg(52)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
module Metrics
|
|
97
|
+
module_function
|
|
98
|
+
|
|
99
|
+
# Optimized width calculation - avoids per-character method calls
|
|
100
|
+
def visible_width(text)
|
|
101
|
+
# Fast path: pure ASCII with no escapes
|
|
102
|
+
if text.bytesize == text.length && !text.include?("\e")
|
|
103
|
+
return text.length
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Strip ANSI escapes only if present
|
|
107
|
+
stripped = text.include?("\e") ? text.gsub(/\e\[[0-9;]*[A-Za-z]/, '') : text
|
|
108
|
+
|
|
109
|
+
# Fast path after stripping: pure ASCII
|
|
110
|
+
if stripped.bytesize == stripped.length
|
|
111
|
+
return stripped.length
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Slow path: calculate width per codepoint (avoids each_char + ord)
|
|
115
|
+
width = 0
|
|
116
|
+
stripped.each_codepoint do |code|
|
|
117
|
+
width += char_width(code)
|
|
118
|
+
end
|
|
119
|
+
width
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Simplified width check - we only use known Unicode in this app
|
|
123
|
+
def char_width(code)
|
|
124
|
+
# Zero-width: variation selectors (ποΈ = trash + VS16)
|
|
125
|
+
return 0 if code >= 0xFE00 && code <= 0xFE0F
|
|
126
|
+
|
|
127
|
+
# Emoji range (ππ ππ etc) = width 2
|
|
128
|
+
return 2 if code >= 0x1F300 && code <= 0x1FAFF
|
|
129
|
+
|
|
130
|
+
# Everything else (ASCII, arrows, box drawing, ellipsis) = width 1
|
|
131
|
+
1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def zero_width?(ch)
|
|
135
|
+
code = ch.ord
|
|
136
|
+
(code >= 0xFE00 && code <= 0xFE0F) ||
|
|
137
|
+
(code >= 0x200B && code <= 0x200D) ||
|
|
138
|
+
(code >= 0x0300 && code <= 0x036F) ||
|
|
139
|
+
(code >= 0xE0100 && code <= 0xE01EF)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def wide?(ch)
|
|
143
|
+
char_width(ch.ord) == 2
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def truncate(text, max_width, overflow: "β¦")
|
|
147
|
+
return text if visible_width(text) <= max_width
|
|
148
|
+
|
|
149
|
+
overflow_width = visible_width(overflow)
|
|
150
|
+
target = [max_width - overflow_width, 0].max
|
|
151
|
+
truncated = String.new
|
|
152
|
+
width = 0
|
|
153
|
+
in_escape = false
|
|
154
|
+
escape_buf = String.new
|
|
155
|
+
|
|
156
|
+
text.each_char do |ch|
|
|
157
|
+
if in_escape
|
|
158
|
+
escape_buf << ch
|
|
159
|
+
if ch.match?(/[A-Za-z]/)
|
|
160
|
+
truncated << escape_buf
|
|
161
|
+
escape_buf = String.new
|
|
162
|
+
in_escape = false
|
|
163
|
+
end
|
|
164
|
+
next
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
if ch == "\e"
|
|
168
|
+
in_escape = true
|
|
169
|
+
escape_buf = ch
|
|
170
|
+
next
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
cw = char_width(ch.ord)
|
|
174
|
+
break if width + cw > target
|
|
175
|
+
|
|
176
|
+
truncated << ch
|
|
177
|
+
width += cw
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
truncated.rstrip + overflow
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Truncate from the start, keeping trailing portion (for right-aligned overflow)
|
|
184
|
+
# Preserves leading ANSI escape sequences (like dim/color codes)
|
|
185
|
+
def truncate_from_start(text, max_width)
|
|
186
|
+
vis_width = visible_width(text)
|
|
187
|
+
return text if vis_width <= max_width
|
|
188
|
+
|
|
189
|
+
# Collect leading escape sequences first
|
|
190
|
+
leading_escapes = String.new
|
|
191
|
+
in_escape = false
|
|
192
|
+
escape_buf = String.new
|
|
193
|
+
text_start = 0
|
|
194
|
+
|
|
195
|
+
text.each_char.with_index do |ch, i|
|
|
196
|
+
if in_escape
|
|
197
|
+
escape_buf << ch
|
|
198
|
+
if ch.match?(/[A-Za-z]/)
|
|
199
|
+
leading_escapes << escape_buf
|
|
200
|
+
escape_buf = String.new
|
|
201
|
+
in_escape = false
|
|
202
|
+
text_start = i + 1
|
|
203
|
+
end
|
|
204
|
+
elsif ch == "\e"
|
|
205
|
+
in_escape = true
|
|
206
|
+
escape_buf = ch
|
|
207
|
+
else
|
|
208
|
+
# First non-escape character, stop collecting leading escapes
|
|
209
|
+
break
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Now skip visible characters to get max_width remaining
|
|
214
|
+
chars_to_skip = vis_width - max_width
|
|
215
|
+
skipped = 0
|
|
216
|
+
result = String.new
|
|
217
|
+
in_escape = false
|
|
218
|
+
|
|
219
|
+
text.each_char do |ch|
|
|
220
|
+
if in_escape
|
|
221
|
+
result << ch if skipped >= chars_to_skip
|
|
222
|
+
in_escape = false if ch.match?(/[A-Za-z]/)
|
|
223
|
+
next
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
if ch == "\e"
|
|
227
|
+
in_escape = true
|
|
228
|
+
result << ch if skipped >= chars_to_skip
|
|
229
|
+
next
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
cw = char_width(ch.ord)
|
|
233
|
+
if skipped < chars_to_skip
|
|
234
|
+
skipped += cw
|
|
235
|
+
else
|
|
236
|
+
result << ch
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Prepend leading escapes to preserve styling
|
|
241
|
+
leading_escapes + result
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
module Text
|
|
246
|
+
module_function
|
|
247
|
+
|
|
248
|
+
def bold(text)
|
|
249
|
+
wrap(text, ANSI::BOLD, ANSI::RESET_INTENSITY)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def dim(text)
|
|
253
|
+
wrap(text, Palette::MUTED, ANSI::RESET_FG)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def highlight(text)
|
|
257
|
+
wrap(text, Palette::HIGHLIGHT, ANSI::RESET_FG + ANSI::RESET_INTENSITY)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def accent(text)
|
|
261
|
+
wrap(text, Palette::ACCENT, ANSI::RESET_FG + ANSI::RESET_INTENSITY)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def wrap(text, prefix, suffix)
|
|
265
|
+
return "" if text.nil? || text.empty?
|
|
266
|
+
return text unless Tui.colors_enabled?
|
|
267
|
+
"#{prefix}#{text}#{suffix}"
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
module Helpers
|
|
272
|
+
def bold(text)
|
|
273
|
+
Text.bold(text)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def dim(text)
|
|
277
|
+
Text.dim(text)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def highlight(text)
|
|
281
|
+
Text.highlight(text)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def accent(text)
|
|
285
|
+
Text.accent(text)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def fill(char = " ")
|
|
289
|
+
SegmentWriter::FillSegment.new(char.to_s)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Use for emoji characters - precomputes width and enables fast-path
|
|
293
|
+
def emoji(char)
|
|
294
|
+
SegmentWriter::EmojiSegment.new(char)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
class Terminal
|
|
299
|
+
class << self
|
|
300
|
+
def size(io = $stderr)
|
|
301
|
+
env_rows = ENV['TRY_HEIGHT'].to_i
|
|
302
|
+
env_cols = ENV['TRY_WIDTH'].to_i
|
|
303
|
+
rows = env_rows.positive? ? env_rows : nil
|
|
304
|
+
cols = env_cols.positive? ? env_cols : nil
|
|
305
|
+
|
|
306
|
+
streams = [io, $stdout, $stdin].compact.uniq
|
|
307
|
+
|
|
308
|
+
streams.each do |stream|
|
|
309
|
+
next unless (!rows || !cols)
|
|
310
|
+
next unless stream.respond_to?(:winsize)
|
|
311
|
+
|
|
312
|
+
begin
|
|
313
|
+
s_rows, s_cols = stream.winsize
|
|
314
|
+
rows ||= s_rows
|
|
315
|
+
cols ||= s_cols
|
|
316
|
+
rescue IOError, Errno::ENOTTY, Errno::EOPNOTSUPP, Errno::ENODEV
|
|
317
|
+
next
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
if (!rows || !cols)
|
|
322
|
+
begin
|
|
323
|
+
console = IO.console
|
|
324
|
+
if console
|
|
325
|
+
c_rows, c_cols = console.winsize
|
|
326
|
+
rows ||= c_rows
|
|
327
|
+
cols ||= c_cols
|
|
328
|
+
end
|
|
329
|
+
rescue IOError, Errno::ENOTTY, Errno::EOPNOTSUPP, Errno::ENODEV
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
rows ||= 24
|
|
334
|
+
cols ||= 80
|
|
335
|
+
[rows, cols]
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
class Screen
|
|
341
|
+
include Helpers
|
|
342
|
+
|
|
343
|
+
attr_reader :header, :body, :footer, :input_field, :width, :height
|
|
344
|
+
|
|
345
|
+
def initialize(io: $stderr, width: nil, height: nil)
|
|
346
|
+
@io = io
|
|
347
|
+
@fixed_width = width
|
|
348
|
+
@fixed_height = height
|
|
349
|
+
@width = @height = nil
|
|
350
|
+
refresh_size
|
|
351
|
+
@header = Section.new(self)
|
|
352
|
+
@body = Section.new(self)
|
|
353
|
+
@footer = Section.new(self)
|
|
354
|
+
@sections = [@header, @body, @footer]
|
|
355
|
+
@input_field = nil
|
|
356
|
+
@cursor_row = nil
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def refresh_size
|
|
360
|
+
rows, cols = Terminal.size(@io)
|
|
361
|
+
@height = @fixed_height || rows
|
|
362
|
+
@width = @fixed_width || cols
|
|
363
|
+
self
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def input(placeholder = "", value: "", cursor: nil)
|
|
367
|
+
raise ArgumentError, "screen already has an input" if @input_field
|
|
368
|
+
@input_field = InputField.new(placeholder: placeholder, text: value, cursor: cursor)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def clear
|
|
372
|
+
@sections.each(&:clear)
|
|
373
|
+
self
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def flush
|
|
377
|
+
refresh_size
|
|
378
|
+
begin
|
|
379
|
+
@io.write(ANSI::HOME)
|
|
380
|
+
rescue IOError
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
cursor_row = nil
|
|
384
|
+
cursor_col = nil
|
|
385
|
+
current_row = 0
|
|
386
|
+
|
|
387
|
+
# Render header at top
|
|
388
|
+
@header.lines.each do |line|
|
|
389
|
+
if @input_field && line.has_input?
|
|
390
|
+
cursor_row = current_row + 1
|
|
391
|
+
cursor_col = line.cursor_column(@input_field, @width)
|
|
392
|
+
end
|
|
393
|
+
line.render(@io, @width)
|
|
394
|
+
current_row += 1
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Calculate available body space (total height minus header and footer)
|
|
398
|
+
footer_lines = @footer.lines.length
|
|
399
|
+
body_space = @height - current_row - footer_lines
|
|
400
|
+
|
|
401
|
+
# Render body lines (limited to available space)
|
|
402
|
+
body_rendered = 0
|
|
403
|
+
@body.lines.each do |line|
|
|
404
|
+
break if body_rendered >= body_space
|
|
405
|
+
if @input_field && line.has_input?
|
|
406
|
+
cursor_row = current_row + 1
|
|
407
|
+
cursor_col = line.cursor_column(@input_field, @width)
|
|
408
|
+
end
|
|
409
|
+
line.render(@io, @width)
|
|
410
|
+
current_row += 1
|
|
411
|
+
body_rendered += 1
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Fill gap between body and footer with blank lines
|
|
415
|
+
# Use \r to position at column 0, clear line, fill with spaces for reliability
|
|
416
|
+
gap = body_space - body_rendered
|
|
417
|
+
blank_line = "\r#{ANSI::CLEAR_EOL}#{' ' * (@width - 1)}\n"
|
|
418
|
+
blank_line_no_newline = "\r#{ANSI::CLEAR_EOL}#{' ' * (@width - 1)}"
|
|
419
|
+
gap.times do |i|
|
|
420
|
+
# Last gap line without newline if no footer follows
|
|
421
|
+
if i == gap - 1 && @footer.lines.empty?
|
|
422
|
+
@io.write(blank_line_no_newline)
|
|
423
|
+
else
|
|
424
|
+
@io.write(blank_line)
|
|
425
|
+
end
|
|
426
|
+
current_row += 1
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Render footer at the bottom (sticky)
|
|
430
|
+
@footer.lines.each_with_index do |line, idx|
|
|
431
|
+
if @input_field && line.has_input?
|
|
432
|
+
cursor_row = current_row + 1
|
|
433
|
+
cursor_col = line.cursor_column(@input_field, @width)
|
|
434
|
+
end
|
|
435
|
+
# Last line: don't write \n to avoid scrolling
|
|
436
|
+
if idx == footer_lines - 1
|
|
437
|
+
line.render_no_newline(@io, @width)
|
|
438
|
+
else
|
|
439
|
+
line.render(@io, @width)
|
|
440
|
+
end
|
|
441
|
+
current_row += 1
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Position cursor at input field if present, otherwise hide cursor
|
|
445
|
+
if cursor_row && cursor_col && @input_field
|
|
446
|
+
@io.write("\e[#{cursor_row};#{cursor_col}H")
|
|
447
|
+
@io.write(ANSI::SHOW)
|
|
448
|
+
else
|
|
449
|
+
@io.write(ANSI::HIDE)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
@io.write(ANSI::RESET)
|
|
453
|
+
@io.flush
|
|
454
|
+
ensure
|
|
455
|
+
clear
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
class Section
|
|
460
|
+
attr_reader :lines
|
|
461
|
+
|
|
462
|
+
def initialize(screen)
|
|
463
|
+
@screen = screen
|
|
464
|
+
@lines = []
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def add_line(background: nil, truncate: true)
|
|
468
|
+
line = Line.new(@screen, background: background, truncate: truncate)
|
|
469
|
+
@lines << line
|
|
470
|
+
yield line if block_given?
|
|
471
|
+
line
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def divider(char: 'β')
|
|
475
|
+
add_line do |line|
|
|
476
|
+
span = [@screen.width - 1, 1].max
|
|
477
|
+
line.write << char * span
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def clear
|
|
482
|
+
@lines.clear
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
class Line
|
|
487
|
+
attr_accessor :background, :truncate
|
|
488
|
+
|
|
489
|
+
def initialize(screen, background:, truncate: true)
|
|
490
|
+
@screen = screen
|
|
491
|
+
@background = background
|
|
492
|
+
@truncate = truncate
|
|
493
|
+
@left = SegmentWriter.new(z_index: 1)
|
|
494
|
+
@center = nil # Lazy - only created when accessed (z_index: 2, renders on top)
|
|
495
|
+
@right = nil # Lazy - only created when accessed (z_index: 0)
|
|
496
|
+
@has_input = false
|
|
497
|
+
@input_prefix_width = 0
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def write
|
|
501
|
+
@left
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def left
|
|
505
|
+
@left
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def center
|
|
509
|
+
@center ||= SegmentWriter.new(z_index: 2)
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def right
|
|
513
|
+
@right ||= SegmentWriter.new(z_index: 0)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def has_input?
|
|
517
|
+
@has_input
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def mark_has_input(prefix_width)
|
|
521
|
+
@has_input = true
|
|
522
|
+
@input_prefix_width = prefix_width
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def cursor_column(input_field, width)
|
|
526
|
+
# Calculate cursor position: prefix + cursor position in input
|
|
527
|
+
@input_prefix_width + input_field.cursor + 1
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def render(io, width)
|
|
531
|
+
buffer = String.new
|
|
532
|
+
buffer << "\r"
|
|
533
|
+
buffer << ANSI::CLEAR_EOL # Clear line before rendering to remove stale content
|
|
534
|
+
|
|
535
|
+
# Set background if present
|
|
536
|
+
buffer << background if background && Tui.colors_enabled?
|
|
537
|
+
|
|
538
|
+
# Maximum content to avoid wrap (leave room for newline)
|
|
539
|
+
max_content = width - 1
|
|
540
|
+
content_width = [width, 1].max
|
|
541
|
+
|
|
542
|
+
left_text = @left.to_s(width: content_width)
|
|
543
|
+
center_text = @center ? @center.to_s(width: content_width) : ""
|
|
544
|
+
right_text = @right ? @right.to_s(width: content_width) : ""
|
|
545
|
+
|
|
546
|
+
# Truncate left to fit line
|
|
547
|
+
left_text = Metrics.truncate(left_text, max_content) if @truncate && !left_text.empty?
|
|
548
|
+
left_width = left_text.empty? ? 0 : Metrics.visible_width(left_text)
|
|
549
|
+
|
|
550
|
+
# Truncate center text to available space (never wrap)
|
|
551
|
+
unless center_text.empty?
|
|
552
|
+
max_center = max_content - left_width - 4
|
|
553
|
+
if max_center > 0
|
|
554
|
+
center_text = Metrics.truncate(center_text, max_center)
|
|
555
|
+
else
|
|
556
|
+
center_text = ""
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
center_width = center_text.empty? ? 0 : Metrics.visible_width(center_text)
|
|
560
|
+
|
|
561
|
+
# Calculate available space for right (need at least 1 space gap after left/center)
|
|
562
|
+
used_by_left_center = left_width + center_width + (center_width > 0 ? 2 : 0)
|
|
563
|
+
available_for_right = max_content - used_by_left_center - 1 # -1 for mandatory gap
|
|
564
|
+
|
|
565
|
+
# Truncate right from the LEFT if needed (show trailing portion)
|
|
566
|
+
right_width = 0
|
|
567
|
+
unless right_text.empty?
|
|
568
|
+
right_width = Metrics.visible_width(right_text)
|
|
569
|
+
if available_for_right <= 0
|
|
570
|
+
right_text = ""
|
|
571
|
+
right_width = 0
|
|
572
|
+
elsif right_width > available_for_right
|
|
573
|
+
# Skip leading characters, keep trailing portion
|
|
574
|
+
right_text = Metrics.truncate_from_start(right_text, available_for_right)
|
|
575
|
+
right_width = Metrics.visible_width(right_text)
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Calculate positions
|
|
580
|
+
center_col = center_text.empty? ? 0 : [(max_content - center_width) / 2, left_width + 1].max
|
|
581
|
+
right_col = right_text.empty? ? max_content : (max_content - right_width)
|
|
582
|
+
|
|
583
|
+
# Write left content
|
|
584
|
+
buffer << left_text unless left_text.empty?
|
|
585
|
+
current_pos = left_width
|
|
586
|
+
|
|
587
|
+
# Write centered content if present
|
|
588
|
+
unless center_text.empty?
|
|
589
|
+
gap_to_center = center_col - current_pos
|
|
590
|
+
buffer << (" " * gap_to_center) if gap_to_center > 0
|
|
591
|
+
buffer << center_text
|
|
592
|
+
current_pos = center_col + center_width
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# Fill gap to right content (or end of line)
|
|
596
|
+
fill_end = right_text.empty? ? max_content : right_col
|
|
597
|
+
gap = fill_end - current_pos
|
|
598
|
+
buffer << (" " * gap) if gap > 0
|
|
599
|
+
|
|
600
|
+
# Write right content if present
|
|
601
|
+
unless right_text.empty?
|
|
602
|
+
buffer << right_text
|
|
603
|
+
buffer << ANSI::RESET_FG
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
buffer << ANSI::RESET
|
|
607
|
+
buffer << "\n"
|
|
608
|
+
|
|
609
|
+
io.write(buffer)
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def render_no_newline(io, width)
|
|
613
|
+
buffer = String.new
|
|
614
|
+
buffer << "\r"
|
|
615
|
+
buffer << ANSI::CLEAR_EOL
|
|
616
|
+
|
|
617
|
+
buffer << background if background && Tui.colors_enabled?
|
|
618
|
+
|
|
619
|
+
max_content = width - 1
|
|
620
|
+
content_width = [width, 1].max
|
|
621
|
+
|
|
622
|
+
left_text = @left.to_s(width: content_width)
|
|
623
|
+
center_text = @center ? @center.to_s(width: content_width) : ""
|
|
624
|
+
right_text = @right ? @right.to_s(width: content_width) : ""
|
|
625
|
+
|
|
626
|
+
# Truncate left to fit line
|
|
627
|
+
left_text = Metrics.truncate(left_text, max_content) if @truncate && !left_text.empty?
|
|
628
|
+
left_width = left_text.empty? ? 0 : Metrics.visible_width(left_text)
|
|
629
|
+
|
|
630
|
+
# Truncate center text to available space (never wrap)
|
|
631
|
+
unless center_text.empty?
|
|
632
|
+
max_center = max_content - left_width - 4
|
|
633
|
+
if max_center > 0
|
|
634
|
+
center_text = Metrics.truncate(center_text, max_center)
|
|
635
|
+
else
|
|
636
|
+
center_text = ""
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
center_width = center_text.empty? ? 0 : Metrics.visible_width(center_text)
|
|
640
|
+
|
|
641
|
+
# Calculate available space for right (need at least 1 space gap)
|
|
642
|
+
used_by_left_center = left_width + center_width + (center_width > 0 ? 2 : 0)
|
|
643
|
+
available_for_right = max_content - used_by_left_center - 1
|
|
644
|
+
|
|
645
|
+
# Truncate right from the LEFT if needed (show trailing portion)
|
|
646
|
+
right_width = 0
|
|
647
|
+
unless right_text.empty?
|
|
648
|
+
right_width = Metrics.visible_width(right_text)
|
|
649
|
+
if available_for_right <= 0
|
|
650
|
+
right_text = ""
|
|
651
|
+
right_width = 0
|
|
652
|
+
elsif right_width > available_for_right
|
|
653
|
+
right_text = Metrics.truncate_from_start(right_text, available_for_right)
|
|
654
|
+
right_width = Metrics.visible_width(right_text)
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# Calculate positions
|
|
659
|
+
center_col = center_text.empty? ? 0 : [(max_content - center_width) / 2, left_width + 1].max
|
|
660
|
+
right_col = right_text.empty? ? max_content : (max_content - right_width)
|
|
661
|
+
|
|
662
|
+
buffer << left_text unless left_text.empty?
|
|
663
|
+
current_pos = left_width
|
|
664
|
+
|
|
665
|
+
unless center_text.empty?
|
|
666
|
+
gap_to_center = center_col - current_pos
|
|
667
|
+
buffer << (" " * gap_to_center) if gap_to_center > 0
|
|
668
|
+
buffer << center_text
|
|
669
|
+
current_pos = center_col + center_width
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
fill_end = right_text.empty? ? max_content : right_col
|
|
673
|
+
gap = fill_end - current_pos
|
|
674
|
+
buffer << (" " * gap) if gap > 0
|
|
675
|
+
|
|
676
|
+
unless right_text.empty?
|
|
677
|
+
buffer << right_text
|
|
678
|
+
buffer << ANSI::RESET_FG
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
buffer << ANSI::RESET
|
|
682
|
+
# No newline at end
|
|
683
|
+
|
|
684
|
+
io.write(buffer)
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
class SegmentWriter
|
|
689
|
+
include Helpers
|
|
690
|
+
|
|
691
|
+
class FillSegment
|
|
692
|
+
attr_reader :char, :style
|
|
693
|
+
|
|
694
|
+
def initialize(char, style: nil)
|
|
695
|
+
@char = char.to_s
|
|
696
|
+
@style = style
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
def with_style(style)
|
|
700
|
+
self.class.new(char, style: style)
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# Emoji with precomputed width - triggers has_wide flag
|
|
705
|
+
class EmojiSegment
|
|
706
|
+
attr_reader :char, :width
|
|
707
|
+
|
|
708
|
+
def initialize(char)
|
|
709
|
+
@char = char.to_s
|
|
710
|
+
# Precompute: emoji = 2, variation selectors = 0
|
|
711
|
+
@width = 0
|
|
712
|
+
@char_count = 0
|
|
713
|
+
@char.each_codepoint do |code|
|
|
714
|
+
w = Metrics.char_width(code)
|
|
715
|
+
@width += w
|
|
716
|
+
@char_count += 1 if w > 0 # Don't count zero-width chars
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
def to_s
|
|
721
|
+
@char
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# How many characters this counts as in string.length
|
|
725
|
+
def char_count
|
|
726
|
+
@char.length
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# Extra width beyond char_count (for width calculation)
|
|
730
|
+
def width_delta
|
|
731
|
+
@width - char_count
|
|
732
|
+
end
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
attr_accessor :z_index
|
|
736
|
+
|
|
737
|
+
def initialize(z_index: 1)
|
|
738
|
+
@segments = []
|
|
739
|
+
@z_index = z_index
|
|
740
|
+
@has_wide = false
|
|
741
|
+
@width_delta = 0 # Extra width from wide chars (width - bytecount)
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
def write(text = "")
|
|
745
|
+
return self if text.nil?
|
|
746
|
+
if text.respond_to?(:empty?) && text.empty?
|
|
747
|
+
return self
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
segment = normalize_segment(text)
|
|
751
|
+
if segment.is_a?(EmojiSegment)
|
|
752
|
+
@has_wide = true
|
|
753
|
+
@width_delta += segment.width_delta
|
|
754
|
+
end
|
|
755
|
+
@segments << segment
|
|
756
|
+
self
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def has_wide?
|
|
760
|
+
@has_wide
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
alias << write
|
|
764
|
+
|
|
765
|
+
def write_dim(text)
|
|
766
|
+
write(style_segment(text, :dim) { |value| dim(value) })
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
def write_bold(text)
|
|
770
|
+
write(style_segment(text, :bold) { |value| bold(value) })
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def write_highlight(text)
|
|
774
|
+
write(style_segment(text, :highlight) { |value| highlight(value) })
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
def to_s(width: nil)
|
|
778
|
+
rendered = String.new
|
|
779
|
+
@segments.each do |segment|
|
|
780
|
+
case segment
|
|
781
|
+
when FillSegment
|
|
782
|
+
raise ArgumentError, "fill requires width context" unless width
|
|
783
|
+
rendered << render_fill(segment, rendered, width)
|
|
784
|
+
when EmojiSegment
|
|
785
|
+
rendered << segment.to_s
|
|
786
|
+
else
|
|
787
|
+
rendered << segment.to_s
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
rendered
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# Fast width calculation using precomputed emoji widths
|
|
794
|
+
def visible_width(rendered_str)
|
|
795
|
+
if @has_wide
|
|
796
|
+
# Has emoji - use delta: string length + extra width from wide chars
|
|
797
|
+
stripped = rendered_str.include?("\e") ? rendered_str.gsub(/\e\[[0-9;]*[A-Za-z]/, '') : rendered_str
|
|
798
|
+
stripped.length + @width_delta
|
|
799
|
+
else
|
|
800
|
+
# Pure ASCII - just string length
|
|
801
|
+
stripped = rendered_str.include?("\e") ? rendered_str.gsub(/\e\[[0-9;]*[A-Za-z]/, '') : rendered_str
|
|
802
|
+
stripped.length
|
|
803
|
+
end
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def empty?
|
|
807
|
+
@segments.empty?
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
private
|
|
811
|
+
|
|
812
|
+
def normalize_segment(text)
|
|
813
|
+
case text
|
|
814
|
+
when FillSegment, EmojiSegment
|
|
815
|
+
text
|
|
816
|
+
else
|
|
817
|
+
text.to_s
|
|
818
|
+
end
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
def style_segment(text, style)
|
|
822
|
+
if text.is_a?(FillSegment)
|
|
823
|
+
text.with_style(style)
|
|
824
|
+
else
|
|
825
|
+
yield(text)
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
def render_fill(segment, rendered, width)
|
|
830
|
+
# Use width - 1 to avoid wrapping in terminals that wrap at the last column
|
|
831
|
+
max_fill = width - 1
|
|
832
|
+
remaining = max_fill - Metrics.visible_width(rendered)
|
|
833
|
+
return "" if remaining <= 0
|
|
834
|
+
|
|
835
|
+
pattern = segment.char
|
|
836
|
+
pattern = " " if pattern.empty?
|
|
837
|
+
pattern_width = [Metrics.visible_width(pattern), 1].max
|
|
838
|
+
repeat = (remaining.to_f / pattern_width).ceil
|
|
839
|
+
filler = pattern * repeat
|
|
840
|
+
filler = Metrics.truncate(filler, remaining, overflow: "")
|
|
841
|
+
apply_style(filler, segment.style)
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
def apply_style(text, style)
|
|
845
|
+
case style
|
|
846
|
+
when :dim
|
|
847
|
+
dim(text)
|
|
848
|
+
when :bold
|
|
849
|
+
bold(text)
|
|
850
|
+
when :highlight
|
|
851
|
+
highlight(text)
|
|
852
|
+
when :accent
|
|
853
|
+
accent(text)
|
|
854
|
+
else
|
|
855
|
+
text
|
|
856
|
+
end
|
|
857
|
+
end
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
class InputField
|
|
861
|
+
attr_accessor :text, :cursor
|
|
862
|
+
attr_reader :placeholder
|
|
863
|
+
|
|
864
|
+
def initialize(placeholder:, text:, cursor: nil)
|
|
865
|
+
@placeholder = placeholder
|
|
866
|
+
@text = text.to_s.dup
|
|
867
|
+
@cursor = cursor.nil? ? @text.length : [[cursor, 0].max, @text.length].min
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def to_s
|
|
871
|
+
return render_placeholder if text.empty?
|
|
872
|
+
|
|
873
|
+
before = text[0...cursor]
|
|
874
|
+
cursor_char = text[cursor] || ' '
|
|
875
|
+
after = cursor < text.length ? text[(cursor + 1)..] : ""
|
|
876
|
+
|
|
877
|
+
buf = String.new
|
|
878
|
+
buf << before
|
|
879
|
+
buf << Palette::INPUT_CURSOR_ON if Tui.colors_enabled?
|
|
880
|
+
buf << cursor_char
|
|
881
|
+
buf << Palette::INPUT_CURSOR_OFF if Tui.colors_enabled?
|
|
882
|
+
buf << after
|
|
883
|
+
buf
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
private
|
|
887
|
+
|
|
888
|
+
def render_placeholder
|
|
889
|
+
Text.dim(placeholder)
|
|
890
|
+
end
|
|
891
|
+
end
|
|
892
|
+
end
|