tui_tui 0.1.0 → 0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7ad8088986ea6e9bc201be5c89597019f4a256de1531f73184e489233752175
4
- data.tar.gz: b08cec47d0355748b9fdf732527f29d2ad2cb104ae1e88c0af5c17a9e488f552
3
+ metadata.gz: 509e482a4d22483da9840ee1ea39021f27f747f2c6919d7782e46f41dbfdfea0
4
+ data.tar.gz: 731b150add99bd21eafef87e2a33c36679eb5998f01a78bf317217a1c7ee428a
5
5
  SHA512:
6
- metadata.gz: 4058461db872786a4db951d4b1cd4b789ca7cc5cdfc07eeec8be7c4f2d1290bca30916f2405fe021f870465d74acab0f886128705567aca961346f389a048684
7
- data.tar.gz: 3634ec3d3c0da6967adf48af668790559087690e543352a9bb3690d61b435553689e46647cbb13b8619deec409abd19fa95f9bb286d8140baaa1d328237ad114
6
+ metadata.gz: '068472d5d8dd0c5ae371ffe2516bbe1f1a548d3d55c97e85b898e821640ed6a64b0e8005fa3e8c219c20cfae79e1e86efafbb2f851f7dbbc6830e5ec2e59c455'
7
+ data.tar.gz: 37e91b9643cc30df3a887e90004859cc37035c52882f353a66148fce585d47c1ba1075af142650fad597e3d34652b9a1983a11f2c19019163740920cff4afadf
data/CHANGELOG.md ADDED
@@ -0,0 +1,54 @@
1
+ # Changelog
2
+
3
+ ## [0.3.0] - 2026-06-22
4
+
5
+ ### Added
6
+ - `Pager` accepts styled lines for coloring: each line may be a plain `String`,
7
+ a `Line`, or an array of `Span`s, so a log / diff / error view can color whole
8
+ lines or runs within them. Unstyled spans fall back to `theme.muted`.
9
+ - `Line.coerce(content, style = nil)`: shared `String` / `Span` / `Span`-array /
10
+ `Line` → `Line` conversion, used by `List`, `TextView`, and `Pager`.
11
+ - `CommandPalette`: a fuzzy-filtered command palette modal (type to narrow,
12
+ arrows or Ctrl-N/Ctrl-P to move, Enter to pick, Esc to cancel). Items are
13
+ arbitrary objects with an optional label block; resolves to the chosen item.
14
+ - Per-frame mouse-reporting toggle: an app may implement `wants_mouse?` and the
15
+ `Runtime` applies it each frame (via `Screen#mouse=` / `TerminalSession#mouse=`),
16
+ so it can release the mouse for a native terminal selection and recapture it.
17
+
18
+ ### Fixed
19
+ - Raw mode now keeps the interrupt/quit/suspend characters live (`raw!(intr:
20
+ true)`), so Ctrl-C raises `SIGINT` (a real force-quit, restored by the INT
21
+ trap) instead of arriving as a byte.
22
+
23
+ ## [0.2.0] - 2026-06-17
24
+
25
+ ### Added
26
+ - Optional Unicode box-drawing chrome: probed once at startup and used only when
27
+ the terminal renders it at width 1, else ASCII. Override with `TUITUI_BOX`.
28
+ - `RenderContext` passed to `view` (a `Size`-compatible value with a `canvas`
29
+ factory); legacy `view(size)` apps keep working.
30
+ - `Rect#include?(row, col)` and `Rect#hit?(mouse_event)` for mouse hit-testing.
31
+ - `List#index_at(rect, event, scrollbar:)` to map a click to a list index,
32
+ accounting for the scroll offset and the scrollbar gutter.
33
+ - `Theme` semantic status roles — `success` / `warning` / `danger` / `info`
34
+ (background-aware, hue-independent) — plus `Theme#status(kind)` to map
35
+ symbolic kinds (`:ok`, `:warn`, `:error`, `:info`, with aliases) to a role.
36
+ - `ModalHost`: a host-side helper that owns the current modal widget, routing
37
+ `MouseEvent`s to `#handle_mouse` and other events to `#handle`, and running an
38
+ `on_result` callback when the widget resolves.
39
+ - `auto:` option for `List.draw` / `TextView.draw`: reserve the scrollbar gutter
40
+ only when the content overflows the rect.
41
+
42
+ ### Fixed
43
+ - Silence the "method redefined; discarding old []" warning from `Span` under
44
+ `-w` by removing the `Data`-generated `.[]` before redefining the convenience
45
+ constructor.
46
+
47
+ ## [0.1.0] - 2026-06-16
48
+
49
+ ### Added
50
+ - Initial release: a lightweight, dependency-free (io/console only) TEA-inspired
51
+ (MVU) TUI toolkit — Canvas with per-cell diffing, Theme, layout `Rect`s,
52
+ widgets (List, TextView, Scrollbar, StatusBar, Toast, Modal, Confirm, Select,
53
+ Help, Prompt, Pager, Fuzzy), East-Asian-width-aware text, and a `Runtime`
54
+ event loop.
data/README.md CHANGED
@@ -84,17 +84,12 @@ Glyphs are clipped at region edges, not split across them.
84
84
  Movement and redraw stay responsive, even with large content.
