tui_tui 0.2.0 → 0.4.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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +51 -14
  4. data/examples/README.md +28 -0
  5. data/examples/breakout.rb +225 -0
  6. data/examples/counter.rb +11 -12
  7. data/examples/csv_viewer.rb +28 -50
  8. data/examples/downloads.rb +128 -0
  9. data/examples/file_browser.rb +106 -56
  10. data/examples/form.rb +80 -102
  11. data/examples/life.rb +1 -1
  12. data/examples/log_viewer.rb +118 -0
  13. data/examples/todo.rb +43 -64
  14. data/examples/widgets.rb +4 -4
  15. data/lib/tui_tui/box_chrome.rb +0 -9
  16. data/lib/tui_tui/box_chrome_resolver.rb +21 -0
  17. data/lib/tui_tui/box_prober.rb +3 -4
  18. data/lib/tui_tui/canvas.rb +6 -55
  19. data/lib/tui_tui/canvas_compositor.rb +12 -10
  20. data/lib/tui_tui/canvas_renderer.rb +59 -0
  21. data/lib/tui_tui/cell.rb +4 -3
  22. data/lib/tui_tui/clock.rb +11 -0
  23. data/lib/tui_tui/command.rb +21 -0
  24. data/lib/tui_tui/event_stream.rb +42 -7
  25. data/lib/tui_tui/key_code.rb +3 -0
  26. data/lib/tui_tui/key_reader.rb +1 -1
  27. data/lib/tui_tui/line.rb +11 -0
  28. data/lib/tui_tui/modal/command_palette.rb +193 -0
  29. data/lib/tui_tui/modal/confirm.rb +74 -0
  30. data/lib/tui_tui/modal/help.rb +44 -0
  31. data/lib/tui_tui/modal/pager.rb +106 -0
  32. data/lib/tui_tui/modal/prompt.rb +66 -0
  33. data/lib/tui_tui/modal/select.rb +103 -0
  34. data/lib/tui_tui/modal.rb +4 -0
  35. data/lib/tui_tui/modal_host.rb +2 -2
  36. data/lib/tui_tui/rect.rb +69 -0
  37. data/lib/tui_tui/runtime.rb +52 -20
  38. data/lib/tui_tui/screen.rb +15 -3
  39. data/lib/tui_tui/terminal_session.rb +16 -1
  40. data/lib/tui_tui/test_runtime.rb +71 -0
  41. data/lib/tui_tui/text_input.rb +168 -0
  42. data/lib/tui_tui/text_sanitizer.rb +3 -2
  43. data/lib/tui_tui/theme.rb +25 -27
  44. data/lib/tui_tui/version.rb +1 -1
  45. data/lib/tui_tui/widget/list.rb +59 -0
  46. data/lib/tui_tui/widget/progress.rb +33 -0
  47. data/lib/tui_tui/widget/scroll_list.rb +59 -0
  48. data/lib/tui_tui/widget/scrollbar.rb +43 -0
  49. data/lib/tui_tui/widget/spinner.rb +37 -0
  50. data/lib/tui_tui/widget/status_bar.rb +25 -0
  51. data/lib/tui_tui/widget/table.rb +81 -0
  52. data/lib/tui_tui/widget/tabs.rb +67 -0
  53. data/lib/tui_tui/widget/text_view.rb +47 -0
  54. data/lib/tui_tui/widget/toast.rb +83 -0
  55. data/lib/tui_tui.rb +24 -14
  56. metadata +28 -13
  57. data/lib/tui_tui/confirm.rb +0 -74
  58. data/lib/tui_tui/help.rb +0 -44
  59. data/lib/tui_tui/list.rb +0 -59
  60. data/lib/tui_tui/pager.rb +0 -94
  61. data/lib/tui_tui/prompt.rb +0 -111
  62. data/lib/tui_tui/scroll_list.rb +0 -57
  63. data/lib/tui_tui/scrollbar.rb +0 -41
  64. data/lib/tui_tui/select.rb +0 -104
  65. data/lib/tui_tui/status_bar.rb +0 -23
  66. data/lib/tui_tui/text_view.rb +0 -56
  67. data/lib/tui_tui/toast.rb +0 -82
  68. /data/lib/tui_tui/{event.rb → events.rb} +0 -0
