tui_tui 0.2.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 +20 -0
- data/examples/file_browser.rb +45 -4
- data/lib/tui_tui/box_prober.rb +3 -4
- 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 +1 -3
- data/lib/tui_tui/pager.rb +15 -3
- data/lib/tui_tui/prompt.rb +2 -5
- data/lib/tui_tui/runtime.rb +9 -0
- data/lib/tui_tui/screen.rb +8 -0
- data/lib/tui_tui/select.rb +3 -4
- 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 +1 -12
- data/lib/tui_tui/toast.rb +2 -1
- data/lib/tui_tui/version.rb +1 -1
- data/lib/tui_tui.rb +2 -0
- metadata +4 -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
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
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
|
+
|
|
3
23
|
## [0.2.0] - 2026-06-17
|
|
4
24
|
|
|
5
25
|
### Added
|
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
|
|
@@ -348,13 +363,39 @@ module FileBrowserSample
|
|
|
348
363
|
end
|
|
349
364
|
end
|
|
350
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
|
+
|
|
351
391
|
# --- input ---
|
|
352
392
|
|
|
353
393
|
def handle_key(key)
|
|
354
394
|
case key
|
|
355
395
|
when "q", TuiTui::KeyCode::CTRL_C then confirm_quit
|
|
356
|
-
when "?" then
|
|
396
|
+
when "?" then open_help
|
|
357
397
|
when "/" then enter_finder
|
|
398
|
+
when ":" then open_palette
|
|
358
399
|
when "m" then open_actions
|
|
359
400
|
when "l", "\r", :right then open_entry
|
|
360
401
|
when "h", :left, TuiTui::KeyCode::BACKSPACE then up_dir # h / ← / Backspace
|
|
@@ -631,7 +672,7 @@ module FileBrowserSample
|
|
|
631
672
|
|
|
632
673
|
def draw_status(canvas, rect)
|
|
633
674
|
left = @finder ? " > #{@finder}" : " #{@dir}"
|
|
634
|
-
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"
|
|
635
676
|
right = "#{@list.cursor + 1}/#{@entries.size} #{hints} "
|
|
636
677
|
TuiTui::StatusBar.draw(canvas, rect, left: left, right: right, style: @styles[:bar])
|
|
637
678
|
end
|
data/lib/tui_tui/box_prober.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
data/lib/tui_tui/list.rb
CHANGED
|
@@ -21,7 +21,7 @@ module TuiTui
|
|
|
21
21
|
row = body.row + offset
|
|
22
22
|
selected = index == @scroll.cursor
|
|
23
23
|
canvas.fill(Rect.new(row: row, col: body.col, rows: 1, cols: body.cols), highlight) if highlight && selected
|
|
24
|
-
canvas.line(row, body.col,
|
|
24
|
+
canvas.line(row, body.col, Line.coerce(yield(index, selected)).truncate(body.cols))
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
draw_scrollbar(canvas, gutter, scrollbar) if gutter
|
|
@@ -53,7 +53,5 @@ module TuiTui
|
|
|
53
53
|
thumb_style: theme.scroll_thumb
|
|
54
54
|
)
|
|
55
55
|
end
|
|
56
|
-
|
|
57
|
-
def as_line(content) = content.is_a?(Line) ? content : Line.new(Array(content))
|
|
58
56
|
end
|
|
59
57
|
end
|
data/lib/tui_tui/pager.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "display_text"
|
|
4
4
|
require_relative "style"
|
|
5
|
+
require_relative "span"
|
|
6
|
+
require_relative "line"
|
|
5
7
|
require_relative "rect"
|
|
6
8
|
require_relative "modal"
|
|
7
9
|
require_relative "key_intent"
|
|
@@ -12,13 +14,16 @@ module TuiTui
|
|
|
12
14
|
MARGIN = 2
|
|
13
15
|
WHEEL = 3
|
|
14
16
|
|
|
17
|
+
# Each line may be a plain String, a `Line`, or an array of `Span`s, so a
|
|
18
|
+
# log/diff/error view can color whole lines or runs within them. Spans
|
|
19
|
+
# without a style fall back to theme.muted.
|
|
15
20
|
def initialize(title, lines, start: 0, close_keys: [], theme: Theme::DEFAULT)
|
|
16
21
|
@title = title
|
|
17
|
-
@
|
|
22
|
+
@theme = theme
|
|
23
|
+
@lines = lines.map { |line| normalize_line(line) }
|
|
18
24
|
@top = start
|
|
19
25
|
@page = 1
|
|
20
26
|
@close_keys = close_keys
|
|
21
|
-
@theme = theme
|
|
22
27
|
end
|
|
23
28
|
|
|
24
29
|
def handle(key)
|
|
@@ -60,7 +65,7 @@ module TuiTui
|
|
|
60
65
|
line = @lines[@top + offset]
|
|
61
66
|
next if line.nil?
|
|
62
67
|
|
|
63
|
-
canvas.
|
|
68
|
+
canvas.line(rect.row + 3 + offset, rect.col + 2, line.truncate(inner))
|
|
64
69
|
end
|
|
65
70
|
|
|
66
71
|
canvas
|
|
@@ -68,6 +73,13 @@ module TuiTui
|
|
|
68
73
|
|
|
69
74
|
private
|
|
70
75
|
|
|
76
|
+
# -> Line. Accepts a String, a Line, or a Span array; spans left unstyled
|
|
77
|
+
# default to theme.muted so a bare line still reads as muted body text.
|
|
78
|
+
def normalize_line(line)
|
|
79
|
+
spans = Line.coerce(line).spans.map { |span| Span[span.text, span.style || theme.muted] }
|
|
80
|
+
Line.new(spans)
|
|
81
|
+
end
|
|
82
|
+
|
|
71
83
|
def paginate(key)
|
|
72
84
|
case key
|
|
73
85
|
when " ", :pgdn
|
data/lib/tui_tui/prompt.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "display_text"
|
|
4
|
+
require_relative "text_sanitizer"
|
|
4
5
|
require_relative "style"
|
|
5
6
|
require_relative "modal"
|
|
6
7
|
require_relative "key_code"
|
|
@@ -38,7 +39,7 @@ module TuiTui
|
|
|
38
39
|
when :end
|
|
39
40
|
edit { @pos = @graphemes.length }
|
|
40
41
|
when String
|
|
41
|
-
edit { insert(key) if printable?(key) }
|
|
42
|
+
edit { insert(key) if TextSanitizer.printable?(key) }
|
|
42
43
|
end
|
|
43
44
|
end
|
|
44
45
|
|
|
@@ -103,9 +104,5 @@ module TuiTui
|
|
|
103
104
|
def delete_forward
|
|
104
105
|
@graphemes.delete_at(@pos) if @pos < @graphemes.length
|
|
105
106
|
end
|
|
106
|
-
|
|
107
|
-
def printable?(string)
|
|
108
|
-
string.bytes.all? { |byte| byte >= 0x20 && byte != 0x7F }
|
|
109
|
-
end
|
|
110
107
|
end
|
|
111
108
|
end
|
data/lib/tui_tui/runtime.rb
CHANGED
|
@@ -26,6 +26,7 @@ module TuiTui
|
|
|
26
26
|
|
|
27
27
|
@app = result
|
|
28
28
|
flush_clipboard(screen)
|
|
29
|
+
flush_mouse(screen)
|
|
29
30
|
screen.render(view(screen))
|
|
30
31
|
end
|
|
31
32
|
end
|
|
@@ -47,6 +48,14 @@ module TuiTui
|
|
|
47
48
|
screen.copy(text) if text
|
|
48
49
|
end
|
|
49
50
|
|
|
51
|
+
# Apps may release/recapture the mouse per frame (e.g. to allow a native
|
|
52
|
+
# terminal selection while a read-only pane is open).
|
|
53
|
+
def flush_mouse(screen)
|
|
54
|
+
return unless @app.respond_to?(:wants_mouse?)
|
|
55
|
+
|
|
56
|
+
screen.mouse = @app.wants_mouse?
|
|
57
|
+
end
|
|
58
|
+
|
|
50
59
|
def wants_redraw?(event)
|
|
51
60
|
@app.respond_to?(:redraw?) && @app.redraw?(event)
|
|
52
61
|
end
|
data/lib/tui_tui/screen.rb
CHANGED
|
@@ -52,6 +52,14 @@ module TuiTui
|
|
|
52
52
|
|
|
53
53
|
attr_reader :events, :chrome
|
|
54
54
|
|
|
55
|
+
# Toggle mouse reporting mid-session (so an app can release the mouse for a
|
|
56
|
+
# native terminal selection while a read-only pane is open).
|
|
57
|
+
def mouse=(enabled)
|
|
58
|
+
@session.mouse = enabled
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def mouse = @session.mouse
|
|
62
|
+
|
|
55
63
|
def start
|
|
56
64
|
@session.start
|
|
57
65
|
# Probe box-drawing support once, after raw mode + alt screen, before the
|
data/lib/tui_tui/select.rb
CHANGED
|
@@ -91,11 +91,10 @@ module TuiTui
|
|
|
91
91
|
|
|
92
92
|
# The item under a click, picked, or nil if the click missed the list.
|
|
93
93
|
def click(event)
|
|
94
|
-
|
|
95
|
-
return nil unless rect && event.col.between?(rect.col, rect.col + rect.cols - 1)
|
|
94
|
+
return nil unless @items_rect
|
|
96
95
|
|
|
97
|
-
index = @list.
|
|
98
|
-
return nil
|
|
96
|
+
index = List.new(@list).index_at(@items_rect, event)
|
|
97
|
+
return nil if index.nil?
|
|
99
98
|
|
|
100
99
|
@list.go_to(index)
|
|
101
100
|
@list.cursor
|
|
@@ -16,7 +16,8 @@ module TuiTui
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def start
|
|
19
|
-
|
|
19
|
+
# `intr: true` keeps the interrupt/quit/suspend characters live in raw mode, so Ctrl-C raises SIGINT.
|
|
20
|
+
@console.raw!(intr: true)
|
|
20
21
|
@output.write(Ansi::ALT_ON + Ansi::HIDE + Ansi::CLEAR + (@mouse ? Ansi::MOUSE_ON : ""))
|
|
21
22
|
@output.flush
|
|
22
23
|
@prev_winch = trap("WINCH") { @events.resized! }
|
|
@@ -24,6 +25,20 @@ module TuiTui
|
|
|
24
25
|
at_exit { close }
|
|
25
26
|
end
|
|
26
27
|
|
|
28
|
+
attr_reader :mouse
|
|
29
|
+
|
|
30
|
+
# Toggle mouse reporting mid-session. Releasing it (false) lets the user
|
|
31
|
+
# make a native terminal selection (drag to select / copy) over the alternate
|
|
32
|
+
# screen; re-enabling restores in-app mouse events. No-op if unchanged.
|
|
33
|
+
def mouse=(enabled)
|
|
34
|
+
enabled = !!enabled
|
|
35
|
+
return if @closed || enabled == @mouse
|
|
36
|
+
|
|
37
|
+
@output.write(enabled ? Ansi::MOUSE_ON : Ansi::MOUSE_OFF)
|
|
38
|
+
@output.flush
|
|
39
|
+
@mouse = enabled
|
|
40
|
+
end
|
|
41
|
+
|
|
27
42
|
def close
|
|
28
43
|
# Close is called from ensure, at_exit, and signal traps.
|
|
29
44
|
return if @closed
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module TuiTui
|
|
4
|
-
#
|
|
5
|
-
# safely
|
|
4
|
+
# Character-level text hygiene: keeps malformed or control bytes out of the
|
|
5
|
+
# render/input pipeline so they are displayed safely (or rejected as input)
|
|
6
|
+
# instead of raising encoding errors or emitting raw control codes.
|
|
6
7
|
module TextSanitizer
|
|
7
8
|
module_function
|
|
8
9
|
|
|
9
10
|
def sanitize(string)
|
|
10
11
|
string.valid_encoding? ? string : string.scrub("?")
|
|
11
12
|
end
|
|
13
|
+
|
|
14
|
+
# Whether `string` is safe to insert as literal text: every byte is a
|
|
15
|
+
# printable character (no C0 controls and no DEL). Multibyte UTF-8 passes,
|
|
16
|
+
# since its bytes are all >= 0x80.
|
|
17
|
+
def printable?(string)
|
|
18
|
+
string.bytes.all? { |byte| byte >= 0x20 && byte != 0x7F }
|
|
19
|
+
end
|
|
12
20
|
end
|
|
13
21
|
end
|
data/lib/tui_tui/text_view.rb
CHANGED
|
@@ -21,7 +21,7 @@ module TuiTui
|
|
|
21
21
|
content = lines ? lines[index] : yield(index)
|
|
22
22
|
next if content.nil?
|
|
23
23
|
|
|
24
|
-
canvas.line(body.row + offset, body.col,
|
|
24
|
+
canvas.line(body.row + offset, body.col, Line.coerce(content, style).truncate(body.cols))
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
draw_scrollbar(canvas, gutter, top, total || lines&.length, body.rows, scrollbar) if gutter
|
|
@@ -41,16 +41,5 @@ module TuiTui
|
|
|
41
41
|
thumb_style: theme.scroll_thumb
|
|
42
42
|
)
|
|
43
43
|
end
|
|
44
|
-
|
|
45
|
-
def as_line(content, style)
|
|
46
|
-
case content
|
|
47
|
-
when Line
|
|
48
|
-
content
|
|
49
|
-
when Array
|
|
50
|
-
Line.new(content)
|
|
51
|
-
else
|
|
52
|
-
Line[Span[content.to_s, style]]
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
44
|
end
|
|
56
45
|
end
|
data/lib/tui_tui/toast.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "display_text"
|
|
4
4
|
require_relative "style"
|
|
5
|
+
require_relative "clock"
|
|
5
6
|
|
|
6
7
|
module TuiTui
|
|
7
8
|
# A transient notification overlay.
|
|
@@ -22,7 +23,7 @@ module TuiTui
|
|
|
22
23
|
bottom_right: [:bottom, :right],
|
|
23
24
|
center: [:middle, :center]
|
|
24
25
|
}.freeze
|
|
25
|
-
MONOTONIC = -> {
|
|
26
|
+
MONOTONIC = -> { Clock.monotonic }
|
|
26
27
|
|
|
27
28
|
def initialize(message, seconds: DEFAULT_SECONDS, position: DEFAULT_POSITION, clock: MONOTONIC)
|
|
28
29
|
@message = DisplayText.new(message)
|
data/lib/tui_tui/version.rb
CHANGED
data/lib/tui_tui.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "tui_tui/version"
|
|
4
4
|
|
|
5
|
+
require_relative "tui_tui/clock"
|
|
5
6
|
require_relative "tui_tui/width"
|
|
6
7
|
require_relative "tui_tui/text_sanitizer"
|
|
7
8
|
require_relative "tui_tui/display_text"
|
|
@@ -38,6 +39,7 @@ require_relative "tui_tui/modal"
|
|
|
38
39
|
require_relative "tui_tui/modal_host"
|
|
39
40
|
require_relative "tui_tui/confirm"
|
|
40
41
|
require_relative "tui_tui/select"
|
|
42
|
+
require_relative "tui_tui/command_palette"
|
|
41
43
|
require_relative "tui_tui/help"
|
|
42
44
|
require_relative "tui_tui/prompt"
|
|
43
45
|
require_relative "tui_tui/pager"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tui_tui
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- takahashim
|
|
@@ -39,7 +39,9 @@ files:
|
|
|
39
39
|
- lib/tui_tui/canvas.rb
|
|
40
40
|
- lib/tui_tui/canvas_compositor.rb
|
|
41
41
|
- lib/tui_tui/cell.rb
|
|
42
|
+
- lib/tui_tui/clock.rb
|
|
42
43
|
- lib/tui_tui/color_depth.rb
|
|
44
|
+
- lib/tui_tui/command_palette.rb
|
|
43
45
|
- lib/tui_tui/confirm.rb
|
|
44
46
|
- lib/tui_tui/display_text.rb
|
|
45
47
|
- lib/tui_tui/event.rb
|
|
@@ -98,7 +100,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
98
100
|
- !ruby/object:Gem::Version
|
|
99
101
|
version: '0'
|
|
100
102
|
requirements: []
|
|
101
|
-
rubygems_version:
|
|
103
|
+
rubygems_version: 3.6.9
|
|
102
104
|
specification_version: 4
|
|
103
105
|
summary: A tiny, dependency-free TUI runtime for modern terminals.
|
|
104
106
|
test_files: []
|