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.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +229 -80
  3. data/README.md +49 -24
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +17 -16
  7. data/book/05-focus.md +106 -34
  8. data/book/06-theming.md +108 -38
  9. data/book/07-components.md +249 -46
  10. data/book/08-testing.md +134 -32
  11. data/book/10-locale.md +3 -3
  12. data/book/README.md +11 -10
  13. data/examples/file_commander.rb +52 -32
  14. data/examples/hello_world.rb +18 -5
  15. data/examples/sampler.rb +576 -146
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +96 -97
  21. data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +27 -19
  24. data/lib/tuile/component/checkbox.rb +21 -19
  25. data/lib/tuile/component/checkbox_group.rb +17 -18
  26. data/lib/tuile/component/combo_box.rb +69 -64
  27. data/lib/tuile/component/confirm_window.rb +34 -27
  28. data/lib/tuile/component/date_field.rb +50 -18
  29. data/lib/tuile/component/date_time_field.rb +319 -0
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +99 -28
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +8 -15
  37. data/lib/tuile/component/has_placeholder.rb +1 -1
  38. data/lib/tuile/component/has_validation.rb +40 -14
  39. data/lib/tuile/component/has_value.rb +71 -17
  40. data/lib/tuile/component/integer_field.rb +7 -6
  41. data/lib/tuile/component/label.rb +8 -15
  42. data/lib/tuile/component/layout/absolute.rb +86 -0
  43. data/lib/tuile/component/layout/box.rb +38 -60
  44. data/lib/tuile/component/layout.rb +127 -13
  45. data/lib/tuile/component/list.rb +233 -120
  46. data/lib/tuile/component/list_dropdown.rb +151 -91
  47. data/lib/tuile/component/menu_bar/cascade.rb +102 -32
  48. data/lib/tuile/component/menu_bar.rb +102 -82
  49. data/lib/tuile/component/notification.rb +76 -49
  50. data/lib/tuile/component/overlay.rb +217 -58
  51. data/lib/tuile/component/password_field.rb +1 -8
  52. data/lib/tuile/component/picker_window.rb +41 -17
  53. data/lib/tuile/component/popup.rb +15 -26
  54. data/lib/tuile/component/progress_bar.rb +17 -11
  55. data/lib/tuile/component/radio_group.rb +16 -17
  56. data/lib/tuile/component/scroller.rb +266 -0
  57. data/lib/tuile/component/select.rb +26 -43
  58. data/lib/tuile/component/slot.rb +4 -5
  59. data/lib/tuile/component/tab_sheet.rb +27 -34
  60. data/lib/tuile/component/tabs.rb +49 -34
  61. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  62. data/lib/tuile/component/text_area.rb +32 -28
  63. data/lib/tuile/component/text_field.rb +68 -50
  64. data/lib/tuile/component/text_view.rb +157 -89
  65. data/lib/tuile/component/time_field.rb +51 -21
  66. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  67. data/lib/tuile/component/window.rb +27 -26
  68. data/lib/tuile/component.rb +653 -323
  69. data/lib/tuile/component_background.rb +177 -0
  70. data/lib/tuile/component_util.rb +43 -0
  71. data/lib/tuile/event.rb +29 -0
  72. data/lib/tuile/event_queue.rb +18 -4
  73. data/lib/tuile/fake_event_queue.rb +1 -1
  74. data/lib/tuile/fake_screen.rb +120 -7
  75. data/lib/tuile/keys.rb +15 -6
  76. data/lib/tuile/layout_pass.rb +180 -0
  77. data/lib/tuile/listeners.rb +219 -0
  78. data/lib/tuile/mouse/router.rb +233 -0
  79. data/lib/tuile/mouse.rb +244 -0
  80. data/lib/tuile/point.rb +6 -0
  81. data/lib/tuile/rect.rb +33 -0
  82. data/lib/tuile/screen.rb +510 -138
  83. data/lib/tuile/screen_pane.rb +185 -67
  84. data/lib/tuile/strict_layout.rb +127 -0
  85. data/lib/tuile/styled_string.rb +144 -14
  86. data/lib/tuile/testing/gestures.rb +35 -0
  87. data/lib/tuile/testing.rb +316 -42
  88. data/lib/tuile/theme.rb +192 -53
  89. data/lib/tuile/theme_def.rb +4 -0
  90. data/lib/tuile/version.rb +1 -1
  91. data/lib/tuile.rb +53 -0
  92. data/sig/tuile.rbs +6084 -1507
  93. metadata +19 -17
  94. data/COMPARISON.md +0 -101
  95. data/DECISIONS.md +0 -8562
  96. data/TERMINOLOGY.md +0 -85
  97. data/ideas/arrow-key-navigation.md +0 -221
  98. data/ideas/binder.md +0 -177
  99. data/ideas/composite-field.md +0 -77
  100. data/ideas/focus-accent.md +0 -116
  101. data/ideas/form-layout.md +0 -151
  102. data/ideas/hover/probe.rb +0 -241
  103. data/ideas/hover/probe_spec.rb +0 -82
  104. data/ideas/hover.md +0 -909
  105. data/ideas/modal-backdrop.md +0 -24
  106. data/ideas/new-components.md +0 -144
  107. data/ideas/per-component-buffers.md +0 -55
  108. data/lib/tuile/mouse_event.rb +0 -68
  109. 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, give it a `rect`, repaint, read the
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
- label.repaint
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.rect)
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(Component::Button, caption: "Save").handle_key(Keys::ENTER)
138
+ Testing.get(id: :save).handle_key?(Keys::ENTER)
111
139
  Testing.get(id: :amount).value = 42