@@ -41,14 +41,5 @@ module TuiTui
41
41
  total_width == PROBE_GLYPHS.length
42
42
  end
43
43
 
44
- # Full resolution given a live console. Honors TUITUI_BOX, else probes; falls
45
- # back to ASCII when forced off, the terminal is too narrow, or the probe fails.
46
- def self.resolve(input:, output:, term_cols:, env: ENV, prober: BoxProber.new)
47
- forced = from(env["TUITUI_BOX"].to_s)
48
- return forced unless forced == :auto
49
- return ASCII if term_cols < MIN_PROBE_COLS
50
-
51
- supported?(prober.measure_all(input: input, output: output)) ? UNICODE : ASCII
52
- end
53
44
  end
54
45
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "box_chrome"
4
+ require_relative "box_prober"
5
+
6
+ module TuiTui
7
+ # Resolves the box-drawing glyph set for a live terminal.
8
+ class BoxChromeResolver
9
+ def initialize(prober: BoxProber.new)
10
+ @prober = prober
11
+ end
12
+
13
+ def resolve(input:, output:, term_cols:, env: ENV)
14
+ forced = BoxChrome.from(env["TUITUI_BOX"])
15
+ return forced unless forced == :auto
16
+ return BoxChrome::ASCII if term_cols < BoxChrome::MIN_PROBE_COLS
17
+
18
+ BoxChrome.supported?(@prober.measure_all(input: input, output: output)) ? BoxChrome::UNICODE : BoxChrome::ASCII
19
+ end
20
+ end
21
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "box_chrome"
4
+ require_relative "clock"
4
5
 
5
6
  module TuiTui
6
7
  # Measures how many columns a string of box-drawing glyphs actually occupies on
@@ -35,10 +36,10 @@ module TuiTui
35
36
  end
36
37
 
37
38
  def read_column(input)
38
- deadline = monotonic + @timeout
39
+ deadline = Clock.monotonic + @timeout
39
40
  buf = +""
40
41
  loop do
41
- remaining = deadline - monotonic
42
+ remaining = deadline - Clock.monotonic
42
43
  break if remaining <= 0
43
44
  break unless @wait.call(input, remaining)
44
45
 
@@ -55,7 +56,5 @@ module TuiTui
55
56
  end
56
57
 
57
58
  def wait_readable(io, timeout) = io.wait_readable(timeout)
58
-
59
- def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
60
59
  end
61
60
  end
@@ -108,59 +108,15 @@ module TuiTui
108
108
  self
109
109
  end
110
110
 
111
- def same_row?(other, r)
112
- grid_row(r) == other.grid_row(r)
113
- end
114
-
115
- def same_size?(other)
116
- @rows == other.rows && @cols == other.cols
117
- end
118
-
119
- # The changed column span of row `r` versus `other`, as [from, to] (1-origin,
120
- # inclusive), or nil if the row is identical. The start is backed up off any
121
- # wide-char continuation cell so a partial repaint never begins mid-glyph.
122
- # Used by the compositor to repaint only the part of a row that moved.
123
- def changed_span(other, r)
124
- mine = grid_row(r)
125
- theirs = other.grid_row(r)
126
- first = last = nil
127
- mine.each_index do |i|
128
- next if mine[i] == theirs[i]
129
-
130
- first ||= i
131
- last = i
132
- end
133
-
134
- return nil if first.nil?
135
-
136
- first -= 1 while first.positive? && mine[first].continuation?
137
- [first + 1, last + 1]
138
- end
139
-
140
- # Render row `r`, or just the column span [from, to], coalescing same-styled
141
- # runs and skipping wide-char continuation cells.
142
- def render_row(r, from: 1, to: @cols, depth: :ansi256, enabled: true)
143
- out = +""
144
- run = +""
145
- run_style = :none
146
- grid_row(r)[(from - 1)..(to - 1)].each do |c|
147
- next if c.continuation?
148
-
149
- if run_style != :none && run_style != c.style
150
- out << paint(run, run_style, depth, enabled)
151
- run = +""
152
- end
153
-
154
- run_style = c.style
155
- run << c.char
156
- end
111
+ def grid_row(r) = @grid[r - 1]
157
112
 
