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.
Files changed (9) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +259 -0
  4. data/VERSION +1 -0
  5. data/bin/try +4 -0
  6. data/lib/fuzzy.rb +133 -0
  7. data/lib/tui.rb +892 -0
  8. data/try.rb +1281 -0
  9. 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