85
85
  Only changed rows are repainted, so cost scales with the change, not the screen size.
86
86
 
87
- #### N7: Width-safe UI chrome (ASCII).
87
+ #### N7: Width-safe UI chrome.
88
88
 
89
- Self-drawn chrome uses only ASCII, color, and spacing.
90
- ASCII characters have a guaranteed width of 1.
91
-
92
- Unicode box-drawing characters are never used.
93
- Their width can vary under CJK terminal settings and break layouts.
94
-
95
- Vertical splits are drawn as a colored one-column gutter.
96
- Rules are drawn with ASCII `-` or a background fill.
97
- Selection is drawn with `:reverse`.
89
+ Self-drawn chrome defaults to ASCII, color, and spacing, which have a guaranteed
90
+ width of 1. Unicode box-drawing has an ambiguous width that can break layouts under
91
+ CJK terminal settings, so it is only used when the terminal is confirmed to render
92
+ it at width 1; otherwise the chrome falls back to ASCII.
98
93
 
99
94
  Content text, such as Japanese data, is measured with `Width`.
100
95
  It is clipped or padded to fit the available space.
@@ -105,6 +100,7 @@ Environment variables (all optional):
105
100
 
106
101
  - `TUITUI_MOUSE` — set to `0`/`off`/`false` to disable mouse reporting (on by default).