158
- out << paint(run, run_style, depth, enabled) unless run.empty?
159
- out
113
+ # Style-free projection of the grid for snapshot tests: continuation cells
114
+ # of wide glyphs contribute nothing, each row loses its trailing blanks,
115
+ # and rows join with "\n" (no trailing newline). Never contains ANSI.
116
+ def to_text
117
+ @grid.map { |row| row.filter_map(&:char).join.rstrip }.join("\n")
160
118
  end
161
119
 
162
- def grid_row(r) = @grid[r - 1]
163
-
164
120
  private
165
121
 
166
122
  # A fill glyph with any control bytes replaced by CONTROL_GLYPH, so `fill`
@@ -181,10 +137,5 @@ module TuiTui
181
137
  grid_row[col - 1] = cell
182
138
  end
183
139
 
184
- def paint(text, style, depth, enabled)
185
- return text if style.nil? || style == :none
186
-
187
- style.paint(text, depth: depth, enabled: enabled)
188
- end
189
140
  end
190
141
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "ansi"
4
+ require_relative "canvas_renderer"
4
5
 
5
6
  module TuiTui
6
7
  # Builds the terminal update string.
@@ -12,11 +13,12 @@ module TuiTui
12
13
 
13
14
  def render(previous, canvas)
14
15
  out = +""
15
- if full_repaint?(previous, canvas)
16
+ renderer = CanvasRenderer.new(canvas)
17
+ if full_repaint?(previous, renderer)
16
18
  out << Ansi::CLEAR
17
- (1..canvas.rows).each { |row| out << row_paint(canvas, row) }
19
+ (1..canvas.rows).each { |row| out << row_paint(renderer, row) }
18
20
  else
19
- (1..canvas.rows).each { |row| out << row_diff(canvas, previous, row) }
21
+ (1..canvas.rows).each { |row| out << row_diff(renderer, previous, row) }
20
22
  end
21
23
 
22
24
  out
@@ -24,22 +26,22 @@ module TuiTui
24
26
 
25
27
  private
26
28
 
27
- def full_repaint?(previous, canvas)
28
- previous.nil? || !previous.same_size?(canvas)
29
+ def full_repaint?(previous, renderer)
30
+ previous.nil? || !renderer.same_size?(previous)
29
31
  end
30
32
 
31
33
  # The whole row, positioned at column 1 (used for a full repaint).
32
- def row_paint(canvas, row)
33
- Ansi.move(row, 1) + canvas.render_row(row, depth: @depth, enabled: true)
34
+ def row_paint(renderer, row)
35
+ Ansi.move(row, 1) + renderer.render_row(row, depth: @depth, enabled: true)
34
36
  end
35
37
 
36
38
  # Only the changed span of `row`, positioned at its first changed column; ""
37
39
  # when the row is unchanged.
38
- def row_diff(canvas, previous, row)
39
- span = canvas.changed_span(previous, row)
40
+ def row_diff(renderer, previous, row)
41
+ span = renderer.changed_span(previous, row)
40
42
  return "" unless span
41
43
 
42
- Ansi.move(row, span.first) + canvas.render_row(row, from: span.first, to: span.last, depth: @depth, enabled: true)
44
+ Ansi.move(row, span.first) + renderer.render_row(row, from: span.first, to: span.last, depth: @depth, enabled: true)
43
45
  end
44
46
  end