112
140
  ```
113
141
 
114
- The spec is a class, an `id`, a caption, a block, or any combination of
115
- them — never a path through the hierarchy, which would break every time you
116
- nested one more layout. The class slot also takes a *mixin*, which is where
117
- the `Has*` family from chapter 7 pays off a second time:
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.on_tree { |c| combo ||= c if c.is_a?(Component::ComboBox) }
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
- `handle_mouse` are public, and calling them straight tests a component's
183
- own logic in isolation — no focus, no dispatch, just "given this key, does
184
- the list move its cursor?" `handle_key` returns whether it consumed the
185
- key, so you assert on that too:
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) # exercises the cursor directly
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 mouse test needs the component mounted, where a key test doesn't.** A
192
- click doesn't only *do* something, it also *focuses* — and
193
- {Tuile::Screen#focused=} refuses a component that isn't on the pane, so
194
- `handle_mouse` on a component you never attached raises "is not attached to
195
- this screen". Give it a tree first:
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
- screen.content = list # a click focuses; focus needs a tree
199
- list.rect = Rect.new(0, 0, 10, 5)
200
- list.handle_mouse(MouseEvent.new(:left, 5, 2))
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
- That applies to containers too, and to more of them than you might expect:
204
- a click descends to every child whose rect contains the point, so testing a
205
- window's footer by clicking it exercises the window, the footer's slot and
206
- the footer, all of which want to be attached.
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` — the top of the ladder — is private: it belongs to the 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#on_locale_changed}
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 `on_locale_changed` to re-derive it — and, while it is
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 = -> { label.text = due.strftime(screen.locale.date_formats.first) }
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 absolute, and the chapter
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 model is flicker-free
51
- without damage tracking or clipping.
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, absolute, integer coordinates; a parent
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, `Layout::Absolute` and the `rect=` override, the `Vertical` /
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` delivered to
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 to write a status line over
72
- `on_focus_changed` — Tuile draws none for you.
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 `on_theme_changed`.
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 `on_locale_changed` is
98
+ the session until you override it, and what `handle_locale_changed` is
98
99
  for.
@@ -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
- self.on_item_chosen = method(:descend)
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 on_focus
75
+ def handle_focus
60
76
  super
61
- @on_cwd_changed&.call
77
+ fire_cwd_changed
62
78
  end
63
79
 
64
80
  private
65
81
 
66
- def descend(_index, entry)
67
- target = File.expand_path(File.join(@cwd, entry[:name]))
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
- @on_cwd_changed&.call
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. `rect=` re-runs on the initial mount and on every WINCH,
114
- # so the split tracks the terminal size automatically.
115
- class FileCommander < Tuile::Component::Layout::Absolute
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 = method(:refresh_header)
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 = method(:refresh_header)
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= when its text actually varies
138
- # with the focused component. `theme.hint` bakes its colors in, so the
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("quit")} Tab #{t.hint("Switch")} " \
144
- "Enter #{t.hint("Open")} Bksp #{t.hint("Up")}"
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 = render_status
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
- def rect=(new_rect)
154
- super
155
- return if rect.empty?
156
-
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)
159
- body_top = rect.top + 1
160
- body_height = [rect.height - 2, 0].max
161
- half = rect.width / 2
162
- @left_window.rect = Tuile::Rect.new(rect.left, body_top, half, body_height)
163
- @right_window.rect = Tuile::Rect.new(rect.left + half, body_top,
164
- rect.width - half, body_height)
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
@@ -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.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.
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("quit")}" }
38
+ render_status = -> { status.text = "q #{screen.theme.fg(:hint, "quit")}" }
26
39
  render_status.call
27
- status.on_theme_changed = render_status
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