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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -0
- data/README.md +51 -14
- data/examples/README.md +28 -0
- data/examples/breakout.rb +225 -0
- data/examples/counter.rb +11 -12
- data/examples/csv_viewer.rb +28 -50
- data/examples/downloads.rb +128 -0
- data/examples/file_browser.rb +106 -56
- data/examples/form.rb +80 -102
- data/examples/life.rb +1 -1
- data/examples/log_viewer.rb +118 -0
- data/examples/todo.rb +43 -64
- data/examples/widgets.rb +4 -4
- data/lib/tui_tui/box_chrome.rb +0 -9
- data/lib/tui_tui/box_chrome_resolver.rb +21 -0
- data/lib/tui_tui/box_prober.rb +3 -4
- data/lib/tui_tui/canvas.rb +6 -55
- data/lib/tui_tui/canvas_compositor.rb +12 -10
- data/lib/tui_tui/canvas_renderer.rb +59 -0
- data/lib/tui_tui/cell.rb +4 -3
- data/lib/tui_tui/clock.rb +11 -0
- data/lib/tui_tui/command.rb +21 -0
- data/lib/tui_tui/event_stream.rb +42 -7
- data/lib/tui_tui/key_code.rb +3 -0
- data/lib/tui_tui/key_reader.rb +1 -1
- data/lib/tui_tui/line.rb +11 -0
- data/lib/tui_tui/modal/command_palette.rb +193 -0
- data/lib/tui_tui/modal/confirm.rb +74 -0
- data/lib/tui_tui/modal/help.rb +44 -0
- data/lib/tui_tui/modal/pager.rb +106 -0
- data/lib/tui_tui/modal/prompt.rb +66 -0
- data/lib/tui_tui/modal/select.rb +103 -0
- data/lib/tui_tui/modal.rb +4 -0
- data/lib/tui_tui/modal_host.rb +2 -2
- data/lib/tui_tui/rect.rb +69 -0
- data/lib/tui_tui/runtime.rb +52 -20
- data/lib/tui_tui/screen.rb +15 -3
- data/lib/tui_tui/terminal_session.rb +16 -1
- data/lib/tui_tui/test_runtime.rb +71 -0
- data/lib/tui_tui/text_input.rb +168 -0
- data/lib/tui_tui/text_sanitizer.rb +3 -2
- data/lib/tui_tui/theme.rb +25 -27
- data/lib/tui_tui/version.rb +1 -1
- data/lib/tui_tui/widget/list.rb +59 -0
- data/lib/tui_tui/widget/progress.rb +33 -0
- data/lib/tui_tui/widget/scroll_list.rb +59 -0
- data/lib/tui_tui/widget/scrollbar.rb +43 -0
- data/lib/tui_tui/widget/spinner.rb +37 -0
- data/lib/tui_tui/widget/status_bar.rb +25 -0
- data/lib/tui_tui/widget/table.rb +81 -0
- data/lib/tui_tui/widget/tabs.rb +67 -0
- data/lib/tui_tui/widget/text_view.rb +47 -0
- data/lib/tui_tui/widget/toast.rb +83 -0
- data/lib/tui_tui.rb +24 -14
- metadata +28 -13
- data/lib/tui_tui/confirm.rb +0 -74
- data/lib/tui_tui/help.rb +0 -44
- data/lib/tui_tui/list.rb +0 -59
- data/lib/tui_tui/pager.rb +0 -94
- data/lib/tui_tui/prompt.rb +0 -111
- data/lib/tui_tui/scroll_list.rb +0 -57
- data/lib/tui_tui/scrollbar.rb +0 -41
- data/lib/tui_tui/select.rb +0 -104
- data/lib/tui_tui/status_bar.rb +0 -23
- data/lib/tui_tui/text_view.rb +0 -56
- data/lib/tui_tui/toast.rb +0 -82
- /data/lib/tui_tui/{event.rb → events.rb} +0 -0
data/lib/tui_tui/box_chrome.rb
CHANGED
|
@@ -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
|
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
|
data/lib/tui_tui/canvas.rb
CHANGED
|
@@ -108,59 +108,15 @@ module TuiTui
|
|
|
108
108
|
self
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
-
def
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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(
|
|
19
|
+
(1..canvas.rows).each { |row| out << row_paint(renderer, row) }
|
|
18
20
|
else
|
|
19
|
-
(1..canvas.rows).each { |row| out << row_diff(
|
|
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,
|
|
28
|
-
previous.nil? || !
|
|
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(
|
|
33
|
-
Ansi.move(row, 1) +
|
|
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(
|
|
39
|
-
span =
|
|
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) +
|
|
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)
|
|
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
|
data/lib/tui_tui/event_stream.rb
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/tui_tui/key_code.rb
CHANGED
data/lib/tui_tui/key_reader.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
|
|
@@ -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
|