45
47
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ # Terminal-facing row rendering and diff helpers for Canvas.
5
+ class CanvasRenderer
6
+ def initialize(canvas)
7
+ @canvas = canvas
8
+ end
9
+
10
+ def same_size?(other_canvas)
11
+ other_canvas.rows == @canvas.rows && other_canvas.cols == @canvas.cols
12
+ end
13
+
14
+ def changed_span(previous, row)
15
+ mine = @canvas.grid_row(row)
16
+ theirs = previous.grid_row(row)
17
+ first = last = nil
18
+ mine.each_index do |i|
19
+ next if mine[i] == theirs[i]
20
+
21
+ first ||= i
22
+ last = i
23
+ end
24
+
25
+ return nil if first.nil?
26
+
27
+ first -= 1 while first.positive? && mine[first].continuation?
28
+ [first + 1, last + 1]
29
+ end
30
+
31
+ def render_row(row, from: 1, to: @canvas.cols, depth: :ansi256, enabled: true)
32
+ out = +""
33
+ run = +""
34
+ run_style = :none
35
+ @canvas.grid_row(row)[(from - 1)..(to - 1)].each do |cell|
36
+ next if cell.continuation?
37
+
38
+ if run_style != :none && run_style != cell.style
39
+ out << paint(run, run_style, depth, enabled)
40
+ run = +""
41
+ end
42
+
43
+ run_style = cell.style
44
+ run << cell.char
45
+ end
46
+
47
+ out << paint(run, run_style, depth, enabled) unless run.empty?
48
+ out
49
+ end
50
+
51
+ private
52
+
53
+ def paint(text, style, depth, enabled)
54
+ return text if style.nil? || style == :none
55
+
56
+ style.paint(text, depth: depth, enabled: enabled)
57
+ end
58
+ end
59
+ end
data/lib/tui_tui/cell.rb CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  module TuiTui
4
4
  # One terminal cell; a nil char marks the continuation cell of a wide glyph.
5
- Cell = Data.define(:char, :style) do
5
+ Cell = Data.define(:char, :style)
6
+
7
+ class Cell
8
+ BLANK = Cell.new(char: " ", style: nil)
6
9
  def self.blank = BLANK
7
10
  def continuation? = char.nil?
8
11
  end
9
-
10
- Cell::BLANK = Cell.new(char: " ", style: nil)
11
12
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ # The single source of monotonic time, so timers and timeouts never depend on
5
+ # wall-clock adjustments. Injected as a callable where tests need to control it.
6
+ module Clock
7
+ module_function
8
+
9
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
+ end
11
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ # One-shot instructions an app returns from `update` alongside its next
5
+ # state: `[app, *commands]`. The Runtime performs them before rendering.
6
+ module Command
7
+ # Write text to the system clipboard (OSC 52).
8
+ Copy = Data.define(:text)
9
+ # Force a full repaint on the next render (e.g. after Ctrl-L).
10
+ Invalidate = Data.define
11
+
12
+ # The canonical list of known commands; add new command types here.
13
+ ALL = [Copy, Invalidate].freeze
14
+
15
+ def self.validate!(command)
16
+ return if ALL.any? { |type| command.is_a?(type) }
17
+
18
+ raise ArgumentError, "tui_tui: unknown command #{command.inspect}"
19
+ end
20
+ end
21
+ end
@@ -1,23 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "event"
3
+ require_relative "clock"
4
+ require_relative "events"
4
5
  require_relative "key_reader"
5
6
 
6
7
  module TuiTui
7
- # Converts terminal input readiness and resize notifications into runtime events.
8
+ # Converts terminal input readiness and resize notifications into runtime
9
+ # events. Ticks are scheduled against a monotonic deadline, so TickEvent
10
+ # arrives at a fixed cadence even while input keeps flowing (key repeat
11
+ # cannot starve animations or timers).
8
12
  class EventStream
9
- def initialize(input:, size:)
13
+ def initialize(input:, size:, clock: Clock)
10
14
  @input = input
11
15
  @size = size
16
+ @clock = clock
12
17
  @key_reader = KeyReader.new
13
18
  @resized = false
14
19
  @queue = []
20
+ @deadline = nil
15
21
  end
16
22
 
17
23
  def resized!
18
24
  @resized = true
19
25
  end
20
26
 
27
+ # Returns the next event: queued input first, then resize, then whichever
28
+ # of "tick deadline reached" or "input ready" happens first. `tick` is the
29
+ # TickEvent period — ticks land between input events, not after idle gaps.
21
30
  def next_event(tick: 0.1)
22
31
  return @queue.shift unless @queue.empty?
23
32
 
@@ -26,14 +35,40 @@ module TuiTui
26
35
  return ResizeEvent.new(size: @size.size)
27
36
  end
28
37
 