107
102
  - `TUITUI_BACKGROUND` — `light` or `dark` to pick the theme for your terminal background. Without it, `COLORFGBG` is read if present, otherwise `dark` is assumed (reliable auto-detection isn't possible on all terminals).
103
+ - `TUITUI_BOX` — `ascii` / `unicode` / `auto` to force or auto-detect Unicode box-drawing chrome (default `auto`: used only when the terminal renders it at width 1, else ASCII).
108
104
 
109
105
  ## Installation
110
106
 
@@ -10,11 +10,11 @@
10
10
  #
11
11
  # Keys: j/k (or ↑/↓) move, l/Enter/→ open dir, h/←/Backspace up, g/G top/bottom,
12
12
  # Tab switch pane, J/K (or mouse wheel) scroll the preview, w wrap, t theme, </> divider, / fuzzy find,
13
- # y copy the path (OSC 52), m actions menu, ? help, q (or Ctrl-C) quit.
13
+ # y copy the path (OSC 52), : command palette, m actions menu, ? help, q (or Ctrl-C) quit.
14
14
  #
15
15
  # `/` is an incremental fuzzy finder built on TuiTui::Fuzzy (type to narrow,
16
16
  # matched characters highlighted, ↑↓ to navigate, Enter to open, Esc to cancel).
17
- # The m / ? / q modals are TuiTui widgets (Select, Help, Confirm).
17
+ # The : / m / ? / q modals are TuiTui widgets (CommandPalette, Select, Help, Confirm).
18
18
 
19
19
  require "strscan"
20
20
  require_relative "../lib/tui_tui"
@@ -177,6 +177,7 @@ module FileBrowserSample
177
177
  ["t", "cycle theme (cool / warm / mono, follows light/dark)"],
178
178
  ["/", "fuzzy find (↑↓ navigate, Enter open, Esc cancel)"],
179
179
  ["y", "copy path to clipboard"],
180
+ [":", "command palette (fuzzy-run any command)"],
180
181
  ["m", "actions menu"],
181
182
  ["?", "this help"],
182
183
  ["q", "quit"],
@@ -184,6 +185,20 @@ module FileBrowserSample
184
185
 
185
186
  ACTIONS = [["Up to parent", :parent], ["Refresh", :refresh], ["Quit", :quit]].freeze
186
187
 
188
+ # Commands surfaced in the ":" command palette. Each is [label, action]; the
189
+ # palette ranks by the label and resolves to the chosen pair (see run_command).
190
+ COMMANDS = [
191
+ ["Open selected entry", :open],
192
+ ["Up to parent directory", :parent],
193
+ ["Refresh listing", :refresh],
194
+ ["Toggle preview wrap", :wrap],
195
+ ["Cycle theme", :theme],
196
+ ["Copy path to clipboard", :copy],
197
+ ["Fuzzy find", :find],
198
+ ["Keyboard help", :help],
199
+ ["Quit", :quit],
200
+ ].freeze
201
+
187
202
  # The app: responds to view(size) -> Canvas and update(event) -> self | :quit,
188
203
  # which is all TuiTui::Runtime asks of it.
189
204
  class Browser
@@ -288,8 +303,9 @@ module FileBrowserSample
288
303
 
289
304
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
290
305
 
291
- def view(size)
292
- canvas = TuiTui::Canvas.blank(size)
306
+ def view(ctx)
307
+ size = ctx.size
308
+ canvas = ctx.canvas
293
309
  body, status = split_status(size)
294
310
  list_rect, preview_rect = split_panes(body)
295
311
  @list_rect = list_rect # remembered so a click can hit-test the list
@@ -347,13 +363,39 @@ module FileBrowserSample
347
363
  end
348
364
  end
349
365
 
366
+ def open_help = open_modal(TuiTui::Help.new("Keys", HELP, theme: @theme)) { nil }
367
+
368
+ # The ":" command palette: a fuzzy-filtered list of every command. The palette
369
+ # ranks by the label and resolves to the chosen [label, action] pair (or
370
+ # :cancel on Esc), which run_command dispatches.
371
+ def open_palette
372
+ open_modal(TuiTui::CommandPalette.new(COMMANDS, theme: @theme) { |label, _action| label }) do |chosen|
373
+ run_command(chosen.last) if chosen.is_a?(Array)
374
+ end
375
+ end
376
+
377
+ def run_command(action)
378
+ case action
379
+ when :open then open_entry
380
+ when :parent then up_dir
381
+ when :refresh then load_entries
382
+ when :wrap then toggle_preview_wrap
383
+ when :theme then cycle_theme
384
+ when :copy then copy_path
385
+ when :find then enter_finder
386
+ when :help then open_help # palettes can chain into another modal
387
+ when :quit then :quit
388
+ end
389
+ end
390
+
350
391
  # --- input ---
351
392
 
352
393
  def handle_key(key)
353
394
  case key
354
395
  when "q", TuiTui::KeyCode::CTRL_C then confirm_quit
355
- when "?" then open_modal(TuiTui::Help.new("Keys", HELP, theme: @theme)) { nil }
396
+ when "?" then open_help
356
397
  when "/" then enter_finder
398
+ when ":" then open_palette
357
399
  when "m" then open_actions
358
400
  when "l", "\r", :right then open_entry
359
401
  when "h", :left, TuiTui::KeyCode::BACKSPACE then up_dir # h / ← / Backspace
@@ -552,10 +594,11 @@ module FileBrowserSample
552
594
  # --- drawing ---
553
595
 
554
596
  # A dim vertical rule in the 1-column gutter between the panes (the column
555
- # split_ratio left between list and preview). ASCII "|" (N7), dim like frames.
597
+ # split_ratio left between list and preview). Follows the canvas chrome:
598
+ # ASCII "|" by default, "│" when the terminal probed as Unicode-capable.
556
599
  def draw_divider(canvas, list_rect)
557
600
  col = list_rect.col + list_rect.cols
558
- canvas.fill(TuiTui::Rect.new(row: list_rect.row, col: col, rows: list_rect.rows, cols: 1), @styles[:divider], "|")
601
+ canvas.fill(TuiTui::Rect.new(row: list_rect.row, col: col, rows: list_rect.rows, cols: 1), @styles[:divider], canvas.chrome.v)
559
602
  end
560
603
 
561
604
  def draw_list(canvas, rect)
@@ -629,7 +672,7 @@ module FileBrowserSample
629
672
 
630
673
  def draw_status(canvas, rect)
631
674
  left = @finder ? " > #{@finder}" : " #{@dir}"
632
- hints = @finder ? "Esc=cancel Enter=open" : "?=help /=find m=menu t=#{THEMES[@theme_i]} q=quit"
675
+ hints = @finder ? "Esc=cancel Enter=open" : "?=help /=find :=cmds m=menu t=#{THEMES[@theme_i]} q=quit"
633
676
  right = "#{@list.cursor + 1}/#{@entries.size} #{hints} "
634
677
  TuiTui::StatusBar.draw(canvas, rect, left: left, right: right, style: @styles[:bar])
635
678
  end
data/examples/paint.rb CHANGED
@@ -160,9 +160,10 @@ module PaintSample
160
160
  end
161
161
  end
162
162
 
163
- def view(size)
163
+ def view(ctx)
164
+ size = ctx.size
164
165
  @rows = size.rows
165
- canvas = TuiTui::Canvas.blank(size)
166
+ canvas = ctx.canvas
166
167
  @cells.each do |(row, col), color|
167
168
  next unless row.between?(PAINT_TOP, size.rows) && col.between?(1, size.cols)
168
169
 
data/examples/widgets.rb CHANGED
@@ -46,8 +46,9 @@ module WidgetsSample
46
46
  end
47
47
  end
48
48
 
49
- def view(size)
50
- canvas = TuiTui::Canvas.blank(size)
49
+ def view(ctx)
50
+ size = ctx.size
51
+ canvas = ctx.canvas
51
52
  canvas.text(2, 3, "TuiTui widget gallery", @theme.title)
52
53
  canvas.text(4, 3, "theme: #{THEMES[@theme_i]}", @theme.accent)
53
54
  canvas.text(5, 3, "last result: #{@last}", @theme.text)
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ # The glyph set used to draw chrome (frame borders, dividers, scrollbar track).
5
+ BoxChrome = Data.define(:tl, :tr, :bl, :br, :h, :v, :lt, :rt, :tt, :bt, :cross, :track)
6
+
7
+ class BoxChrome
8
+ ASCII = new(
9
+ tl: "+", tr: "+", bl: "+", br: "+",
10
+ h: "-", v: "|",
11
+ lt: "+", rt: "+", tt: "+", bt: "+", cross: "+",
12
+ track: "|"
13
+ )
14
+
15
+ # Single-line box drawing (U+2500..U+253C).
16
+ UNICODE = new(
17
+ tl: "┌", tr: "┐", bl: "└", br: "┘",
18
+ h: "─", v: "│",
19
+ lt: "├", rt: "┤", tt: "┬", bt: "┴", cross: "┼",
20
+ track: "│"
21
+ )
22
+
23
+ # The distinct Unicode glyphs chrome can emit, probed as one string.
24
+ PROBE_GLYPHS = "─│┌┐└┘├┤┬┴┼"
25
+
26
+ # Narrower than this and the probe glyphs would wrap at column 1.
27
+ MIN_PROBE_COLS = 12
28
+
29
+ # Resolve an override string to a chrome, or :auto when a probe is needed.
30
+ def self.from(name)
31
+ case name.to_s.downcase
32
+ when "ascii", "0", "off", "false" then ASCII
33
+ when "unicode", "1", "on", "true" then UNICODE
34
+ else :auto
35
+ end
36
+ end
37
+
38
+ # The capability gate: every probed glyph must render at width 1, so the total
39
+ # advance equals the glyph count.
40
+ def self.supported?(total_width)
41
+ total_width == PROBE_GLYPHS.length
42
+ end
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
+ end
54
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "box_chrome"
4
+ require_relative "clock"
5
+
6
+ module TuiTui
7
+ # Measures how many columns a string of box-drawing glyphs actually occupies on
8
+ # the live terminal, by printing them and asking for the cursor position (DSR).
9
+ class BoxProber
10
+ # Cursor Position Report: ESC [ row ; col R -> capture the column.
11
+ CPR = /\e\[\d+;(\d+)R/
12
+ MAX_REPLY_BYTES = 64
13
+
14
+ def initialize(glyphs: BoxChrome::PROBE_GLYPHS, timeout: 0.2, wait: nil)
15
+ @glyphs = glyphs
16
+ @timeout = timeout
17
+ @wait = wait || method(:wait_readable)
18
+ end
19
+
20
+ def measure_all(input:, output:)
21
+ output.write("\r") # known baseline: column 1
22
+ output.write(@glyphs) # advances by the sum of glyph widths
23
+ output.write("\e[6n") # DSR: request cursor position
24
+ output.flush
25
+ col = read_column(input)
26
+ cleanup(output)
27
+ col.nil? ? -1 : col - 1
28
+ end
29
+
30
+ private
31
+
32
+ # Wipe the probe line before the first render (the alt screen stays clean).
33
+ def cleanup(output)
34
+ output.write("\r\e[K")
35
+ output.flush
36
+ end
37
+
38
+ def read_column(input)
39
+ deadline = Clock.monotonic + @timeout
40
+ buf = +""
41
+ loop do
42
+ remaining = deadline - Clock.monotonic
43
+ break if remaining <= 0
44
+ break unless @wait.call(input, remaining)
45
+
46
+ char = input.getc
47
+ break if char.nil?
48
+
49
+ buf << char
50
+ if (match = CPR.match(buf))
51
+ return match[1].to_i
52
+ end
53
+ break if buf.bytesize > MAX_REPLY_BYTES
54
+ end
55
+ nil
56
+ end
57
+
58
+ def wait_readable(io, timeout) = io.wait_readable(timeout)
59
+ end
60
+ end
@@ -5,6 +5,7 @@ require_relative "text_sanitizer"
5
5
  require_relative "display_text"
6
6
  require_relative "style"
7
7
  require_relative "cell"
8
+ require_relative "box_chrome"
8
9
 
9
10
  module TuiTui
10
11
  # Pure drawing surface. Coordinates are 1-origin to match terminal cursor
@@ -14,18 +15,20 @@ module TuiTui
14
15
  CONTROL_GLYPH = "?"
15
16
  FRAME = Style.new(fg: :bright_black)
16
17
 
17
- def self.blank(size)
18
- new(size.rows, size.cols)
18
+ def self.blank(size, chrome: BoxChrome::ASCII)
19
+ new(size.rows, size.cols, chrome: chrome)
19
20
  end
20
21
 
21
22
  attr_reader :rows, :cols
22
23
  attr_reader :cursor
24
+ attr_reader :chrome
23
25
 
24
- def initialize(rows, cols)
26
+ def initialize(rows, cols, chrome: BoxChrome::ASCII)
25
27
  @rows = rows
26
28
  @cols = cols
27
29
  @grid = Array.new(rows) { Array.new(cols, Cell::BLANK) }
28
30
  @cursor = nil
31
+ @chrome = chrome
29
32
  end
30
33
 
31
34
  def cursor_at(row, col)
@@ -92,14 +95,14 @@ module TuiTui
92
95
  text(row, col, char * len, style)
93
96
  end
94
97
 
95
- def frame(rect, style: FRAME)
98
+ def frame(rect, style: FRAME, chrome: @chrome)
96
99
  fill(rect, nil)
97
- bar = "+#{"-" * (rect.cols - 2)}+"
98
- text(rect.row, rect.col, bar, style)
99
- text(rect.row + rect.rows - 1, rect.col, bar, style)
100
+ mid = chrome.h * (rect.cols - 2)
101
+ text(rect.row, rect.col, chrome.tl + mid + chrome.tr, style)
102
+ text(rect.row + rect.rows - 1, rect.col, chrome.bl + mid + chrome.br, style)
100
103
  (1...(rect.rows - 1)).each do |dy|
101
- text(rect.row + dy, rect.col, "|", style)
102
- text(rect.row + dy, rect.col + rect.cols - 1, "|", style)
104
+ text(rect.row + dy, rect.col, chrome.v, style)
105
+ text(rect.row + dy, rect.col + rect.cols - 1, chrome.v, style)
103
106
  end
104
107
 
105
108
  self
@@ -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,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "display_text"
4
+ require_relative "text_sanitizer"
5
+ require_relative "style"
6
+ require_relative "scroll_list"
7
+ require_relative "list"
8
+ require_relative "line"
9
+ require_relative "span"
10
+ require_relative "rect"
11
+ require_relative "modal"
12
+ require_relative "fuzzy"
13
+ require_relative "key_code"
14
+
15
+ module TuiTui
16
+ # Fuzzy-filtered command palette modal (think Ctrl-P): type to narrow a list of
17
+ # commands, arrows or Ctrl-N/Ctrl-P to move, Enter to pick, Esc to cancel.
18
+ #
19
+ # Items are arbitrary objects; pass a block to derive each one's display label
20
+ # (defaults to #to_s). Resolves to the chosen item on Enter and :cancel on
21
+ # escape; stays open (nil) while the query has no matches.
22
+ #
23
+ # host.open(CommandPalette.new(commands) { |c| c.title }) { |cmd| cmd.run; self }
24
+ class CommandPalette < Modal
25
+ MAX_ROWS = 10
26
+ MIN_INNER = 28
27
+ WHEEL = 3
28
+
29
+ def initialize(items, prompt: "> ", placeholder: "Type to search…", theme: Theme::DEFAULT, &label)
30
+ @items = items.to_a
31
+ @label = label || :to_s.to_proc
32
+ @prompt = DisplayText.new(prompt)
33
+ @placeholder = DisplayText.new(placeholder)
34
+ @theme = theme
35
+ @graphemes = []
36
+ @list = ScrollList.new(0)
37
+ refilter
38
+ end
39
+
40
+ def query = @graphemes.join
41
+
42
+ # The original item under the cursor, or nil when nothing matches.
43
+ def selection = @filtered[@list.cursor]&.first
44
+
45
+ def handle(key)
46
+ case key
47
+ when "\r"
48
+ selection
49
+ when :escape, KeyCode::CTRL_C
50
+ :cancel
51
+ when :up, KeyCode::CTRL_P
52
+ move(-1)
53
+ when :down, KeyCode::CTRL_N
54
+ move(1)
55
+ when :home
56
+ move_to(0)
57
+ when :end
58
+ move_to(@list.last)
59
+ when KeyCode::BACKSPACE, :backspace
60
+ edit { @graphemes.pop }
61
+ when String
62
+ edit { @graphemes.concat(key.grapheme_clusters) if TextSanitizer.printable?(key) }
63
+ end
64
+ end
65
+
66
+ # Wheel scrolls the highlight; a click on a row picks it (returns the item),
67
+ # otherwise nil to stay open.
68
+ def handle_mouse(event)
69
+ case event.action
70
+ when :wheel
71
+ move(event.button == :wheel_up ? -WHEEL : WHEEL)
72
+ when :press
73
+ click(event)
74
+ end
75
+ end
76
+
77
+ def draw(canvas, size)
78
+ rows = visible_rows(size)
79
+ inner = [MIN_INNER, *@filtered.map { |_item, label, _pos| label.width }].max
80
+ rect, col = panel(canvas, inner: inner, body_rows: rows + 2)
81
+
82
+ draw_query(canvas, rect.row + 1, col, inner)
83
+ draw_items(canvas, rect.row + 3, col, inner, rows)
84
+ canvas
85
+ end
86
+
87
+ private
88
+
89
+ def move(delta)
90
+ @list.move(delta)
91
+ nil
92
+ end
93
+
94
+ def move_to(index)
95
+ @list.go_to(index)
96
+ nil
97
+ end
98
+
99
+ # Apply a query edit, then refilter. Returns nil so the modal stays open.
100
+ def edit
101
+ yield
102
+ refilter
103
+ nil
104
+ end
105
+
106
+ # Recompute the visible list: fuzzy-ranked (best first, with matched positions
107
+ # for highlighting) while querying, otherwise the items in their given order.
108
+ # Each entry is [item, DisplayText(label), positions]; the cursor resets so a
109
+ # narrowed query always lands on the top match.
110
+ def refilter
111
+ @filtered =
112
+ if @graphemes.empty?
113
+ @items.map { |item| [item, label_text(item), []] }
114
+ else
115
+ Fuzzy.new(query).rank(@items) { |item| @label.call(item).to_s }
116
+ .map { |item, found| [item, label_text(item), found.positions] }
117
+ end
118
+ @list.count = @filtered.size
119
+ @list.go_to(0)
120
+ end
121
+
122
+ def label_text(item) = DisplayText.new(@label.call(item).to_s)
123
+
124
+ def visible_rows(size)
125
+ room = [size.rows - 4, 1].max
126
+ [[@filtered.size, 1].max, MAX_ROWS, room].min
127
+ end
128
+
129
+ def draw_query(canvas, row, col, inner)
130
+ canvas.text(row, col, @prompt, theme.accent)
131
+ text_col = col + @prompt.width
132
+ budget = inner - @prompt.width
133
+ if @graphemes.empty?
134
+ canvas.text(row, text_col, @placeholder.truncate(budget), theme.muted)
135
+ else
136
+ canvas.text(row, text_col, DisplayText.new(query).truncate(budget), theme.text)
137
+ end
138
+ end
139
+
140
+ def draw_items(canvas, row, col, inner, rows)
141
+ @items_rect = Rect.new(row: row, col: col, rows: rows, cols: inner)
142
+ if @filtered.empty?
143
+ canvas.text(row, col, DisplayText.new("No matches").truncate(inner), theme.muted)
144
+ return
145
+ end
146
+
147
+ List.new(@list).draw(canvas, @items_rect, highlight: theme.selection) do |index, focused|
148
+ _item, label, positions = @filtered[index]
149
+ base = focused ? theme.selection : theme.text
150
+ # Keep the focused row a single style; highlight matches with accent only
151
+ # on unfocused rows so the selection bar stays legible.
152
+ match = focused ? base : theme.accent
153
+ styled_line(label.to_s, positions, base, match)
154
+ end
155
+ end
156
+
157
+ # The label as a Line with matched graphemes in `match` and the rest in `base`;
158
+ # runs of the same style coalesce into one Span (grapheme indices line up with
159
+ # Fuzzy#positions).
160
+ def styled_line(label, positions, base, match)
161
+ return Line[Span[label, base]] if positions.empty?
162
+
163
+ spans = []
164
+ run = +""
165
+ run_style = nil
166
+ label.grapheme_clusters.each_with_index do |grapheme, i|
167
+ style = positions.include?(i) ? match : base
168
+ if style != run_style && !run.empty?
169
+ spans << Span[run, run_style]
170
+ run = +""
171
+ end
172
+ run_style = style
173
+ run << grapheme
174
+ end
175
+ spans << Span[run, run_style] unless run.empty?
176
+ Line.new(spans)
177
+ end
178
+
179
+ # The item under a click, picked, or nil if the click missed the list.
180
+ def click(event)
181
+ return nil unless @items_rect
182
+
183
+ index = List.new(@list).index_at(@items_rect, event)
184
+ return nil if index.nil?
185
+
186
+ @list.go_to(index)
187
+ selection
188
+ end
189
+ end
190
+ 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
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