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
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../modal"
|
|
4
|
+
|
|
5
|
+
module TuiTui
|
|
6
|
+
class Modal
|
|
7
|
+
# Key-binding cheat sheet modal.
|
|
8
|
+
class Help < Modal
|
|
9
|
+
COLGAP = 2
|
|
10
|
+
|
|
11
|
+
def initialize(title, entries, theme: Theme::DEFAULT)
|
|
12
|
+
@title = DisplayText.new(title)
|
|
13
|
+
@entries = entries.map { |keys, desc| [DisplayText.new(keys), DisplayText.new(desc)] }
|
|
14
|
+
@theme = theme
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def handle(_key) = :close
|
|
18
|
+
|
|
19
|
+
# Any click dismisses the sheet, like any key does.
|
|
20
|
+
def handle_mouse(event) = event.action == :press ? :close : nil
|
|
21
|
+
|
|
22
|
+
def draw(canvas, size)
|
|
23
|
+
key_w = @entries.map { |keys, _| keys.width }.max || 0
|
|
24
|
+
body_w = @entries.map { |keys, desc| keys.width + COLGAP + desc.width }.max || 0
|
|
25
|
+
inner = [@title.width, body_w].max
|
|
26
|
+
|
|
27
|
+
rect, col = panel(canvas, inner: inner, body_rows: @entries.size + 2)
|
|
28
|
+
|
|
29
|
+
canvas.text(rect.row + 1, col, @title.truncate(inner), theme.title)
|
|
30
|
+
draw_entries(canvas, rect.row + 3, col, key_w)
|
|
31
|
+
canvas
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def draw_entries(canvas, row, col, key_w)
|
|
37
|
+
@entries.each_with_index do |(keys, desc), index|
|
|
38
|
+
canvas.text(row + index, col, keys, theme.accent)
|
|
39
|
+
canvas.text(row + index, col + key_w + COLGAP, desc, theme.muted)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -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,19 +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
|
|
36
|
+
flush_mouse(screen)
|
|
29
37
|
screen.render(view(screen))
|
|
30
38
|
end
|
|
31
39
|
end
|
|
32
40
|
end
|
|
33
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
|
+
|
|
34
68
|
private
|
|
35
69
|
|
|
36
70
|
# Pass a RenderContext (size + resolved chrome + canvas factory). It is
|
|
@@ -39,22 +73,20 @@ module TuiTui
|
|
|
39
73
|
@app.view(RenderContext.new(size: screen.size, chrome: screen.chrome))
|
|
40
74
|
end
|
|
41
75
|
|
|
42
|
-
def
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
48
82
|
end
|
|
49
83
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
84
|
+
# Apps may release/recapture the mouse per frame (e.g. to allow a native
|
|
85
|
+
# terminal selection while a read-only pane is open).
|
|
86
|
+
def flush_mouse(screen)
|
|
87
|
+
return unless @app.respond_to?(:wants_mouse?)
|
|
53
88
|
|
|
54
|
-
|
|
55
|
-
# Ticks are opt-in: a TickEvent is inert unless the app explicitly wants it
|
|
56
|
-
# (so a plain app like counter.rb never redraws on the timer).
|
|
57
|
-
event.is_a?(TickEvent) && !(@app.respond_to?(:wants_tick?) && @app.wants_tick?)
|
|
89
|
+
screen.mouse = @app.wants_mouse?
|
|
58
90
|
end
|
|
59
91
|
end
|
|
60
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"
|
|
@@ -52,11 +52,19 @@ 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
|
|
58
66
|
# first render/next_event so the DSR reply never reaches the key reader.
|
|
59
|
-
@chrome =
|
|
67
|
+
@chrome = BoxChromeResolver.new.resolve(
|
|
60
68
|
input: @input,
|
|
61
69
|
output: @output,
|
|
62
70
|
term_cols: size.cols,
|
|
@@ -71,7 +79,7 @@ module TuiTui
|
|
|
71
79
|
# is appended only on a full repaint or when the cursor actually moved, so an
|
|
72
80
|
# idle identical re-render still writes nothing.
|
|
73
81
|
def render(canvas)
|
|
74
|
-
full =
|
|
82
|
+
full = full_repaint?(canvas)
|
|
75
83
|
out = @compositor.render(@previous, canvas)
|
|
76
84
|
out += cursor_directive(canvas) if full || canvas.cursor != @cursor
|
|
77
85
|
@output.write(out)
|
|
@@ -93,6 +101,10 @@ module TuiTui
|
|
|
93
101
|
|
|
94
102
|
private
|
|
95
103
|
|
|
104
|
+
def full_repaint?(canvas)
|
|
105
|
+
@previous.nil? || @previous.rows != canvas.rows || @previous.cols != canvas.cols
|
|
106
|
+
end
|
|
107
|
+
|
|
96
108
|
def cursor_directive(canvas)
|
|
97
109
|
pos = canvas.cursor
|
|
98
110
|
pos ? Ansi.move(pos[0], pos[1]) + Ansi::SHOW : Ansi::HIDE
|
|
@@ -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
|