29
- ready = IO.select([@input], nil, nil, tick)
30
- return TickEvent.new unless ready
38
+ now = @clock.monotonic
39
+ @deadline ||= now + tick
40
+ if now >= @deadline
41
+ # Drain pending input into the queue before returning the tick, so a
42
+ # zero (or tiny) period cannot starve keys: the tick goes out now, the
43
+ # input on the very next calls.
44
+ return EofEvent.new if IO.select([@input], nil, nil, 0) && read_input == :eof
31
45
 
46
+ return advance_deadline(now, tick)
47
+ end
48
+
49
+ ready = IO.select([@input], nil, nil, @deadline - now)
50
+ return advance_deadline(@clock.monotonic, tick) unless ready
51
+ return EofEvent.new if read_input == :eof
52
+
53
+ @queue.shift
54
+ end
55
+
56
+ private
57
+
58
+ # Read whatever is ready into the queue; :eof when the input is closed.
59
+ def read_input
32
60
  raw = @key_reader.read_all(@input)
33
- return EofEvent.new if raw.nil?
61
+ return :eof if raw.nil?
34
62
 
35
63
  @queue.concat(raw.map { |event| event.is_a?(MouseEvent) ? event : KeyEvent.new(key: event) })
36
- @queue.shift
64
+ end
65
+
66
+ # Emit one tick and schedule the next. When we have fallen a whole period
67
+ # behind, resync from now instead of bursting a backlog of stale ticks.
68
+ def advance_deadline(now, tick)
69
+ @deadline += tick
70
+ @deadline = now + tick if @deadline <= now
71
+ TickEvent.new
37
72
  end
38
73
  end
39
74
  end
@@ -4,6 +4,9 @@ module TuiTui
4
4
  module KeyCode
5
5
  ESCAPE = "\e"
6
6
  CTRL_C = "\u0003"
7
+ CTRL_L = "\u000C"
8
+ CTRL_N = "\u000E"
9
+ CTRL_P = "\u0010"
7
10
  BACKSPACE = "\u007F"
8
11
  end
9
12
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "event"
3
+ require_relative "events"
4
4
  require_relative "key_code"
5
5
 
6
6
  module TuiTui
data/lib/tui_tui/line.rb CHANGED
@@ -10,6 +10,17 @@ module TuiTui
10
10
  # Convenience constructor: Line[Span["a", s1], Span["b", s2]].
11
11
  def self.[](*spans) = new(spans)
12
12
 
13
+ # Coerce loose content into a Line: a Line passes through, a Span or an Array
14
+ # of Spans is wrapped, and anything else is one Span (in `style`, when given).
15
+ def self.coerce(content, style = nil)
16
+ case content
17
+ when Line then content
18
+ when Span then new([content])
19
+ when Array then new(content)
20
+ else Line[Span[content.to_s, style]]
21
+ end
22
+ end
23
+
13
24
  def initialize(spans = [])
14
25
  @spans = spans
15
26
  end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../text_input"
