tuile 0.15.0 → 0.17.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 +229 -80
- data/README.md +49 -24
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +17 -16
- data/book/05-focus.md +106 -34
- data/book/06-theming.md +108 -38
- data/book/07-components.md +249 -46
- data/book/08-testing.md +134 -32
- data/book/10-locale.md +3 -3
- data/book/README.md +11 -10
- data/examples/file_commander.rb +52 -32
- data/examples/hello_world.rb +18 -5
- data/examples/sampler.rb +576 -146
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +96 -97
- data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +27 -19
- data/lib/tuile/component/checkbox.rb +21 -19
- data/lib/tuile/component/checkbox_group.rb +17 -18
- data/lib/tuile/component/combo_box.rb +69 -64
- data/lib/tuile/component/confirm_window.rb +34 -27
- data/lib/tuile/component/date_field.rb +50 -18
- data/lib/tuile/component/date_time_field.rb +319 -0
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +99 -28
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +8 -15
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +40 -14
- data/lib/tuile/component/has_value.rb +71 -17
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -60
- data/lib/tuile/component/layout.rb +127 -13
- data/lib/tuile/component/list.rb +233 -120
- data/lib/tuile/component/list_dropdown.rb +151 -91
- data/lib/tuile/component/menu_bar/cascade.rb +102 -32
- data/lib/tuile/component/menu_bar.rb +102 -82
- data/lib/tuile/component/notification.rb +76 -49
- data/lib/tuile/component/overlay.rb +217 -58
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +41 -17
- data/lib/tuile/component/popup.rb +15 -26
- data/lib/tuile/component/progress_bar.rb +17 -11
- data/lib/tuile/component/radio_group.rb +16 -17
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +26 -43
- data/lib/tuile/component/slot.rb +4 -5
- data/lib/tuile/component/tab_sheet.rb +27 -34
- data/lib/tuile/component/tabs.rb +49 -34
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +32 -28
- data/lib/tuile/component/text_field.rb +68 -50
- data/lib/tuile/component/text_view.rb +157 -89
- data/lib/tuile/component/time_field.rb +51 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +653 -323
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +18 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +120 -7
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +233 -0
- data/lib/tuile/mouse.rb +244 -0
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +510 -138
- data/lib/tuile/screen_pane.rb +185 -67
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +144 -14
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +316 -42
- data/lib/tuile/theme.rb +192 -53
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +6084 -1507
- metadata +19 -17
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -8562
- data/TERMINOLOGY.md +0 -85
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/binder.md +0 -177
- data/ideas/composite-field.md +0 -77
- data/ideas/focus-accent.md +0 -116
- data/ideas/form-layout.md +0 -151
- data/ideas/hover/probe.rb +0 -241
- data/ideas/hover/probe_spec.rb +0 -82
- data/ideas/hover.md +0 -909
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -144
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/book/08-testing.md
CHANGED
|
@@ -66,16 +66,21 @@ components don't emit escape sequences — they write styled cells into
|
|
|
66
66
|
that means the buffer *is* the rendered screen, sitting in memory, fully
|
|
67
67
|
inspectable, before any diffing or I/O. You assert against it directly.
|
|
68
68
|
|
|
69
|
-
The rhythm is: build the component,
|
|
70
|
-
buffer back over that rect.
|
|
69
|
+
The rhythm is: build the component, have a parent place it at a rect,
|
|
70
|
+
repaint, read the buffer back over that rect. A component never takes a
|
|
71
|
+
rect from anyone but its parent's `relayout` — `rect=` raises anywhere
|
|
72
|
+
else — so a test holds it in a `Layout::Absolute`, which places each child
|
|
73
|
+
exactly where it was added:
|
|
71
74
|
|
|
72
75
|
```ruby
|
|
73
76
|
label = Component::Label.new
|
|
74
|
-
label.rect = Rect.new(0, 0, 10, 1)
|
|
75
77
|
label.text = "hi"
|
|
76
|
-
|
|
78
|
+
holder = Component::Layout::Absolute.new
|
|
79
|
+
holder.add(label, Rect.new(0, 0, 10, 1))
|
|
80
|
+
Screen.instance.content = holder
|
|
81
|
+
Screen.instance.repaint
|
|
77
82
|
|
|
78
|
-
assert_equal ["hi "], Screen.instance.buffer.region_text(label.
|
|
83
|
+
assert_equal ["hi "], Screen.instance.buffer.region_text(label.absolute_rect)
|
|
79
84
|
```
|
|
80
85
|
|
|
81
86
|
{Tuile::Buffer#region_text} returns the plain text of each row in the
|
|
@@ -88,6 +93,29 @@ active-background, say, or that a theme flip changed a hint's hue. And
|
|
|
88
93
|
pinpoint check. Everything is scoped to a `rect`, so you assert about a
|
|
89
94
|
component's own region without caring what surrounds it.
|
|
90
95
|
|
|
96
|
+
That is what the *user* sees at those cells, a popup drawn over them
|
|
97
|
+
included. Often a test wants something narrower — what this one component
|
|
98
|
+
paints — and {Tuile::Testing.paint} answers that without a screen round
|
|
99
|
+
trip. It paints the component and its whole subtree into a {Tuile::Buffer}
|
|
100
|
+
of the component's own size, whose `(0, 0)` is the component's top-left:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
window = Component::Window.new("Settings")
|
|
104
|
+
window.content = Component::Label.new("hi")
|
|
105
|
+
Testing.place(window, Rect.new(0, 0, 12, 3)) # sized; nothing to attach
|
|
106
|
+
|
|
107
|
+
assert_equal ["┌Settings──┐", "│hi │", "└──────────┘"], Testing.paint(window).text
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`Testing.place` puts a component at a rect through whatever places it —
|
|
111
|
+
here a throwaway holder, since `window` has no parent — because `rect=`
|
|
112
|
+
raises anywhere but a parent's `relayout`. It never attaches, and
|
|
113
|
+
`Testing.paint` doesn't need it to. Because the buffer is the component's
|
|
114
|
+
own, `cell(1, 0)` means column 1 *of the window*, and the classic mistake of
|
|
115
|
+
reading the screen at `rect` instead of `absolute_rect` has nowhere to
|
|
116
|
+
happen. Ancestors don't clip the paint, though their background shows
|
|
117
|
+
through; popups aren't in the subtree, so they don't show at all.
|
|
118
|
+
|
|
91
119
|
What you do *not* assert content against is `prints`. On a FakeScreen,
|
|
92
120
|
`prints` captures only what actually went "to the wire" — cursor
|
|
93
121
|
positioning, housekeeping escapes, and the assembled frame string. Content
|
|
@@ -107,17 +135,33 @@ method* assembled, and the test never held a reference to it.
|
|
|
107
135
|
one component matching a spec:
|
|
108
136
|
|
|
109
137
|
```ruby
|
|
110
|
-
Testing.get(
|
|
138
|
+
Testing.get(id: :save).handle_key?(Keys::ENTER)
|
|
111
139
|
Testing.get(id: :amount).value = 42
|
|
112
140
|
```
|
|
113
141
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
142
|
+
Those two lines drive the component directly, which is the right altitude for
|
|
143
|
+
some tests and too low for others; the gestures later in this chapter are the
|
|
144
|
+
same two lines with "could a user have done this?" asked first.
|
|
145
|
+
|
|
146
|
+
The spec is a class, an `id`, a block, or any combination of them — never a
|
|
147
|
+
path through the hierarchy, which would break every time you nested one more
|
|
148
|
+
layout. The class slot also takes a *mixin*, which is where the `Has*` family
|
|
149
|
+
from chapter 7 pays off a second time:
|
|
118
150
|
`Testing.find(Component::HasBadInput)` finds every field in the tree whose
|
|
119
151
|
parse can fail, whatever their classes.
|
|
120
152
|
|
|
153
|
+
Notice what is *not* on that list: there is no way to look a component up by
|
|
154
|
+
the text it shows. That is deliberate. Those handles are structural — they
|
|
155
|
+
describe where a component sits and what kind of thing it is — while a caption
|
|
156
|
+
is copy, and copy gets reworded by people who are not thinking about your
|
|
157
|
+
specs. A test that breaks because "Save" became "Save changes" has told you
|
|
158
|
+
nothing. When you really do want the text, the block says so and reads better
|
|
159
|
+
for being explicit about the class:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
Testing.get(Component::Button) { _1.caption.to_s == "Save" }
|
|
163
|
+
```
|
|
164
|
+
|
|
121
165
|
The `id` in that second line is a plain `Symbol` tag you set on any
|
|
122
166
|
component, purely so a test can ask for it back:
|
|
123
167
|
|
|
@@ -132,7 +176,7 @@ walk that takes the first match —
|
|
|
132
176
|
|
|
133
177
|
```ruby
|
|
134
178
|
combo = nil
|
|
135
|
-
window.
|
|
179
|
+
window.walk_tree { |c| combo ||= c if c.is_a?(Component::ComboBox) }
|
|
136
180
|
```
|
|
137
181
|
|
|
138
182
|
— and the day the pane grows a second `ComboBox`, that silently re-points
|
|
@@ -178,34 +222,39 @@ terser, and changes nothing about the assertion channel. What a component
|
|
|
178
222
|
There are two altitudes at which you feed input, and picking the right one
|
|
179
223
|
is most of writing a good Tuile test.
|
|
180
224
|
|
|
181
|
-
**Low: call the component directly.** {Tuile::Component#handle_key} and
|
|
182
|
-
`
|
|
183
|
-
own logic in isolation — no focus, no dispatch, just "given this
|
|
184
|
-
the list move its cursor?"
|
|
185
|
-
|
|
225
|
+
**Low: call the component directly.** {Tuile::Component#handle_key?} and the
|
|
226
|
+
`handle_mouse_*` handlers are public, and calling one straight tests a
|
|
227
|
+
component's own logic in isolation — no focus, no dispatch, just "given this
|
|
228
|
+
key, does the list move its cursor?" Both answer whether they consumed the
|
|
229
|
+
event, so you assert on that too:
|
|
186
230
|
|
|
187
231
|
```ruby
|
|
188
|
-
list.handle_key(Keys::DOWN_ARROW)
|
|
232
|
+
list.handle_key?(Keys::DOWN_ARROW) # exercises the cursor directly
|
|
233
|
+
list.handle_mouse_scroll?(Mouse::ScrollEvent.new(:up, 5, 2)) # false at the top: an ancestor gets it
|
|
189
234
|
```
|
|
190
235
|
|
|
191
|
-
**A
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
236
|
+
**A press, though, wants the high altitude.** It does not only *do* something:
|
|
237
|
+
it focuses, it dismisses popups, and which component it even reaches is the
|
|
238
|
+
router's answer rather than the component's — so calling `handle_mouse_down?`
|
|
239
|
+
by hand tests a third of what a click is. Drive it through
|
|
240
|
+
{Tuile::FakeScreen}, which posts the gesture the terminal would:
|
|
196
241
|
|
|
197
242
|
```ruby
|
|
198
|
-
|
|
199
|
-
list
|
|
200
|
-
|
|
243
|
+
holder = Component::Layout::Absolute.new
|
|
244
|
+
holder.add(list, Rect.new(0, 0, 10, 5))
|
|
245
|
+
screen.content = holder # a press focuses; focus needs a tree
|
|
246
|
+
screen.click(5, 2) # press then release, at that cell
|
|
201
247
|
```
|
|
202
248
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
249
|
+
`click` is the whole gesture; `press` / `release` are its halves, for a test
|
|
250
|
+
about what the grab does in between, and `scroll` / `move` post the other two
|
|
251
|
+
events. They all take screen-absolute, 0-based coordinates, because that is what a
|
|
252
|
+
terminal reports — so a test clicks at `button.absolute_rect.left`, not at
|
|
253
|
+
`button.rect.left`, which is measured inside the button's parent. The component
|
|
254
|
+
receives the press back in its own coordinates. A press on a cell no component
|
|
255
|
+
covers simply does nothing.
|
|
207
256
|
|
|
208
|
-
**High: go through the pane.** {Tuile::ScreenPane#handle_key} runs the
|
|
257
|
+
**High: go through the pane.** {Tuile::ScreenPane#handle_key?} runs the
|
|
209
258
|
dispatch rung from chapter 5 that routing is actually about: delivery to
|
|
210
259
|
{Tuile::Screen#focused}, then the bubble up its ancestor chain to the scope
|
|
211
260
|
root. So when your test is about routing — that a layout's one-key pane jump
|
|
@@ -215,11 +264,11 @@ drive the pane and let the real machinery run:
|
|
|
215
264
|
|
|
216
265
|
```ruby
|
|
217
266
|
screen.focused = list # focus as production does — or list.focus
|
|
218
|
-
assert screen.pane.handle_key("1") # the layout's ancestor binding fires
|
|
267
|
+
assert screen.pane.handle_key?("1") # the layout's ancestor binding fires
|
|
219
268
|
```
|
|
220
269
|
|
|
221
270
|
The two rungs *above* the pane have their own doors, because `Screen`'s own
|
|
222
|
-
`handle_key
|
|
271
|
+
`handle_key?` — the top of the ladder — is private: it belongs to the key
|
|
223
272
|
thread, not to app code. Tab cycling is {Tuile::Screen#focus_next} /
|
|
224
273
|
`focus_previous`, both already scoped to the topmost modal popup, which is
|
|
225
274
|
what "a popup traps Tab" means. A global shortcut is a block you registered,
|
|
@@ -237,6 +286,59 @@ invalidates and lets the loop coalesce.) And to check invalidation itself
|
|
|
237
286
|
`Screen.instance.invalidated?(component)` and `invalidated_clear` let you
|
|
238
287
|
assert on the set directly.
|
|
239
288
|
|
|
289
|
+
## Gestures: locating and driving in one line
|
|
290
|
+
|
|
291
|
+
Put the two halves together and a gap appears between them. `Testing.get`
|
|
292
|
+
refuses to hand you a hidden component, because it simulates a user — but the
|
|
293
|
+
moment you have a handle, `handle_key?` and `value=` will do as they are told
|
|
294
|
+
no matter what. A button under an open modal popup, a field in a collapsed
|
|
295
|
+
panel: both drive perfectly from a test, and both are unreachable in the app
|
|
296
|
+
you are shipping. The test passes; the feature is broken.
|
|
297
|
+
|
|
298
|
+
The gestures close it. Each one asks whether a user could have done this, and
|
|
299
|
+
raises with a dump of the tree when the answer is no:
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
Testing.click(Testing.get(Component::Button, id: :save))
|
|
303
|
+
Testing.set_value(Testing.get(Component::TextField, id: :name), "Zaphod")
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
`Testing.click` is `screen.click` aimed by component rather than by cell: it
|
|
307
|
+
finds the top-left cell the component actually paints, checks a press there
|
|
308
|
+
*reaches* it, and posts the real press and release. What it refuses is
|
|
309
|
+
everything that would have clicked nothing — hidden, collapsed, or covered:
|
|
310
|
+
|
|
311
|
+
```
|
|
312
|
+
#<Button id=:save rect=(1,1 8x1) caption="Save"> is not clickable at 1,1:
|
|
313
|
+
a press there reaches nothing — a modal popup is open
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
It does *not* raise when the press lands and nobody claims it. Clicking a
|
|
317
|
+
`Label` is a thing a user can really do, and nothing happens; the gesture
|
|
318
|
+
asserts the click was possible, not that it achieved something.
|
|
319
|
+
|
|
320
|
+
`Testing.set_value` asks the keyboard's version of the same question: a field
|
|
321
|
+
outside the current key scope — behind an open modal — refuses. It moves no
|
|
322
|
+
focus, since no keystroke is involved, and assigns through `value=`, so it is
|
|
323
|
+
the value-level shortcut rather than a simulation of typing: the editor's input
|
|
324
|
+
filters never run.
|
|
325
|
+
|
|
326
|
+
Written out, those calls nest inside-out. Activate the refinement and they read
|
|
327
|
+
in the order they happen:
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
using Tuile::Testing::Gestures # top of the file, or inside one describe
|
|
331
|
+
|
|
332
|
+
Testing.get(Component::TextField, id: :name)._value = "Zaphod"
|
|
333
|
+
Testing.get(Component::Button, id: :save)._click
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
The leading underscore is a borrowing from Karibu-Testing, and it earns its keep
|
|
337
|
+
on the first line: `_value =` sits one character from a real `value=`, and the
|
|
338
|
+
mark is what tells a reader which is running. Being a refinement, it exists only
|
|
339
|
+
where you `using` it — per file or per `describe`, never leaking to the next —
|
|
340
|
+
and nothing is added to `Component`, so an app never sees it.
|
|
341
|
+
|
|
240
342
|
## Why background code just works
|
|
241
343
|
|
|
242
344
|
Chapter 4's rule was that background threads marshal UI work back with
|
data/book/10-locale.md
CHANGED
|
@@ -181,7 +181,7 @@ forever.
|
|
|
181
181
|
|
|
182
182
|
## When the locale changes under you
|
|
183
183
|
|
|
184
|
-
{Tuile::Screen#locale=} fires {Tuile::Component#
|
|
184
|
+
{Tuile::Screen#locale=} fires {Tuile::Component#handle_locale_changed}
|
|
185
185
|
across the attached tree and then invalidates all of it — the same
|
|
186
186
|
machinery {Tuile::Screen#theme=} uses, for the same reason.
|
|
187
187
|
|
|
@@ -194,14 +194,14 @@ The hook is for state you *pushed* somewhere when you last read the
|
|
|
194
194
|
conventions. A date field's typing hint is the worked example: it lives
|
|
195
195
|
in its editor's `placeholder`, written when the formats were last set, so
|
|
196
196
|
a repaint alone would faithfully repaint the stale `dd.mm.yyyy`. The
|
|
197
|
-
field overrides `
|
|
197
|
+
field overrides `handle_locale_changed` to re-derive it — and, while it is
|
|
198
198
|
there, to rewrite a buffer that still parses into the new primary format.
|
|
199
199
|
Your own code does the same for a date you rendered into a
|
|
200
200
|
{Tuile::Component::Label}, either by overriding the hook or by assigning
|
|
201
201
|
the listener:
|
|
202
202
|
|
|
203
203
|
```ruby
|
|
204
|
-
label.on_locale_changed
|
|
204
|
+
label.on_locale_changed { label.text = due.strftime(screen.locale.date_formats.first) }
|
|
205
205
|
```
|
|
206
206
|
|
|
207
207
|
One consequence to accept rather than defend against: a field holding a
|
data/book/README.md
CHANGED
|
@@ -18,7 +18,7 @@ links to the rdoc rather than restating it.
|
|
|
18
18
|
|
|
19
19
|
Chapters 1–2 are the **base vocabulary** — the component tree and the
|
|
20
20
|
repaint model that every later chapter leans on. Chapter 3 is the heart
|
|
21
|
-
of Tuile's design: layout is top-down and
|
|
21
|
+
of Tuile's design: layout is top-down and parent-relative, and the chapter
|
|
22
22
|
argues *why that is enough* — the "C64" case for hand-placed
|
|
23
23
|
coordinates on a character grid — rather than reaching for the
|
|
24
24
|
negotiated min/pref/max machinery of desktop and web toolkits.
|
|
@@ -47,13 +47,13 @@ one, not to fill an outline.
|
|
|
47
47
|
2. **[How the screen repaints](02-repaint.md).** Why components never
|
|
48
48
|
write to the terminal directly. `invalidate` → the back buffer →
|
|
49
49
|
a minimal diff → one synchronized flush per tick. The "cover your
|
|
50
|
-
own `rect`" contract, and why the whole
|
|
51
|
-
without damage tracking
|
|
50
|
+
own `rect`" contract, the bound that enforces it, and why the whole
|
|
51
|
+
model is flicker-free without damage tracking.
|
|
52
52
|
3. **[Layout: the parent sets the size](03-layout.md).** The heart of
|
|
53
|
-
the design. Top-down,
|
|
53
|
+
the design. Top-down, parent-relative, integer coordinates; a parent
|
|
54
54
|
assigns its children's `rect` and components never negotiate a size.
|
|
55
55
|
The C64 argument for *why simple layouting is enough* on a character
|
|
56
|
-
grid,
|
|
56
|
+
grid, the `relayout` override, `Layout::Absolute`, the `Vertical` /
|
|
57
57
|
`Horizontal` box layouts and their three constraints (`Fixed` /
|
|
58
58
|
`Percent` / `Expand`) as sugar over that same rule, `Fraction` for
|
|
59
59
|
sizing a popup against the screen, and resize as a discrete
|
|
@@ -65,16 +65,17 @@ one, not to fill an outline.
|
|
|
65
65
|
same queue rather than handled off the signal.
|
|
66
66
|
5. **[Focus and the keyboard](05-focus.md).** The focus chain and
|
|
67
67
|
`focusable?`, and the three-rung order in which a keystroke is offered
|
|
68
|
-
to the tree — Tab, global shortcuts, then `handle_key
|
|
68
|
+
to the tree — Tab, global shortcuts, then `handle_key?` delivered to
|
|
69
69
|
focus and bubbling up its ancestors. Why scope-wide keys (pane jumps, a
|
|
70
70
|
form's default button) belong on an ancestor, why a paste rides its own
|
|
71
|
-
path rather than the ladder, and how
|
|
72
|
-
|
|
71
|
+
path rather than the ladder, and how the mouse takes a road of its own —
|
|
72
|
+
a press bubbling to one claimant that is then grabbed. Ends on writing a
|
|
73
|
+
status line over `on_focus_changed` — Tuile draws none for you.
|
|
73
74
|
6. **[Theming](06-theming.md).** Semantic color tokens read at paint
|
|
74
75
|
time, opt-in component backgrounds that inherit down the tree
|
|
75
76
|
(`bg_color`), light/dark auto-detection at startup and live OS
|
|
76
77
|
appearance flips, pairing variants in a `ThemeDef`, app-specific custom
|
|
77
|
-
tokens, and rebuilding theme-derived content in `
|
|
78
|
+
tokens, and rebuilding theme-derived content in `handle_theme_changed`.
|
|
78
79
|
7. **[The component library](07-components.md).** A narrative tour of
|
|
79
80
|
the shipped toolbox — the text inputs and views, the value fields, the
|
|
80
81
|
selectors, Button, ProgressBar, Window, TabSheet, MenuBar, Popup and the
|
|
@@ -94,5 +95,5 @@ one, not to fill an outline.
|
|
|
94
95
|
never prose*, detected from `locale(1)` only when the environment
|
|
95
96
|
actually asked, with `Locale::ISO` as the floor. Why the name tables
|
|
96
97
|
are keyed by the `Date` accessor that reads them, how a field follows
|
|
97
|
-
the session until you override it, and what `
|
|
98
|
+
the session until you override it, and what `handle_locale_changed` is
|
|
98
99
|
for.
|
data/examples/file_commander.rb
CHANGED
|
@@ -20,6 +20,14 @@ require "rainbow"
|
|
|
20
20
|
require "tuile"
|
|
21
21
|
|
|
22
22
|
module FileCommanderExample
|
|
23
|
+
# `hint` is the app's token, not Tuile's — the framework carries accents only
|
|
24
|
+
# for the chrome it paints itself, and the status line below is ours. Paired
|
|
25
|
+
# in a ThemeDef so it survives an OS appearance flip.
|
|
26
|
+
APP_THEME = Tuile::ThemeDef.new(
|
|
27
|
+
dark: Tuile::Theme::DARK.with(custom: { hint: Tuile::Color::GREY54 }),
|
|
28
|
+
light: Tuile::Theme::LIGHT.with(custom: { hint: Tuile::Color::GREY62 })
|
|
29
|
+
)
|
|
30
|
+
|
|
23
31
|
# Pastel X11 colors chosen to read on a black background.
|
|
24
32
|
TYPE_COLORS = {
|
|
25
33
|
directory: :lightskyblue,
|
|
@@ -32,20 +40,28 @@ module FileCommanderExample
|
|
|
32
40
|
# navigation, and notifies a callback so the shared header label can be
|
|
33
41
|
# rebuilt without the panes knowing about each other.
|
|
34
42
|
class DirList < Tuile::Component::List
|
|
43
|
+
# What `on_cwd_changed` fires. An app's own event is a `Data.define`
|
|
44
|
+
# including the marker, exactly as the gem's are.
|
|
45
|
+
CwdChangedEvent = Data.define(:source, :cwd) { include Tuile::Event }
|
|
46
|
+
|
|
47
|
+
# @!method on_cwd_changed
|
|
48
|
+
# Fired whenever this pane's `cwd` changes, or it takes focus — the shared
|
|
49
|
+
# header rebuilds from it, so the two panes need not know about each other.
|
|
50
|
+
# @return [Tuile::Listeners]
|
|
51
|
+
listener :on_cwd_changed
|
|
52
|
+
|
|
35
53
|
def initialize(start_dir)
|
|
36
54
|
super()
|
|
37
55
|
self.cursor = Tuile::Component::List::Cursor.new
|
|
38
|
-
self.renderer = ->(entry) { Rainbow(entry[:display]).color(TYPE_COLORS[entry[:type]]) }
|
|
56
|
+
self.renderer = ->(entry, _w) { Rainbow(entry[:display]).color(TYPE_COLORS[entry[:type]]) }
|
|
39
57
|
@cwd = File.expand_path(start_dir)
|
|
40
|
-
@on_cwd_changed = nil
|
|
41
58
|
load_entries
|
|
42
|
-
|
|
59
|
+
on_item_chosen << method(:descend)
|
|
43
60
|
end
|
|
44
61
|
|
|
45
62
|
attr_reader :cwd
|
|
46
|
-
attr_accessor :on_cwd_changed
|
|
47
63
|
|
|
48
|
-
def handle_key(key)
|
|
64
|
+
def handle_key?(key)
|
|
49
65
|
return false unless active?
|
|
50
66
|
|
|
51
67
|
if Tuile::Keys::BACKSPACES.include?(key)
|
|
@@ -56,15 +72,15 @@ module FileCommanderExample
|
|
|
56
72
|
end
|
|
57
73
|
end
|
|
58
74
|
|
|
59
|
-
def
|
|
75
|
+
def handle_focus
|
|
60
76
|
super
|
|
61
|
-
|
|
77
|
+
fire_cwd_changed
|
|
62
78
|
end
|
|
63
79
|
|
|
64
80
|
private
|
|
65
81
|
|
|
66
|
-
def descend(
|
|
67
|
-
target = File.expand_path(File.join(@cwd,
|
|
82
|
+
def descend(event)
|
|
83
|
+
target = File.expand_path(File.join(@cwd, event.item[:name]))
|
|
68
84
|
change_to(target) if File.directory?(target)
|
|
69
85
|
end
|
|
70
86
|
|
|
@@ -73,13 +89,15 @@ module FileCommanderExample
|
|
|
73
89
|
change_to(parent) if parent != @cwd
|
|
74
90
|
end
|
|
75
91
|
|
|
92
|
+
def fire_cwd_changed = on_cwd_changed.fire(CwdChangedEvent.new(source: self, cwd: @cwd))
|
|
93
|
+
|
|
76
94
|
def change_to(path)
|
|
77
95
|
previous = @cwd
|
|
78
96
|
@cwd = path
|
|
79
97
|
load_entries
|
|
80
98
|
self.cursor = Tuile::Component::List::Cursor.new
|
|
81
99
|
self.scroll_top_row = 0
|
|
82
|
-
|
|
100
|
+
fire_cwd_changed
|
|
83
101
|
rescue SystemCallError => e
|
|
84
102
|
@cwd = previous
|
|
85
103
|
Tuile::Component::InfoWindow.open("Cannot open", "#{path}\n#{e.message}")
|
|
@@ -110,9 +128,9 @@ module FileCommanderExample
|
|
|
110
128
|
end
|
|
111
129
|
|
|
112
130
|
# Top-level layout. Header label on the first row, two side-by-side
|
|
113
|
-
# windows below. `
|
|
114
|
-
# so the split tracks the terminal size automatically.
|
|
115
|
-
class FileCommander < Tuile::Component::Layout
|
|
131
|
+
# windows below. `relayout` re-runs on the initial mount and on every
|
|
132
|
+
# WINCH, so the split tracks the terminal size automatically.
|
|
133
|
+
class FileCommander < Tuile::Component::Layout
|
|
116
134
|
def initialize(left_dir, right_dir)
|
|
117
135
|
super()
|
|
118
136
|
@header = Tuile::Component::Label.new
|
|
@@ -120,48 +138,49 @@ module FileCommanderExample
|
|
|
120
138
|
|
|
121
139
|
@left_window = Tuile::Component::Window.new
|
|
122
140
|
@left_list = DirList.new(left_dir)
|
|
123
|
-
@left_list.on_cwd_changed
|
|
141
|
+
@left_list.on_cwd_changed << method(:refresh_header)
|
|
124
142
|
@left_window.content = @left_list
|
|
125
143
|
@left_window.scrollbar = true
|
|
126
144
|
add(@left_window)
|
|
127
145
|
|
|
128
146
|
@right_window = Tuile::Component::Window.new
|
|
129
147
|
@right_list = DirList.new(right_dir)
|
|
130
|
-
@right_list.on_cwd_changed
|
|
148
|
+
@right_list.on_cwd_changed << method(:refresh_header)
|
|
131
149
|
@right_window.content = @right_list
|
|
132
150
|
@right_window.scrollbar = true
|
|
133
151
|
add(@right_window)
|
|
134
152
|
|
|
135
153
|
# The status line. Every key here works in both panes, so the row never
|
|
136
154
|
# changes and nothing needs to watch focus — a status line is only worth
|
|
137
|
-
# wiring to Tuile::Screen#on_focus_changed
|
|
138
|
-
# with the focused component. `theme.
|
|
155
|
+
# wiring to Tuile::Screen#on_focus_changed when its text actually varies
|
|
156
|
+
# with the focused component. `theme.fg` bakes its colors in, so the
|
|
139
157
|
# one thing this label does watch is a light/dark flip.
|
|
140
158
|
@status = Tuile::Component::Label.new
|
|
141
159
|
render_status = lambda do
|
|
142
160
|
t = screen.theme
|
|
143
|
-
@status.text = "q #{t.hint
|
|
144
|
-
"Enter #{t.hint
|
|
161
|
+
@status.text = "q #{t.fg(:hint, "quit")} Tab #{t.fg(:hint, "Switch")} " \
|
|
162
|
+
"Enter #{t.fg(:hint, "Open")} Bksp #{t.fg(:hint, "Up")}"
|
|
145
163
|
end
|
|
146
164
|
render_status.call
|
|
147
|
-
@status.on_theme_changed
|
|
165
|
+
@status.on_theme_changed << render_status
|
|
148
166
|
add(@status)
|
|
149
167
|
end
|
|
150
168
|
|
|
151
169
|
attr_reader :left_window
|
|
152
170
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
171
|
+
protected
|
|
172
|
+
|
|
173
|
+
# No `return if rect.empty?` guard: an empty pane still assigns every
|
|
174
|
+
# child, or they strand at their old coordinates (`D_empty_ancestor`).
|
|
175
|
+
def relayout
|
|
176
|
+
# A child's rect is relative to this layout, so nothing here names where
|
|
177
|
+
# the layout itself sits — moving it moves the whole pane for free.
|
|
178
|
+
@header.rect = Tuile::Rect.new(0, 0, width, 1)
|
|
179
|
+
@status.rect = Tuile::Rect.new(0, height - 1, width, 1)
|
|
180
|
+
body_height = [height - 2, 0].max
|
|
181
|
+
half = width / 2
|
|
182
|
+
@left_window.rect = Tuile::Rect.new(0, 1, half, body_height)
|
|
183
|
+
@right_window.rect = Tuile::Rect.new(half, 1, width - half, body_height)
|
|
165
184
|
end
|
|
166
185
|
|
|
167
186
|
private
|
|
@@ -180,6 +199,7 @@ unless File.directory?(start_dir)
|
|
|
180
199
|
end
|
|
181
200
|
|
|
182
201
|
screen = Tuile::Screen.new
|
|
202
|
+
screen.theme_def = FileCommanderExample::APP_THEME
|
|
183
203
|
commander = FileCommanderExample::FileCommander.new(start_dir, start_dir)
|
|
184
204
|
screen.content = commander
|
|
185
205
|
commander.left_window.focus
|
data/examples/hello_world.rb
CHANGED
|
@@ -11,20 +11,33 @@
|
|
|
11
11
|
|
|
12
12
|
require "tuile"
|
|
13
13
|
|
|
14
|
+
# `hint` is the app's token, not Tuile's: the framework carries accents for the
|
|
15
|
+
# chrome *it* paints, and a status line is the app's own (Tuile draws none).
|
|
16
|
+
# Pairing the two shades in a ThemeDef is what makes it survive the user
|
|
17
|
+
# flipping OS appearance — a bare `theme=` would be replaced on the next flip.
|
|
18
|
+
# Both greys quantize to :bright_black on a 16-color terminal, so the
|
|
19
|
+
# description stays dimmer than the key beside it even there.
|
|
20
|
+
APP_THEME = Tuile::ThemeDef.new(
|
|
21
|
+
dark: Tuile::Theme::DARK.with(custom: { hint: Tuile::Color::GREY54 }),
|
|
22
|
+
light: Tuile::Theme::LIGHT.with(custom: { hint: Tuile::Color::GREY62 })
|
|
23
|
+
)
|
|
24
|
+
|
|
14
25
|
# Screen must exist before any Component is built: components reach for
|
|
15
26
|
# Tuile::Screen.instance during invalidate/repaint hooks.
|
|
16
27
|
screen = Tuile::Screen.new
|
|
28
|
+
screen.theme_def = APP_THEME
|
|
17
29
|
|
|
18
30
|
window = Tuile::Component::Window.new("Tuile")
|
|
19
31
|
window.content = Tuile::Component::Label.new("Hello, world!")
|
|
20
32
|
|
|
21
|
-
# The status line. `theme.
|
|
22
|
-
# pair,
|
|
23
|
-
# `on_theme_changed` to follow a
|
|
33
|
+
# The status line. `theme.fg` styles the *description* half of a "key what"
|
|
34
|
+
# pair — dimmed, so the key is the element that pulls the eye — and bakes the
|
|
35
|
+
# color in, so the label rebuilds itself from its `on_theme_changed` slot to follow a
|
|
36
|
+
# light/dark flip.
|
|
24
37
|
status = Tuile::Component::Label.new
|
|
25
|
-
render_status = -> { status.text = "q #{screen.theme.hint
|
|
38
|
+
render_status = -> { status.text = "q #{screen.theme.fg(:hint, "quit")}" }
|
|
26
39
|
render_status.call
|
|
27
|
-
status.on_theme_changed
|
|
40
|
+
status.on_theme_changed << render_status
|
|
28
41
|
|
|
29
42
|
# One row for the status line, everything else to the window.
|
|
30
43
|
root = Tuile::Component::Layout::Vertical.new
|