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 +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +6 -10
- data/examples/file_browser.rb +51 -8
- data/examples/paint.rb +3 -2
- data/examples/widgets.rb +3 -2
- data/lib/tui_tui/box_chrome.rb +54 -0
- data/lib/tui_tui/box_prober.rb +60 -0
- data/lib/tui_tui/canvas.rb +12 -9
- data/lib/tui_tui/clock.rb +11 -0
- data/lib/tui_tui/command_palette.rb +190 -0
- data/lib/tui_tui/key_code.rb +3 -0
- data/lib/tui_tui/line.rb +11 -0
- data/lib/tui_tui/list.rb +17 -5
- data/lib/tui_tui/modal_host.rb +61 -0
- data/lib/tui_tui/pager.rb +15 -3
- data/lib/tui_tui/prompt.rb +2 -5
- data/lib/tui_tui/rect.rb +8 -0
- data/lib/tui_tui/render_context.rb +14 -0
- data/lib/tui_tui/runtime.rb +20 -4
- data/lib/tui_tui/screen.rb +29 -5
- data/lib/tui_tui/scrollbar.rb +2 -1
- data/lib/tui_tui/select.rb +3 -4
- data/lib/tui_tui/span.rb +5 -0
- data/lib/tui_tui/terminal_session.rb +16 -1
- data/lib/tui_tui/text_sanitizer.rb +10 -2
- data/lib/tui_tui/text_view.rb +7 -14
- data/lib/tui_tui/theme.rb +32 -4
- data/lib/tui_tui/toast.rb +2 -1
- data/lib/tui_tui/version.rb +1 -1
- data/lib/tui_tui.rb +6 -0
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 509e482a4d22483da9840ee1ea39021f27f747f2c6919d7782e46f41dbfdfea0
|
|
4
|
+
data.tar.gz: 731b150add99bd21eafef87e2a33c36679eb5998f01a78bf317217a1c7ee428a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
87
|
+
#### N7: Width-safe UI chrome.
|
|
88
88
|
|
|
89
|
-
Self-drawn chrome
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
data/examples/file_browser.rb
CHANGED
|
@@ -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(
|
|
292
|
-
|
|
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
|
|
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).
|
|
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(
|
|
163
|
+
def view(ctx)
|
|
164
|
+
size = ctx.size
|
|
164
165
|
@rows = size.rows
|
|
165
|
-
canvas =
|
|
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(
|
|
50
|
-
|
|
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
|
data/lib/tui_tui/canvas.rb
CHANGED
|
@@ -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
|
-
|
|
98
|
-
text(rect.row, rect.col,
|
|
99
|
-
text(rect.row + rect.rows - 1, rect.col,
|
|
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,
|
|
102
|
-
text(rect.row + dy, rect.col + rect.cols - 1,
|
|
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
|
data/lib/tui_tui/key_code.rb
CHANGED
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
|