4
+ require_relative "../widget/scroll_list"
5
+ require_relative "../widget/list"
6
+ require_relative "../line"
7
+ require_relative "../span"
8
+ require_relative "../rect"
9
+ require_relative "../modal"
10
+ require_relative "../fuzzy"
11
+ require_relative "../key_code"
12
+
13
+ module TuiTui
14
+ class Modal
15
+ # Fuzzy-filtered command palette modal (think Ctrl-P): type to narrow a list of
16
+ # commands, arrows or Ctrl-N/Ctrl-P to move, Enter to pick, Esc to cancel.
17
+ #
18
+ # Items are arbitrary objects; pass a block to derive each one's display label
19
+ # (defaults to #to_s). Resolves to the chosen item on Enter and :cancel on
20
+ # escape; stays open (nil) while the query has no matches.
21
+ #
22
+ # host.open(CommandPalette.new(commands) { |c| c.title }) { |cmd| cmd.run; self }
23
+ class CommandPalette < Modal
24
+ MAX_ROWS = 10
25
+ MIN_INNER = 28
26
+ WHEEL = 3
27
+
28
+ def initialize(items, prompt: "> ", placeholder: "Type to search…", theme: Theme::DEFAULT, &label)
29
+ @items = items.to_a
30
+ @label = label || :to_s.to_proc
31
+ @prompt = DisplayText.new(prompt)
32
+ @placeholder = DisplayText.new(placeholder)
33
+ @theme = theme
34
+ # The query buffer. Editing keys stay append-only here: the cursor is
35
+ # pinned at the end (we never feed it left/right/Home/End), because those
36
+ # keys drive the list below, not the query.
37
+ @query = TextInput.new
38
+ @list = Widget::ScrollList.new(0)
39
+ refilter
40
+ end
41
+
42
+ def query = @query.value
43
+
44
+ # The original item under the cursor, or nil when nothing matches.
45
+ def selection = @filtered[@list.cursor]&.first
46
+
47
+ def handle(key)
48
+ case key
49
+ when "\r"
50
+ selection
51
+ when :escape, KeyCode::CTRL_C
52
+ :cancel
53
+ when :up, KeyCode::CTRL_P
54
+ move(-1)
55
+ when :down, KeyCode::CTRL_N
56
+ move(1)
57
+ when :home
58
+ move_to(0)
59
+ when :end
60
+ move_to(@list.last)
61
+ when KeyCode::BACKSPACE, :backspace
62
+ edit { @query.delete_back }
63
+ when String
64
+ edit { @query.type(key) }
65
+ end
66
+ end
67
+
68
+ # Wheel scrolls the highlight; a click on a row picks it (returns the item),
69
+ # otherwise nil to stay open.
70
+ def handle_mouse(event)
71
+ case event.action
72
+ when :wheel
73
+ move(event.button == :wheel_up ? -WHEEL : WHEEL)
74
+ when :press
75
+ click(event)
76
+ end
77
+ end
78
+
79
+ def draw(canvas, size)
80
+ rows = visible_rows(size)
81
+ inner = [MIN_INNER, *@filtered.map { |_item, label, _pos| label.width }].max
82
+ rect, col = panel(canvas, inner: inner, body_rows: rows + 2)
83
+
84
+ draw_query(canvas, rect.row + 1, col, inner)
85
+ draw_items(canvas, rect.row + 3, col, inner, rows)
86
+ canvas
87
+ end
88
+
89
+ private
90
+
91
+ def move(delta)
92
+ @list.move(delta)
93
+ nil
94
+ end
95
+
96
+ def move_to(index)
97
+ @list.go_to(index)
98
+ nil
99
+ end
100
+
101
+ # Apply a query edit, then refilter. Returns nil so the modal stays open.
102
+ def edit
103
+ yield
104
+ refilter
105
+ nil
106
+ end
107
+
108
+ # Recompute the visible list: fuzzy-ranked (best first, with matched positions
109
+ # for highlighting) while querying, otherwise the items in their given order.
110
+ # Each entry is [item, DisplayText(label), positions]; the cursor resets so a
111
+ # narrowed query always lands on the top match.
112
+ def refilter
113
+ @filtered =
114
+ if @query.empty?
115
+ @items.map { |item| [item, label_text(item), []] }
116
+ else
117
+ Fuzzy.new(query).rank(@items) { |item| @label.call(item).to_s }
118
+ .map { |item, found| [item, label_text(item), found.positions] }
119
+ end
120
+ @list.count = @filtered.size
121
+ @list.go_to(0)
122
+ end
123
+
124
+ def label_text(item) = DisplayText.new(@label.call(item).to_s)
125
+
126
+ def visible_rows(size)
127
+ room = [size.rows - 4, 1].max
128
+ [[@filtered.size, 1].max, MAX_ROWS, room].min
129
+ end
130
+
131
+ def draw_query(canvas, row, col, inner)
132
+ canvas.text(row, col, @prompt, theme.accent)
133
+ text_col = col + @prompt.width
134
+ budget = inner - @prompt.width
135
+ if @query.empty?
136
+ canvas.text(row, text_col, @placeholder.truncate(budget), theme.muted)
137
+ else
138
+ canvas.text(row, text_col, DisplayText.new(query).truncate(budget), theme.text)
139
+ end
140
+ end
141
+
142
+ def draw_items(canvas, row, col, inner, rows)
143
+ @items_rect = Rect.new(row: row, col: col, rows: rows, cols: inner)
144
+ if @filtered.empty?
145
+ canvas.text(row, col, DisplayText.new("No matches").truncate(inner), theme.muted)
146
+ return
147
+ end
148
+
149
+ Widget::List.new(@list).draw(canvas, @items_rect, highlight: theme.selection) do |index, focused|
150
+ _item, label, positions = @filtered[index]
151
+ base = focused ? theme.selection : theme.text
152
+ # Keep the focused row a single style; highlight matches with accent only
153
+ # on unfocused rows so the selection bar stays legible.
154
+ match = focused ? base : theme.accent
155
+ styled_line(label.to_s, positions, base, match)
156
+ end
157
+ end
158
+
159
+ # The label as a Line with matched graphemes in `match` and the rest in `base`;
160
+ # runs of the same style coalesce into one Span (grapheme indices line up with
161
+ # Fuzzy#positions).
162
+ def styled_line(label, positions, base, match)
163
+ return Line[Span[label, base]] if positions.empty?
164
+
165
+ spans = []
166
+ run = +""
167
+ run_style = nil
168
+ label.grapheme_clusters.each_with_index do |grapheme, i|
169
+ style = positions.include?(i) ? match : base
170
+ if style != run_style && !run.empty?
171
+ spans << Span[run, run_style]
172
+ run = +""
173
+ end
174
+ run_style = style
175
+ run << grapheme
176
+ end
177
+ spans << Span[run, run_style] unless run.empty?
178
+ Line.new(spans)
179
+ end
180
+
181
+ # The item under a click, picked, or nil if the click missed the list.
182
+ def click(event)
183
+ return nil unless @items_rect
184
+
185
+ index = Widget::List.new(@list).index_at(@items_rect, event)
186
+ return nil if index.nil?
187
+
188
+ @list.go_to(index)
189
+ selection
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../modal"
4
+ require_relative "../key_code"
5
+
6
+ module TuiTui
7
+ class Modal
8
+ # OK / Cancel confirmation modal.
9
+ class Confirm < Modal
10
+ GAP = 2
11
+
12
+ attr_reader :focus
13
+
14
+ def initialize(message, ok: "OK", cancel: "Cancel", default: :cancel, theme: Theme::DEFAULT)
15
+ @message = DisplayText.new(message)
16
+ @ok = button_text(ok)
17
+ @cancel = button_text(cancel)
18
+ @focus = default
19
+ @theme = theme
20
+ end
21
+
22
+ def handle(key)
23
+ case key
24
+ when :left, :right, "\t", "h", "l"
25
+ toggle
26
+ when "\r", " "
27
+ @focus
28
+ when "y", "Y"
29
+ :ok
30
+ when "n", "N", :escape, KeyCode::CTRL_C
31
+ :cancel
32
+ end
33
+ end
34
+
35
+ def handle_mouse(event)
36
+ return nil unless event.action == :press && @buttons_row == event.row
37
+
38
+ return :ok if hit?(event.col, @ok_at, @ok.width)
39
+ return :cancel if hit?(event.col, @cancel_at, @cancel.width)
40
+
41
+ nil
42
+ end
43
+
44
+ def draw(canvas, size)
45
+ inner = [@message.width, buttons_width].max
46
+ rect, col = panel(canvas, inner: inner, body_rows: 3)
47
+ canvas.text(rect.row + 1, col, @message.center(inner), theme.text)
48
+ draw_buttons(canvas, rect.row + 3, col, inner)
49
+ canvas
50
+ end
51
+
52
+ private
53
+
54
+ def hit?(col, start, width) = col.between?(start, start + width - 1)
55
+
56
+ def toggle
57
+ @focus = @focus == :ok ? :cancel : :ok
58
+ nil
59
+ end
60
+
61
+ def draw_buttons(canvas, row, col, inner)
62
+ start = col + [(inner - buttons_width) / 2, 0].max
63
+ @buttons_row = row
64
+ @ok_at = start
65
+ @cancel_at = start + @ok.width + GAP
66
+ canvas.text(row, @ok_at, @ok, @focus == :ok ? theme.selection : theme.text)
67
+ canvas.text(row, @cancel_at, @cancel, @focus == :cancel ? theme.selection : theme.text)
68
+ end
69
+
70
+ def buttons_width = @ok.width + GAP + @cancel.width
71
+ def button_text(label) = DisplayText.new("[ #{label} ]")
72
+ end
73
+ end
74
+ end