tui_tui 0.3.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 +60 -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 +64 -55
- 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/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/command.rb +21 -0
- data/lib/tui_tui/event_stream.rb +42 -7
- data/lib/tui_tui/key_reader.rb +1 -1
- 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 +46 -23
- data/lib/tui_tui/screen.rb +7 -3
- 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 -10
- 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 +23 -15
- metadata +28 -15
- data/lib/tui_tui/command_palette.rb +0 -190
- data/lib/tui_tui/confirm.rb +0 -74
- data/lib/tui_tui/help.rb +0 -44
- data/lib/tui_tui/list.rb +0 -57
- data/lib/tui_tui/pager.rb +0 -106
- data/lib/tui_tui/prompt.rb +0 -108
- data/lib/tui_tui/scroll_list.rb +0 -57
- data/lib/tui_tui/scrollbar.rb +0 -41
- data/lib/tui_tui/select.rb +0 -103
- data/lib/tui_tui/status_bar.rb +0 -23
- data/lib/tui_tui/text_view.rb +0 -45
- data/lib/tui_tui/toast.rb +0 -83
- /data/lib/tui_tui/{event.rb → events.rb} +0 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../span"
|
|
4
|
+
require_relative "../line"
|
|
5
|
+
require_relative "../rect"
|
|
6
|
+
require_relative "../modal"
|
|
7
|
+
require_relative "../key_intent"
|
|
8
|
+
|
|
9
|
+
module TuiTui
|
|
10
|
+
class Modal
|
|
11
|
+
# Scrollable read-only text modal.
|
|
12
|
+
class Pager < Modal
|
|
13
|
+
MARGIN = 2
|
|
14
|
+
WHEEL = 3
|
|
15
|
+
|
|
16
|
+
# Each line may be a plain String, a `Line`, or an array of `Span`s, so a
|
|
17
|
+
# log/diff/error view can color whole lines or runs within them. Spans
|
|
18
|
+
# without a style fall back to theme.muted.
|
|
19
|
+
def initialize(title, lines, start: 0, close_keys: [], theme: Theme::DEFAULT)
|
|
20
|
+
@title = title
|
|
21
|
+
@theme = theme
|
|
22
|
+
@lines = lines.map { |line| normalize_line(line) }
|
|
23
|
+
@top = start
|
|
24
|
+
@page = 1
|
|
25
|
+
@close_keys = close_keys
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def handle(key)
|
|
29
|
+
return :close if @close_keys.include?(key)
|
|
30
|
+
|
|
31
|
+
case KeyIntent.for(key)
|
|
32
|
+
when :up
|
|
33
|
+
scroll(-1)
|
|
34
|
+
when :down
|
|
35
|
+
scroll(1)
|
|
36
|
+
when :top
|
|
37
|
+
scroll(-@lines.size)
|
|
38
|
+
when :bottom
|
|
39
|
+
scroll(@lines.size)
|
|
40
|
+
when :cancel
|
|
41
|
+
:close
|
|
42
|
+
else
|
|
43
|
+
paginate(key)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def handle_mouse(event)
|
|
48
|
+
scroll(event.button == :wheel_up ? -WHEEL : WHEEL) if event.action == :wheel
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def draw(canvas, size)
|
|
52
|
+
width = [size.cols - (MARGIN * 2), 20].max
|
|
53
|
+
height = [size.rows - (MARGIN * 2), 5].max
|
|
54
|
+
rect = Rect.centered(size, cols: width, rows: height)
|
|
55
|
+
canvas.frame(rect, style: theme.frame)
|
|
56
|
+
|
|
57
|
+
inner = width - 4
|
|
58
|
+
body = [height - 4, 1].max
|
|
59
|
+
@page = body
|
|
60
|
+
clamp(body)
|
|
61
|
+
|
|
62
|
+
canvas.text(rect.row + 1, rect.col + 2, DisplayText.new(title_line(body)).truncate(inner), theme.title)
|
|
63
|
+
body.times do |offset|
|
|
64
|
+
line = @lines[@top + offset]
|
|
65
|
+
next if line.nil?
|
|
66
|
+
|
|
67
|
+
canvas.line(rect.row + 3 + offset, rect.col + 2, line.truncate(inner))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
canvas
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# -> Line. Accepts a String, a Line, or a Span array; spans left unstyled
|
|
76
|
+
# default to theme.muted so a bare line still reads as muted body text.
|
|
77
|
+
def normalize_line(line)
|
|
78
|
+
spans = Line.coerce(line).spans.map { |span| Span[span.text, span.style || theme.muted] }
|
|
79
|
+
Line.new(spans)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def paginate(key)
|
|
83
|
+
case key
|
|
84
|
+
when " ", :pgdn
|
|
85
|
+
scroll(@page)
|
|
86
|
+
when "b", :pgup
|
|
87
|
+
scroll(-@page)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def scroll(delta)
|
|
92
|
+
@top += delta
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def clamp(body)
|
|
97
|
+
@top = @top.clamp(0, [@lines.size - body, 0].max)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def title_line(body)
|
|
101
|
+
last = [@top + body, @lines.size].min
|
|
102
|
+
"#{@title} (#{@top + 1}-#{last}/#{@lines.size})"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../text_input"
|
|
4
|
+
require_relative "../modal"
|
|
5
|
+
require_relative "../key_code"
|
|
6
|
+
|
|
7
|
+
module TuiTui
|
|
8
|
+
class Modal
|
|
9
|
+
# Single-line text input modal with terminal-column-aware cursor placement.
|
|
10
|
+
class Prompt < Modal
|
|
11
|
+
MIN_INNER = 24
|
|
12
|
+
|
|
13
|
+
def initialize(label, value: "", theme: Theme::DEFAULT)
|
|
14
|
+
@label = DisplayText.new(label)
|
|
15
|
+
@input = TextInput.new(value: value)
|
|
16
|
+
@theme = theme
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def value = @input.value
|
|
20
|
+
|
|
21
|
+
def handle(key)
|
|
22
|
+
case key
|
|
23
|
+
when "\r"
|
|
24
|
+
[:ok, value]
|
|
25
|
+
when :escape, KeyCode::CTRL_C
|
|
26
|
+
:cancel
|
|
27
|
+
else
|
|
28
|
+
@input.handle_editing(key)
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def handle_mouse(event)
|
|
34
|
+
return nil unless event.action == :press && @text_row == event.row
|
|
35
|
+
|
|
36
|
+
@input.cursor = @input.index_at(event.col - @text_col, from: @scroll)
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def draw(canvas, size)
|
|
41
|
+
# Grow the panel to fit the value, but never past the screen: beyond that
|
|
42
|
+
# the value scrolls horizontally inside its column budget (see #viewport).
|
|
43
|
+
max_inner = [size.cols - (PAD * 2) - 2, MIN_INNER].max
|
|
44
|
+
desired = @label.width + 1 + DisplayText.new(value).width
|
|
45
|
+
inner = desired.clamp(MIN_INNER, max_inner)
|
|
46
|
+
rect, col = panel(canvas, inner: inner, body_rows: 1)
|
|
47
|
+
|
|
48
|
+
canvas.text(rect.row + 1, col, @label, theme.title)
|
|
49
|
+
@text_row = rect.row + 1
|
|
50
|
+
@text_col = col + @label.width + 1
|
|
51
|
+
draw_value(canvas, [inner - @label.width - 1, 1].max)
|
|
52
|
+
canvas
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def draw_value(canvas, budget)
|
|
58
|
+
viewport = @input.viewport(budget)
|
|
59
|
+
@scroll = viewport.start
|
|
60
|
+
canvas.text(@text_row, @text_col, viewport.text, theme.text)
|
|
61
|
+
cursor_col = @text_col + viewport.cursor_col
|
|
62
|
+
canvas.text(@text_row, cursor_col, viewport.cursor_grapheme || " ", theme.cursor)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../widget/scroll_list"
|
|
4
|
+
require_relative "../widget/list"
|
|
5
|
+
require_relative "../line"
|
|
6
|
+
require_relative "../span"
|
|
7
|
+
require_relative "../rect"
|
|
8
|
+
require_relative "../modal"
|
|
9
|
+
require_relative "../key_intent"
|
|
10
|
+
|
|
11
|
+
module TuiTui
|
|
12
|
+
class Modal
|
|
13
|
+
# Scrollable list picker modal.
|
|
14
|
+
class Select < Modal
|
|
15
|
+
MAX_ROWS = 12
|
|
16
|
+
MIN_INNER = 16
|
|
17
|
+
WHEEL = 3
|
|
18
|
+
|
|
19
|
+
def initialize(title, items, default: 0, theme: Theme::DEFAULT)
|
|
20
|
+
@title = DisplayText.new(title)
|
|
21
|
+
@items = items.map { |item| DisplayText.new(item) }
|
|
22
|
+
@list = Widget::ScrollList.new(@items.size)
|
|
23
|
+
@list.go_to(default)
|
|
24
|
+
@theme = theme
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def cursor = @list.cursor
|
|
28
|
+
|
|
29
|
+
def handle(key)
|
|
30
|
+
case KeyIntent.for(key)
|
|
31
|
+
when :up
|
|
32
|
+
nudge(-1)
|
|
33
|
+
when :down
|
|
34
|
+
nudge(1)
|
|
35
|
+
when :top
|
|
36
|
+
nudge_to(0)
|
|
37
|
+
when :bottom
|
|
38
|
+
nudge_to(@list.last)
|
|
39
|
+
when :cancel
|
|
40
|
+
:cancel
|
|
41
|
+
else
|
|
42
|
+
@list.cursor if ["\r", " "].include?(key)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Wheel moves the highlighted item; a click on an item picks it. Returns the
|
|
47
|
+
# chosen index on a click, otherwise nil (stay open).
|
|
48
|
+
def handle_mouse(event)
|
|
49
|
+
case event.action
|
|
50
|
+
when :wheel
|
|
51
|
+
nudge(event.button == :wheel_up ? -WHEEL : WHEEL)
|
|
52
|
+
when :press
|
|
53
|
+
click(event)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def draw(canvas, size)
|
|
58
|
+
rows = visible_rows(size)
|
|
59
|
+
inner = [MIN_INNER, @title.width, *@items.map(&:width)].max
|
|
60
|
+
rect, text_col = panel(canvas, inner: inner, body_rows: rows + 2)
|
|
61
|
+
|
|
62
|
+
canvas.text(rect.row + 1, text_col, @title.truncate(inner), theme.title)
|
|
63
|
+
draw_items(canvas, rect.row + 3, text_col, inner, rows)
|
|
64
|
+
canvas
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def nudge(delta)
|
|
70
|
+
@list.move(delta)
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def nudge_to(index)
|
|
75
|
+
@list.go_to(index)
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def visible_rows(size)
|
|
80
|
+
room = [size.rows - 4, 1].max
|
|
81
|
+
[@items.size, MAX_ROWS, room].min
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def draw_items(canvas, row, col, inner, rows)
|
|
85
|
+
@items_rect = Rect.new(row: row, col: col, rows: rows, cols: inner)
|
|
86
|
+
Widget::List.new(@list).draw(canvas, @items_rect) do |index, focused|
|
|
87
|
+
Line[Span[@items[index].to_s, focused ? theme.selection : theme.text]]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The item under a click, picked, or nil if the click missed the list.
|
|
92
|
+
def click(event)
|
|
93
|
+
return nil unless @items_rect
|
|
94
|
+
|
|
95
|
+
index = Widget::List.new(@list).index_at(@items_rect, event)
|
|
96
|
+
return nil if index.nil?
|
|
97
|
+
|
|
98
|
+
@list.go_to(index)
|
|
99
|
+
@list.cursor
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
data/lib/tui_tui/modal.rb
CHANGED
data/lib/tui_tui/modal_host.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "events"
|
|
4
4
|
|
|
5
5
|
module TuiTui
|
|
6
6
|
# Host-side helper for the app that *owns* the current modal widget.
|
|
@@ -11,7 +11,7 @@ module TuiTui
|
|
|
11
11
|
# the caller's on_result callback when the modal resolves.
|
|
12
12
|
#
|
|
13
13
|
# host = TuiTui::ModalHost.new
|
|
14
|
-
# host.open(TuiTui::Confirm.new("Quit?")) { |r| :quit if r == :ok }
|
|
14
|
+
# host.open(TuiTui::Modal::Confirm.new("Quit?")) { |r| :quit if r == :ok }
|
|
15
15
|
# # in update(event):
|
|
16
16
|
# if host.open?
|
|
17
17
|
# outcome = host.handle(event) # nil while open; on_result value once resolved
|
data/lib/tui_tui/rect.rb
CHANGED
|
@@ -12,6 +12,12 @@ module TuiTui
|
|
|
12
12
|
)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# The Rect covering a whole screen: row/col 1 over anything with
|
|
16
|
+
# Size-compatible `rows` / `cols` (a Size or a RenderContext).
|
|
17
|
+
def self.of(size)
|
|
18
|
+
new(row: 1, col: 1, rows: size.rows, cols: size.cols)
|
|
19
|
+
end
|
|
20
|
+
|
|
15
21
|
def split_h(top_rows)
|
|
16
22
|
top = Rect.new(row: row, col: col, rows: top_rows, cols: cols)
|
|
17
23
|
bottom = Rect.new(row: row + top_rows, col: col, rows: rows - top_rows, cols: cols)
|
|
@@ -33,6 +39,35 @@ module TuiTui
|
|
|
33
39
|
[left, right.shift_right(gutter)]
|
|
34
40
|
end
|
|
35
41
|
|
|
42
|
+
# Split into stacked Rects, top to bottom, one per constraint:
|
|
43
|
+
# an Integer is a fixed number of rows, a Float a fraction of the whole
|
|
44
|
+
# (`(rows * f).round`), and :rest shares whatever the fixed and fractional
|
|
45
|
+
# segments leave over (several :rest split it evenly, leading ones taking
|
|
46
|
+
# the indivisible remainder). Sizes resolve front to back, each clamped to
|
|
47
|
+
# what is still left, so an undersized rect squashes trailing segments to
|
|
48
|
+
# 0 rather than raising — the same live-and-let-live rule as split_ratio.
|
|
49
|
+
# Without a :rest, leftover space stays unassigned (no implicit stretch).
|
|
50
|
+
# Every constraint yields a Rect (possibly 0-sized), so destructuring is
|
|
51
|
+
# stable: `header, body, status = rect.split_rows(2, :rest, 2)`.
|
|
52
|
+
def split_rows(*constraints)
|
|
53
|
+
top = row
|
|
54
|
+
resolve_segments(constraints, rows).map do |size|
|
|
55
|
+
segment = Rect.new(row: top, col: col, rows: size, cols: cols)
|
|
56
|
+
top += size
|
|
57
|
+
segment
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Same as split_rows, but left to right over the columns.
|
|
62
|
+
def split_cols(*constraints)
|
|
63
|
+
left = col
|
|
64
|
+
resolve_segments(constraints, cols).map do |size|
|
|
65
|
+
segment = Rect.new(row: row, col: left, rows: rows, cols: size)
|
|
66
|
+
left += size
|
|
67
|
+
segment
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
36
71
|
def shift_right(by)
|
|
37
72
|
Rect.new(row: row, col: col + by, rows: rows, cols: cols - by)
|
|
38
73
|
end
|
|
@@ -52,5 +87,39 @@ module TuiTui
|
|
|
52
87
|
|
|
53
88
|
[with(cols: cols - width), Rect.new(row: row, col: col + cols - width, rows: rows, cols: width)]
|
|
54
89
|
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Segment sizes for split_rows / split_cols: nominal sizes first (:rest
|
|
94
|
+
# from the leftover), then a front-to-back pass clamping each to what
|
|
95
|
+
# remains of `total`.
|
|
96
|
+
def resolve_segments(constraints, total)
|
|
97
|
+
rest_count = constraints.count(:rest)
|
|
98
|
+
claimed = constraints.sum { |constraint| nominal_size(constraint, total) }
|
|
99
|
+
base, spare = rest_count.zero? ? [0, 0] : [total - claimed, 0].max.divmod(rest_count)
|
|
100
|
+
|
|
101
|
+
rest_index = -1
|
|
102
|
+
remaining = total
|
|
103
|
+
constraints.map do |constraint|
|
|
104
|
+
size = if constraint == :rest
|
|
105
|
+
rest_index += 1
|
|
106
|
+
base + (rest_index < spare ? 1 : 0)
|
|
107
|
+
else
|
|
108
|
+
nominal_size(constraint, total)
|
|
109
|
+
end
|
|
110
|
+
size = size.clamp(0, remaining)
|
|
111
|
+
remaining -= size
|
|
112
|
+
size
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def nominal_size(constraint, total)
|
|
117
|
+
case constraint
|
|
118
|
+
when Integer then constraint
|
|
119
|
+
when Float then (total * constraint).round
|
|
120
|
+
when :rest then 0
|
|
121
|
+
else raise ArgumentError, "unknown layout constraint: #{constraint.inspect}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
55
124
|
end
|
|
56
125
|
end
|
data/lib/tui_tui/runtime.rb
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "command"
|
|
3
4
|
require_relative "screen"
|
|
4
5
|
require_relative "render_context"
|
|
5
6
|
|
|
6
7
|
module TuiTui
|
|
7
|
-
# Small Elm-style loop: render, read one event, fold it through the app,
|
|
8
|
+
# Small Elm-style loop: render, read one event, fold it through the app,
|
|
9
|
+
# repeat. `update` returns the next app, `[app, *commands]` to also request
|
|
10
|
+
# one-shot effects (see Command), or `:quit`/nil to stop; `[:quit, *commands]`
|
|
11
|
+
# performs the commands and then stops.
|
|
8
12
|
class Runtime
|
|
9
13
|
def initialize(app)
|
|
10
14
|
@app = app
|
|
11
15
|
end
|
|
12
16
|
|
|
17
|
+
# `tick:` is the TickEvent period in seconds: ticks arrive on that fixed
|
|
18
|
+
# cadence (between input events too), not after idle gaps.
|
|
13
19
|
def run(input: $stdin, output: $stdout, depth: ColorDepth.detect, tick: 0.1, mouse: Screen.mouse_default, box: ENV["TUITUI_BOX"])
|
|
14
20
|
Screen.run(input: input, output: output, depth: depth, mouse: mouse, box: box) do |screen|
|
|
15
21
|
raise "tui_tui: not a terminal" if screen.nil?
|
|
@@ -18,20 +24,47 @@ module TuiTui
|
|
|
18
24
|
loop do
|
|
19
25
|
event = screen.events.next_event(tick: tick)
|
|
20
26
|
break if event.is_a?(EofEvent)
|
|
21
|
-
next if inert_tick?(event)
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
break if result == :quit || result.nil?
|
|
28
|
+
folded = Runtime.step(@app, event)
|
|
29
|
+
next if folded.nil?
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
app, commands = folded
|
|
32
|
+
commands.each { |command| perform(command, screen) }
|
|
33
|
+
break if app.nil?
|
|
34
|
+
|
|
35
|
+
@app = app
|
|
29
36
|
flush_mouse(screen)
|
|
30
37
|
screen.render(view(screen))
|
|
31
38
|
end
|
|
32
39
|
end
|
|
33
40
|
end
|
|
34
41
|
|
|
42
|
+
# Normalizes the update result to [app, commands]; a quit (bare or as the
|
|
43
|
+
# array head) becomes a nil app. An app is never itself an Array. Shared
|
|
44
|
+
# with TestRuntime so both read `update`'s contract the same way.
|
|
45
|
+
def self.unwrap(result)
|
|
46
|
+
app, commands = result.is_a?(Array) ? [result.first, result.drop(1)] : [result, []]
|
|
47
|
+
[app == :quit ? nil : app, commands]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The canonical pure part of one loop iteration: drop an inert tick
|
|
51
|
+
# (returning nil), otherwise fold the event through `update`, validate the
|
|
52
|
+
# requested commands, and return [next_app_or_nil, commands]. Runtime and
|
|
53
|
+
# TestRuntime both fold through this, so their semantics cannot drift.
|
|
54
|
+
def self.step(app, event)
|
|
55
|
+
return nil if inert_tick?(app, event)
|
|
56
|
+
|
|
57
|
+
app, commands = unwrap(app.update(event))
|
|
58
|
+
commands.each { |command| Command.validate!(command) }
|
|
59
|
+
[app, commands]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Ticks are opt-in: a TickEvent is inert unless the app explicitly wants it
|
|
63
|
+
# (so a plain app like counter.rb never redraws on the timer).
|
|
64
|
+
def self.inert_tick?(app, event)
|
|
65
|
+
event.is_a?(TickEvent) && !(app.respond_to?(:wants_tick?) && app.wants_tick?)
|
|
66
|
+
end
|
|
67
|
+
|
|
35
68
|
private
|
|
36
69
|
|
|
37
70
|
# Pass a RenderContext (size + resolved chrome + canvas factory). It is
|
|
@@ -40,12 +73,12 @@ module TuiTui
|
|
|
40
73
|
@app.view(RenderContext.new(size: screen.size, chrome: screen.chrome))
|
|
41
74
|
end
|
|
42
75
|
|
|
43
|
-
def
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
76
|
+
def perform(command, screen)
|
|
77
|
+
case command
|
|
78
|
+
when Command::Copy then screen.copy(command.text)
|
|
79
|
+
when Command::Invalidate then screen.invalidate
|
|
80
|
+
else raise ArgumentError, "tui_tui: unknown command #{command.inspect}"
|
|
81
|
+
end
|
|
49
82
|
end
|
|
50
83
|
|
|
51
84
|
# Apps may release/recapture the mouse per frame (e.g. to allow a native
|
|
@@ -55,15 +88,5 @@ module TuiTui
|
|
|
55
88
|
|
|
56
89
|
screen.mouse = @app.wants_mouse?
|
|
57
90
|
end
|
|
58
|
-
|
|
59
|
-
def wants_redraw?(event)
|
|
60
|
-
@app.respond_to?(:redraw?) && @app.redraw?(event)
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def inert_tick?(event)
|
|
64
|
-
# Ticks are opt-in: a TickEvent is inert unless the app explicitly wants it
|
|
65
|
-
# (so a plain app like counter.rb never redraws on the timer).
|
|
66
|
-
event.is_a?(TickEvent) && !(@app.respond_to?(:wants_tick?) && @app.wants_tick?)
|
|
67
|
-
end
|
|
68
91
|
end
|
|
69
92
|
end
|
data/lib/tui_tui/screen.rb
CHANGED
|
@@ -6,7 +6,7 @@ require_relative "ansi"
|
|
|
6
6
|
require_relative "size"
|
|
7
7
|
require_relative "color_depth"
|
|
8
8
|
require_relative "box_chrome"
|
|
9
|
-
require_relative "
|
|
9
|
+
require_relative "box_chrome_resolver"
|
|
10
10
|
require_relative "canvas_compositor"
|
|
11
11
|
require_relative "terminal_size"
|
|
12
12
|
require_relative "event_stream"
|
|
@@ -64,7 +64,7 @@ module TuiTui
|
|
|
64
64
|
@session.start
|
|
65
65
|
# Probe box-drawing support once, after raw mode + alt screen, before the
|
|
66
66
|
# first render/next_event so the DSR reply never reaches the key reader.
|
|
67
|
-
@chrome =
|
|
67
|
+
@chrome = BoxChromeResolver.new.resolve(
|
|
68
68
|
input: @input,
|
|
69
69
|
output: @output,
|
|
70
70
|
term_cols: size.cols,
|
|
@@ -79,7 +79,7 @@ module TuiTui
|
|
|
79
79
|
# is appended only on a full repaint or when the cursor actually moved, so an
|
|
80
80
|
# idle identical re-render still writes nothing.
|
|
81
81
|
def render(canvas)
|
|
82
|
-
full =
|
|
82
|
+
full = full_repaint?(canvas)
|
|
83
83
|
out = @compositor.render(@previous, canvas)
|
|
84
84
|
out += cursor_directive(canvas) if full || canvas.cursor != @cursor
|
|
85
85
|
@output.write(out)
|
|
@@ -101,6 +101,10 @@ module TuiTui
|
|
|
101
101
|
|
|
102
102
|
private
|
|
103
103
|
|
|
104
|
+
def full_repaint?(canvas)
|
|
105
|
+
@previous.nil? || @previous.rows != canvas.rows || @previous.cols != canvas.cols
|
|
106
|
+
end
|
|
107
|
+
|
|
104
108
|
def cursor_directive(canvas)
|
|
105
109
|
pos = canvas.cursor
|
|
106
110
|
pos ? Ansi.move(pos[0], pos[1]) + Ansi::SHOW : Ansi::HIDE
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "runtime"
|
|
4
|
+
require_relative "render_context"
|
|
5
|
+
require_relative "events"
|
|
6
|
+
require_relative "size"
|
|
7
|
+
require_relative "box_chrome"
|
|
8
|
+
|
|
9
|
+
module TuiTui
|
|
10
|
+
# The Runtime with the Screen removed, for testing apps: inject events, then
|
|
11
|
+
# assert on the folded model (`app`), the accumulated `commands`, `quit?`,
|
|
12
|
+
# and the rendered view (`screen` / `canvas`). Events fold exactly as in
|
|
13
|
+
# Runtime (via Runtime.step — the shared pure part of the loop, including
|
|
14
|
+
# the inert-tick rule and unknown-command validation). Not loaded by
|
|
15
|
+
# `require "tui_tui"` — opt in with `require "tui_tui/test_runtime"`.
|
|
16
|
+
class TestRuntime
|
|
17
|
+
attr_reader :app, :commands
|
|
18
|
+
|
|
19
|
+
def initialize(app, rows: 24, cols: 80)
|
|
20
|
+
@app = app
|
|
21
|
+
@size = Size.new(rows: rows, cols: cols)
|
|
22
|
+
@commands = []
|
|
23
|
+
@quit = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def quit? = @quit
|
|
27
|
+
|
|
28
|
+
def key(k) = fold(KeyEvent.new(key: k))
|
|
29
|
+
|
|
30
|
+
# One KeyEvent per grapheme cluster, as typing arrives from a terminal.
|
|
31
|
+
def type(str)
|
|
32
|
+
str.each_grapheme_cluster { |grapheme| key(grapheme) }
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Same rule as Runtime: a tick reaches `update` only when the app
|
|
37
|
+
# implements `wants_tick?` and it returns true; otherwise Runtime.step
|
|
38
|
+
# treats the tick as inert and nothing is folded.
|
|
39
|
+
def tick = fold(TickEvent.new)
|
|
40
|
+
|
|
41
|
+
def resize(rows:, cols:)
|
|
42
|
+
@size = Size.new(rows: rows, cols: cols)
|
|
43
|
+
fold(ResizeEvent.new(size: @size))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def mouse(action:, col:, row:, button: nil)
|
|
47
|
+
fold(MouseEvent.new(action: action, button: button, col: col, row: row))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The current view as Canvas#to_text (a style-free string).
|
|
51
|
+
def screen = canvas.to_text
|
|
52
|
+
|
|
53
|
+
# The current view's Canvas, for cell-level style assertions. Built like
|
|
54
|
+
# Runtime's: a RenderContext, so legacy `view(size)` apps work too.
|
|
55
|
+
def canvas = @app.view(RenderContext.new(size: @size, chrome: BoxChrome::ASCII))
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def fold(event)
|
|
60
|
+
raise "tui_tui: app already quit" if @quit
|
|
61
|
+
|
|
62
|
+
folded = Runtime.step(@app, event)
|
|
63
|
+
return self if folded.nil? # inert tick
|
|
64
|
+
|
|
65
|
+
app, commands = folded
|
|
66
|
+
@commands.concat(commands)
|
|
67
|
+
app.nil? ? @quit = true : @app = app
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|