tuile 0.12.0 → 0.14.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 +116 -27
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +2342 -158
- data/README.md +151 -505
- data/TERMINOLOGY.md +15 -5
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +28 -20
- data/book/05-focus.md +137 -19
- data/book/06-theming.md +103 -2
- data/book/07-components.md +567 -27
- data/book/08-testing.md +34 -4
- data/book/09-styled-text.md +3 -3
- data/book/README.md +7 -5
- data/examples/file_commander.rb +23 -17
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +527 -108
- data/ideas/arrow-key-navigation.md +17 -1
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +28 -27
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +12 -3
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/has_content.rb +22 -9
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/layout.rb +0 -10
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +102 -11
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +24 -39
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +61 -123
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +17 -7
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +231 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area.rb +5 -4
- data/lib/tuile/component/text_field.rb +23 -6
- data/lib/tuile/component/text_view.rb +8 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +186 -31
- data/lib/tuile/event_queue.rb +45 -1
- data/lib/tuile/fake_screen.rb +40 -2
- data/lib/tuile/keys.rb +72 -0
- data/lib/tuile/screen.rb +212 -113
- data/lib/tuile/screen_pane.rb +125 -41
- data/lib/tuile/styled_string.rb +80 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2527 -358
- metadata +13 -3
- data/mise.toml +0 -2
data/book/08-testing.md
CHANGED
|
@@ -28,10 +28,13 @@ after { Screen.close }
|
|
|
28
28
|
`Screen.fake` installs a {Tuile::FakeScreen} as the process singleton — a
|
|
29
29
|
`Screen` subclass with the terminal amputated. It has a fixed 160×50
|
|
30
30
|
viewport (so geometry is deterministic, independent of whoever's terminal
|
|
31
|
-
runs the suite), it writes nothing to any TTY, its
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
runs the suite), it writes nothing to any TTY, and its event queue is the
|
|
32
|
+
synchronous {Tuile::FakeEventQueue} (more on that below). You can mutate the
|
|
33
|
+
UI directly from your example — and note there is no lock *bypass* doing
|
|
34
|
+
that for you: the fake runs no loop, so `running?` is false and chapter 4's
|
|
35
|
+
rule falls back to "the thread that created the screen," which is yours. A
|
|
36
|
+
spec that mutates the UI from a **spawned** thread therefore raises, exactly
|
|
37
|
+
as an app would. It also pins the color scheme to `:dark`, skipping
|
|
35
38
|
the OSC 11 probe from chapter 6 — a probe would otherwise write an escape
|
|
36
39
|
query to the test runner's terminal and swallow its input.
|
|
37
40
|
|
|
@@ -44,6 +47,17 @@ from nothing. Skip the `after` and you get the classic singleton test
|
|
|
44
47
|
smell: passes in isolation, fails in suite, order-dependent. The pair is
|
|
45
48
|
not boilerplate you can trim.
|
|
46
49
|
|
|
50
|
+
One more line of setup earns its place if your app has a theme of its own. A
|
|
51
|
+
fresh `Screen.fake` starts from the built-in {Tuile::ThemeDef}, so a
|
|
52
|
+
component reading `theme[:my_token]` would `KeyError` in every example.
|
|
53
|
+
Rather than assigning `Screen.instance.theme_def` in every `before` block,
|
|
54
|
+
point the construction-time default at your definition once, in
|
|
55
|
+
`spec_helper`:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
Tuile::ThemeDef.default = APP_THEME # every Screen.fake now carries it
|
|
59
|
+
```
|
|
60
|
+
|
|
47
61
|
## Asserting what got painted
|
|
48
62
|
|
|
49
63
|
Here's where the back buffer earns its keep. Recall from chapter 2 that
|
|
@@ -94,9 +108,25 @@ key, so you assert on that too:
|
|
|
94
108
|
|
|
95
109
|
```ruby
|
|
96
110
|
list.handle_key(Keys::DOWN_ARROW) # exercises the cursor directly
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**A mouse test needs the component mounted, where a key test doesn't.** A
|
|
114
|
+
click doesn't only *do* something, it also *focuses* — and
|
|
115
|
+
{Tuile::Screen#focused=} refuses a component that isn't on the pane, so
|
|
116
|
+
`handle_mouse` on a component you never attached raises "is not attached to
|
|
117
|
+
this screen". Give it a tree first:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
screen.content = list # a click focuses; focus needs a tree
|
|
121
|
+
list.rect = Rect.new(0, 0, 10, 5)
|
|
97
122
|
list.handle_mouse(MouseEvent.new(:left, 5, 2))
|
|
98
123
|
```
|
|
99
124
|
|
|
125
|
+
That applies to containers too, and to more of them than you might expect:
|
|
126
|
+
a click descends to every child whose rect contains the point, so testing a
|
|
127
|
+
window's footer by clicking it exercises the window, the footer's slot and
|
|
128
|
+
the footer, all of which want to be attached.
|
|
129
|
+
|
|
100
130
|
**High: go through the pane.** {Tuile::ScreenPane#handle_key} runs the
|
|
101
131
|
dispatch rung from chapter 5 that routing is actually about: delivery to
|
|
102
132
|
{Tuile::Screen#focused}, then the bubble up its ancestor chain to the scope
|
data/book/09-styled-text.md
CHANGED
|
@@ -29,7 +29,7 @@ tangle has to be re-untangled on every edit.
|
|
|
29
29
|
{Tuile::StyledString} untangles it once, structurally. A styled string is
|
|
30
30
|
a sequence of **spans**, each a maximal run of characters that share one
|
|
31
31
|
complete {Tuile::StyledString::Style} — foreground, background, bold,
|
|
32
|
-
italic, underline, strikethrough. The spans are non-overlapping and tile
|
|
32
|
+
italic, underline, strikethrough, inverse. The spans are non-overlapping and tile
|
|
33
33
|
the whole string: every character belongs to exactly one span, and that
|
|
34
34
|
span's `style` *is* the character's style. There are no overlay layers to
|
|
35
35
|
merge, no running state to reconstruct. "What's the style at column 5?"
|
|
@@ -100,8 +100,8 @@ by default.**
|
|
|
100
100
|
|
|
101
101
|
Strict means it recognizes exactly the SGR codes that map to a
|
|
102
102
|
{Tuile::StyledString::Style}'s attributes — the foreground and background
|
|
103
|
-
colors, bold, italic, underline, strikethrough — and *raises* on
|
|
104
|
-
else. An unmodeled attribute like blink or
|
|
103
|
+
colors, bold, italic, underline, strikethrough, inverse — and *raises* on
|
|
104
|
+
anything else. An unmodeled attribute like blink or conceal, an unknown SGR
|
|
105
105
|
code, a non-SGR escape like a cursor move or an OSC sequence: all of them
|
|
106
106
|
are a {Tuile::StyledString::ParseError}, not a shrug. The reason is a
|
|
107
107
|
contract worth protecting: `parse(to_ansi(x)) == x`. If parsing silently
|
data/book/README.md
CHANGED
|
@@ -66,17 +66,19 @@ one, not to fill an outline.
|
|
|
66
66
|
`focusable?`, and the three-rung order in which a keystroke is offered
|
|
67
67
|
to the tree — Tab, global shortcuts, then `handle_key` delivered to
|
|
68
68
|
focus and bubbling up its ancestors. Why scope-wide keys (pane jumps, a
|
|
69
|
-
form's default button) belong on an ancestor,
|
|
70
|
-
|
|
69
|
+
form's default button) belong on an ancestor, why a paste rides its own
|
|
70
|
+
path rather than the ladder, and how to write a status line over
|
|
71
|
+
`on_focus_changed` — Tuile draws none for you.
|
|
71
72
|
6. **[Theming](06-theming.md).** Semantic color tokens read at paint
|
|
72
73
|
time, opt-in component backgrounds that inherit down the tree
|
|
73
74
|
(`bg_color`), light/dark auto-detection at startup and live OS
|
|
74
75
|
appearance flips, pairing variants in a `ThemeDef`, app-specific custom
|
|
75
76
|
tokens, and rebuilding theme-derived content in `on_theme_changed`.
|
|
76
77
|
7. **[The component library](07-components.md).** A narrative tour of
|
|
77
|
-
the shipped toolbox —
|
|
78
|
-
|
|
79
|
-
*when and why* you reach for each.
|
|
78
|
+
the shipped toolbox — the text inputs and views, the value fields, the
|
|
79
|
+
selectors, Button, ProgressBar, Window, TabSheet, MenuBar, Popup and the
|
|
80
|
+
window conveniences — framed around *when and why* you reach for each.
|
|
81
|
+
Signatures stay in the rdoc.
|
|
80
82
|
8. **[Testing a Tuile app](08-testing.md).** The testing approach:
|
|
81
83
|
`FakeScreen`, asserting against the painted buffer, driving
|
|
82
84
|
invalidation, and PTY-based end-to-end tests of runnable scripts.
|
data/examples/file_commander.rb
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
# Tuile two-pane file commander. Two windows side by side, each showing a
|
|
5
5
|
# directory listing. Tab switches active pane; arrows / jk move the cursor;
|
|
6
6
|
# Enter descends into a directory (no-op on a regular file); Backspace
|
|
7
|
-
# ascends to the parent. The header label shows the active pane's cwd
|
|
7
|
+
# ascends to the parent. The header label shows the active pane's cwd, and a
|
|
8
|
+
# static status line spells out the keys — Tuile draws no status bar and
|
|
9
|
+
# reserves no row, so both are ordinary children of the layout.
|
|
8
10
|
# Unreadable directories surface an InfoWindow. Layout follows the
|
|
9
11
|
# terminal on resize (WINCH) — the framework dispatches a TTYSizeEvent and
|
|
10
12
|
# the layout's `rect=` rebuilds the geometry.
|
|
@@ -80,7 +82,7 @@ module FileCommanderExample
|
|
|
80
82
|
@on_cwd_changed&.call
|
|
81
83
|
rescue SystemCallError => e
|
|
82
84
|
@cwd = previous
|
|
83
|
-
Tuile::Component::InfoWindow.open("Cannot open",
|
|
85
|
+
Tuile::Component::InfoWindow.open("Cannot open", "#{path}\n#{e.message}")
|
|
84
86
|
end
|
|
85
87
|
|
|
86
88
|
def load_entries
|
|
@@ -107,18 +109,6 @@ module FileCommanderExample
|
|
|
107
109
|
end
|
|
108
110
|
end
|
|
109
111
|
|
|
110
|
-
# A pane window that advertises navigation shortcuts in the status bar.
|
|
111
|
-
# The active window's `keyboard_hint` is rendered by {Tuile::Screen}
|
|
112
|
-
# alongside the global `q` quit hint, so all the user-facing controls
|
|
113
|
-
# land in one place.
|
|
114
|
-
class PaneWindow < Tuile::Component::Window
|
|
115
|
-
def keyboard_hint
|
|
116
|
-
"Tab #{screen.theme.hint("Switch")} " \
|
|
117
|
-
"Enter #{screen.theme.hint("Open")} " \
|
|
118
|
-
"Bksp #{screen.theme.hint("Up")}"
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
|
|
122
112
|
# Top-level layout. Header label on the first row, two side-by-side
|
|
123
113
|
# windows below. `rect=` re-runs on the initial mount and on every WINCH,
|
|
124
114
|
# so the split tracks the terminal size automatically.
|
|
@@ -128,19 +118,34 @@ module FileCommanderExample
|
|
|
128
118
|
@header = Tuile::Component::Label.new
|
|
129
119
|
add(@header)
|
|
130
120
|
|
|
131
|
-
@left_window =
|
|
121
|
+
@left_window = Tuile::Component::Window.new
|
|
132
122
|
@left_list = DirList.new(left_dir)
|
|
133
123
|
@left_list.on_cwd_changed = method(:refresh_header)
|
|
134
124
|
@left_window.content = @left_list
|
|
135
125
|
@left_window.scrollbar = true
|
|
136
126
|
add(@left_window)
|
|
137
127
|
|
|
138
|
-
@right_window =
|
|
128
|
+
@right_window = Tuile::Component::Window.new
|
|
139
129
|
@right_list = DirList.new(right_dir)
|
|
140
130
|
@right_list.on_cwd_changed = method(:refresh_header)
|
|
141
131
|
@right_window.content = @right_list
|
|
142
132
|
@right_window.scrollbar = true
|
|
143
133
|
add(@right_window)
|
|
134
|
+
|
|
135
|
+
# The status line. Every key here works in both panes, so the row never
|
|
136
|
+
# changes and nothing needs to watch focus — a status line is only worth
|
|
137
|
+
# wiring to Tuile::Screen#on_focus_changed= when its text actually varies
|
|
138
|
+
# with the focused component. `theme.hint` bakes its colors in, so the
|
|
139
|
+
# one thing this label does watch is a light/dark flip.
|
|
140
|
+
@status = Tuile::Component::Label.new
|
|
141
|
+
render_status = lambda do
|
|
142
|
+
t = screen.theme
|
|
143
|
+
@status.text = "q #{t.hint("quit")} Tab #{t.hint("Switch")} " \
|
|
144
|
+
"Enter #{t.hint("Open")} Bksp #{t.hint("Up")}"
|
|
145
|
+
end
|
|
146
|
+
render_status.call
|
|
147
|
+
@status.on_theme_changed = render_status
|
|
148
|
+
add(@status)
|
|
144
149
|
end
|
|
145
150
|
|
|
146
151
|
attr_reader :left_window
|
|
@@ -150,8 +155,9 @@ module FileCommanderExample
|
|
|
150
155
|
return if rect.empty?
|
|
151
156
|
|
|
152
157
|
@header.rect = Tuile::Rect.new(rect.left, rect.top, rect.width, 1)
|
|
158
|
+
@status.rect = Tuile::Rect.new(rect.left, rect.top + rect.height - 1, rect.width, 1)
|
|
153
159
|
body_top = rect.top + 1
|
|
154
|
-
body_height = [rect.height -
|
|
160
|
+
body_height = [rect.height - 2, 0].max
|
|
155
161
|
half = rect.width / 2
|
|
156
162
|
@left_window.rect = Tuile::Rect.new(rect.left, body_top, half, body_height)
|
|
157
163
|
@right_window.rect = Tuile::Rect.new(rect.left + half, body_top,
|
data/examples/hello_world.rb
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
# Tuile hello-world. A Window wrapping a Label
|
|
4
|
+
# Tuile hello-world. A Window wrapping a Label, over a status line the app
|
|
5
|
+
# owns — Tuile draws no chrome of its own and reserves no row.
|
|
5
6
|
#
|
|
6
7
|
# Run from the gem root:
|
|
7
8
|
# bundle exec ruby -Ilib examples/hello_world.rb
|
|
@@ -14,12 +15,23 @@ require "tuile"
|
|
|
14
15
|
# Tuile::Screen.instance during invalidate/repaint hooks.
|
|
15
16
|
screen = Tuile::Screen.new
|
|
16
17
|
|
|
17
|
-
label = Tuile::Component::Label.new("Hello, world!")
|
|
18
|
-
|
|
19
18
|
window = Tuile::Component::Window.new("Tuile")
|
|
20
|
-
window.content =
|
|
19
|
+
window.content = Tuile::Component::Label.new("Hello, world!")
|
|
20
|
+
|
|
21
|
+
# The status line. `theme.hint` styles the *description* half of a "key what"
|
|
22
|
+
# pair, and bakes the color in — so the label rebuilds itself from
|
|
23
|
+
# `on_theme_changed` to follow a light/dark flip.
|
|
24
|
+
status = Tuile::Component::Label.new
|
|
25
|
+
render_status = -> { status.text = "q #{screen.theme.hint("quit")}" }
|
|
26
|
+
render_status.call
|
|
27
|
+
status.on_theme_changed = render_status
|
|
28
|
+
|
|
29
|
+
# One row for the status line, everything else to the window.
|
|
30
|
+
root = Tuile::Component::Layout::Vertical.new
|
|
31
|
+
root.add(window, Tuile::Component::Layout::Expand[1])
|
|
32
|
+
root.add(status, Tuile::Component::Layout::Fixed[1])
|
|
21
33
|
|
|
22
|
-
screen.content =
|
|
34
|
+
screen.content = root
|
|
23
35
|
window.focus
|
|
24
36
|
begin
|
|
25
37
|
screen.run_event_loop
|