tuile 0.9.0 → 0.10.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +31 -24
  5. data/book/04-event-loop.md +86 -0
  6. data/book/05-focus.md +82 -51
  7. data/book/06-theming.md +53 -1
  8. data/book/07-components.md +363 -9
  9. data/book/08-testing.md +21 -8
  10. data/book/09-styled-text.md +132 -0
  11. data/book/README.md +19 -13
  12. data/examples/sampler.rb +435 -20
  13. data/ideas/new-components.md +109 -0
  14. data/ideas/per-component-buffers.md +55 -0
  15. data/lib/tuile/buffer.rb +52 -51
  16. data/lib/tuile/color.rb +4 -10
  17. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  18. data/lib/tuile/component/button.rb +25 -21
  19. data/lib/tuile/component/checkbox.rb +133 -0
  20. data/lib/tuile/component/checkbox_group.rb +188 -0
  21. data/lib/tuile/component/combo_box.rb +281 -0
  22. data/lib/tuile/component/has_caption.rb +44 -0
  23. data/lib/tuile/component/has_content.rb +5 -5
  24. data/lib/tuile/component/has_value.rb +64 -0
  25. data/lib/tuile/component/integer_field.rb +135 -0
  26. data/lib/tuile/component/label.rb +13 -10
  27. data/lib/tuile/component/layout.rb +3 -17
  28. data/lib/tuile/component/list.rb +8 -7
  29. data/lib/tuile/component/list_dropdown.rb +106 -0
  30. data/lib/tuile/component/password_field.rb +105 -0
  31. data/lib/tuile/component/popup.rb +10 -14
  32. data/lib/tuile/component/progress_bar.rb +278 -0
  33. data/lib/tuile/component/radio_group.rb +188 -0
  34. data/lib/tuile/component/text_area.rb +189 -65
  35. data/lib/tuile/component/text_field.rb +170 -32
  36. data/lib/tuile/component/text_view.rb +57 -114
  37. data/lib/tuile/component/window.rb +32 -47
  38. data/lib/tuile/component.rb +250 -87
  39. data/lib/tuile/event_queue.rb +14 -17
  40. data/lib/tuile/fake_event_queue.rb +11 -2
  41. data/lib/tuile/fake_screen.rb +4 -5
  42. data/lib/tuile/fraction.rb +6 -10
  43. data/lib/tuile/screen.rb +202 -104
  44. data/lib/tuile/screen_pane.rb +51 -41
  45. data/lib/tuile/styled_string.rb +112 -83
  46. data/lib/tuile/theme.rb +78 -41
  47. data/lib/tuile/version.rb +1 -1
  48. data/sig/tuile.rbs +2043 -614
  49. metadata +18 -7
data/README.md CHANGED
@@ -98,9 +98,8 @@ Shift+Tab move focus between the list and the demo's widgets.
98
98
  ### Component tree
99
99
 
100
100
  Everything on screen is a `Tuile::Component`. Components have a `parent`,
101
- `children`, a `rect` (absolute position), an `active?` flag (true for every
102
- component on the focus chain root → focused), and an optional `key_shortcut`
103
- that the framework will route keys to from anywhere in the tree.
101
+ `children`, a `rect` (absolute position), and an `active?` flag (true for
102
+ every component on the focus chain root → focused).
104
103
 
105
104
  A single `Tuile::Screen` (process singleton) owns the tree. Under it sits a
106
105
  structural `ScreenPane` with three slots: tiled `content` (your app's main
@@ -194,11 +193,13 @@ mechanism that handles it wins:
194
193
  screen.unregister_global_shortcut(Tuile::Keys::CTRL_L)
195
194
  ```
196
195
 
197
- Only unprintable keys are accepted (control characters, ESC, BACKSPACE,
198
- arrows, F-keys); printable keys raise so they can't hijack typing into
199
- a `TextField`. By default, the shortcut is suppressed while any popup
200
- is open and the popup receives the key; pass `over_popups: true` to
201
- pre-empt the popup.
196
+ This registry sits above the component tree and nothing suppresses it,
197
+ so it only accepts keys no widget can need: printable keys raise (they'd
198
+ hijack typing), as do Tab/Shift+Tab and `Screen::EDITING_KEYS` (Enter,
199
+ Backspace, Delete, the arrows). Control characters, ESC, `PgUp`/`PgDn`
200
+ and F-keys are yours. By default, the shortcut is suppressed while any
201
+ popup is open and the popup receives the key; pass `over_popups: true`
202
+ to pre-empt the popup.
202
203
 
203
204
  Pass `hint:` to surface the shortcut in the status bar. It's a
204
205
  preformatted string the caller fully owns (color it however the rest
@@ -207,23 +208,10 @@ mechanism that handles it wins:
207
208
  `over_popups: true` hints show up, prepended before the popup's
208
209
  `q Close`. Omit `hint:` to leave the shortcut silent in the status bar.
209
210
 
210
- 3. **`Component#key_shortcut`** — a declarative hotkey attached to a
211
- component. The framework walks the focused component's subtree for a
212
- match and focuses the winner. Good fit for "press F to focus the filter
213
- field" or one-key tab pickers. The lookup is suppressed while the
214
- focused component owns the hardware cursor (e.g. a `TextField` the user
215
- is typing into) so editing isn't interrupted:
216
-
217
- ```ruby
218
- filter_field.key_shortcut = "f"
219
- ```
220
-
221
- 4. **`Component#handle_key`** — override this on your own component when
211
+ 3. **`Component#handle_key`** — override this on your own component when
222
212
  it needs to react to keys directly (a list reacting to arrows, a custom
223
213
  widget handling Enter, …). Return `true` to mark the key handled,
224
- `false` to let the dispatcher keep walking. Call `super` to keep the
225
- default `key_shortcut` subtree lookup; suppress it only when you
226
- deliberately want this component to swallow everything:
214
+ `false` to let the key keep travelling:
227
215
 
228
216
  ```ruby
229
217
  class Toggle < Tuile::Component
@@ -233,7 +221,26 @@ mechanism that handles it wins:
233
221
  invalidate
234
222
  true
235
223
  else
236
- super
224
+ false
225
+ end
226
+ end
227
+ end
228
+ ```
229
+
230
+ The key goes to the focused component first, then **bubbles up its
231
+ ancestors** to the scope root (the topmost popup, or the tiled content).
232
+ That makes an ancestor the right home for scope-wide keys — a form's
233
+ default button, or one-key jumps between panes — and it needs no special
234
+ protection, because a focused `TextField` consumes the key before the
235
+ ancestor ever sees it:
236
+
237
+ ```ruby
238
+ class AppLayout < Tuile::Component::Layout::Absolute
239
+ def handle_key(key)
240
+ case key
241
+ when "1" then @files.focus; true
242
+ when "2" then @log.focus; true
243
+ else false
237
244
  end
238
245
  end
239
246
  end
@@ -172,6 +172,92 @@ Reach for `tick` when you're pacing work ("check every two seconds"),
172
172
  `tick_fps` when you're driving an animation ("spin at 8 fps"). Same
173
173
  machinery underneath; pick the unit that matches how you're thinking.
174
174
 
175
+ ## Owning a resource for as long as you're on screen
176
+
177
+ Both examples above end with a loose thread: *who calls `cancel`, and
178
+ when?* If the spinner lives in a popup the user can close, then someone
179
+ has to remember to stop the ticker at exactly the moment the popup goes
180
+ away — and "someone remembers" is how you end up with a timer firing
181
+ against a component nobody can see, forever.
182
+
183
+ The component itself is the only thing that knows. So it gets told:
184
+
185
+ ```ruby
186
+ class Spinner < Tuile::Component::Label
187
+ FRAMES = %w[/ - \\ |]
188
+
189
+ protected
190
+
191
+ def on_attached
192
+ @ticker = screen.event_queue.tick_fps(8) { |n| self.text = FRAMES[n % FRAMES.size] }
193
+ end
194
+
195
+ def on_detached
196
+ @ticker&.cancel
197
+ @ticker = nil
198
+ end
199
+ end
200
+ ```
201
+
202
+ `on_attached` fires the moment this component's tree is mounted on the
203
+ screen; `on_detached` fires the moment it's unmounted. Add the spinner to a
204
+ popup and it starts; close the popup and it stops. Nothing at the call site
205
+ remembers anything — `popup.close` is the whole teardown.
206
+
207
+ The contract is a mirror: **`on_attached` starts what `on_detached` stops.**
208
+ Keep both cheap and idempotent, because a component *moved* from one parent
209
+ to another gets `on_detached` and then `on_attached` — between those two
210
+ calls it genuinely is off the screen, possibly for a long time, so stopping
211
+ and restarting is the honest thing to do. And whatever you acquire in
212
+ `on_attached` you must release in `on_detached`, because nothing else will.
213
+
214
+ This generalizes well beyond tickers, and the interesting case is
215
+ subscriptions. A component may depend on a service, but a service must never
216
+ reach back up into the UI — so when data has to flow *upward*, the component
217
+ subscribes and the service emits blind. That subscription is a resource with
218
+ exactly the lifetime the hooks describe:
219
+
220
+ ```ruby
221
+ class BuildStatus < Tuile::Component::Label
222
+ def initialize(service)
223
+ super()
224
+ @service = service
225
+ end
226
+
227
+ protected
228
+
229
+ def on_attached
230
+ @subscription = @service.on_change { |s| screen.event_queue.submit { self.text = s } }
231
+ end
232
+
233
+ def on_detached
234
+ @subscription&.unsubscribe
235
+ @subscription = nil
236
+ end
237
+ end
238
+ ```
239
+
240
+ Note the `submit` inside the listener — the service emits from whatever
241
+ thread it likes, and marshalling onto the UI thread is the listener's job,
242
+ exactly as earlier in this chapter. What the hooks add is the other half:
243
+ the subscription exists for precisely as long as the component is on
244
+ screen, and no view-closing code path has to know that the subscription
245
+ exists at all.
246
+
247
+ `screen.close` counts as unmounting, so the `screen.close` at the end of
248
+ your `main` gives every component still on screen its `on_detached` — the
249
+ tickers stop, the subscriptions come off, and you didn't write any of that
250
+ teardown. What *doesn't* fire is a process that exits without closing the
251
+ screen at all: these are lifecycle hooks, not destructors, and Tuile
252
+ installs no `at_exit`. If your `on_detached` does something that matters
253
+ beyond the process — flushing a file, say — close the screen deliberately
254
+ rather than relying on exit.
255
+
256
+ The other thing the hooks are not is a place to do layout. When
257
+ `on_attached` runs, your parent hasn't assigned your `rect` yet. If you need
258
+ to paint, invalidate here and do the work in `repaint`, which is what
259
+ chapter 2 was about anyway.
260
+
175
261
  ## Resize is just another event
176
262
 
177
263
  A terminal resize could have been handled off the `SIGWINCH` signal
data/book/05-focus.md CHANGED
@@ -55,8 +55,8 @@ Shift+Tab land on this component while cycling. It's also `false` by
55
55
  default, and it *implies* focusable — a tab stop is always a valid focus
56
56
  target, but not every focus target is a tab stop. The distinction matters
57
57
  for containers: a {Tuile::Component::Window} is focusable (so a click on
58
- its chrome, or a `key_shortcut`, can focus it) but is *not* a tab stop
59
- (Tab should skip the frame and stop on the actual inputs inside it). So:
58
+ its chrome can focus it) but is *not* a tab stop (Tab should skip the frame
59
+ and stop on the actual inputs inside it). So:
60
60
 
61
61
  - **Label** — neither. Decoration.
62
62
  - **Window, Popup** — focusable, not a tab stop. Clickable chrome,
@@ -80,8 +80,8 @@ earlier.
80
80
  **1. Tab and Shift+Tab — focus navigation, first, always.** These are
81
81
  intercepted before anything else and drive the cycling described above.
82
82
  They're taken off the top deliberately: a focused text field swallows
83
- almost every printable key (step 3 explains how), and if Tab weren't
84
- reserved here, a field would trap it too and you could never Tab out.
83
+ almost every printable key, and if Tab weren't reserved here, a field would
84
+ trap it too and you could never Tab out.
85
85
 
86
86
  **2. Global shortcuts.** App-level shortcuts registered with
87
87
  {Tuile::Screen#register_global_shortcut} fire next, before any component
@@ -95,32 +95,24 @@ screen.register_global_shortcut(Tuile::Keys::CTRL_L,
95
95
  end
96
96
  ```
97
97
 
98
- Only *unprintable* keys are allowed here (control keys, function keys,
99
- escape sequences). Printable keys are rejected at registration time,
100
- because a global binding on `a` would hijack someone typing `a` into a
101
- text field — that's what the per-component `key_shortcut` in step 3 is
102
- for. A shortcut can opt to fire even while a modal popup is open
103
- (`over_popups: true`); by default it's suppressed while a popup is up, so
104
- the popup stays modal.
105
-
106
- **3. A `key_shortcut` anywhere in the focused scope.** Every component can
107
- carry a {Tuile::Component#key_shortcut} — a single key that, when pressed,
108
- *jumps focus to that component*. The screen searches the current modal
109
- scope's subtree for a component whose shortcut matches; if it finds one,
110
- it focuses it and consumes the key. A window advertises its shortcut in
111
- its caption (`[f]-Files`), so a whole pane can be reachable with one key.
112
-
113
- But this search is **suppressed while a text widget is mid-edit** — and
114
- that suppression is the subtle, important part, so it gets its own
115
- section below.
116
-
117
- **4. `handle_key` on the focus chain.** Finally, if nothing above claimed
118
- the key, it's delivered to the focused component's
119
- {Tuile::Component#handle_key}, and if that returns `false` (didn't handle
120
- it), it bubbles up the ancestor chain — the focused component, then its
121
- parent, then *its* parent — until someone returns `true` or the scope
122
- root is reached. This is how a list handles arrow keys itself but lets an
123
- unhandled key rise to the window around it.
98
+ This registry is the only keyboard mechanism that sits *above* the
99
+ component tree, and nothing suppresses it — which is exactly why it's
100
+ picky about what it accepts. Printable keys are rejected at registration
101
+ time, because a global binding on `a` would hijack someone typing `a` into
102
+ a text field. So are Tab and Shift+Tab (step 1 already took them), and so
103
+ are `Screen::EDITING_KEYS` — Enter, Backspace, Delete and the arrows —
104
+ because every editable widget needs those, and a global binding would
105
+ break text entry app-wide with no way for a field to defend itself. What's
106
+ left is yours: control keys, ESC, PgUp/PgDn, function keys. A shortcut can
107
+ opt to fire even while a modal popup is open (`over_popups: true`); by
108
+ default it's suppressed while a popup is up, so the popup stays modal.
109
+
110
+ **3. `handle_key`, delivered to focus and bubbling up.** Everything else
111
+ goes to the focused component's {Tuile::Component#handle_key}, and if that
112
+ returns `false` (didn't handle it), the key bubbles up the ancestor chain —
113
+ the focused component, then its parent, then *its* parent — until someone
114
+ returns `true` or the scope root is reached. This is how a list handles
115
+ arrow keys itself but lets an unhandled key rise to the window around it.
124
116
 
125
117
  A component only ever receives a key when it's on the focus chain, so
126
118
  `handle_key` implementations act on the key alone — they never need to
@@ -128,32 +120,71 @@ check their own `active?` state. And if focus is `nil`, or sits outside
128
120
  the current modal scope, delivery reaches no one: that's precisely what
129
121
  makes an open modal popup modal.
130
122
 
131
- ## Why a text field can just type
123
+ Three rungs, and that's the whole ladder. Tuile used to have a fourth — a
124
+ scan of the scope for a component carrying a matching "shortcut key,"
125
+ which would jump focus to it. It's gone; the next section explains why the
126
+ bubble does that job better.
132
127
 
133
- Here's the problem the cursor-ownership rule solves. Suppose a window has
134
- a search field, and elsewhere in the same window a list has `key_shortcut
135
- = "d"` for "delete." You click into the field and type "add item." Should
136
- the "d" in "add" trigger delete?
128
+ ## Scope-wide keys live on an ancestor
137
129
 
138
- Obviously not — and step 3 is where it would go wrong, because "d" *is* a
139
- registered `key_shortcut` in the scope. The rule that saves you: the
140
- `key_shortcut` search in step 3 is skipped whenever a component **owns
141
- the hardware cursor**.
130
+ Two things every app wants: `1`/`2`/`3` to jump between panes, and Enter to
131
+ submit a form. Neither is a *global* action — each belongs to one region of
132
+ the tree — and neither needs machinery, because bubbling already has the
133
+ right shape. **Put the key on the ancestor that owns the region.**
134
+
135
+ ```ruby
136
+ class AppLayout < Tuile::Component::Layout::Absolute
137
+ def handle_key(key)
138
+ case key
139
+ when "1" then @files.focus; true
140
+ when "2" then @log.focus; true
141
+ else false
142
+ end
143
+ end
144
+ end
145
+ ```
146
+
147
+ Now think about what happens when the user clicks into a search field
148
+ inside `@files` and types "add item". Should the `1` in a typed "item 1"
149
+ jump panes? Obviously not — and it doesn't, for a reason that requires no
150
+ special case at all: **the focused field consumes the key at step 3 and
151
+ returns `true`, so the layout never sees it.** An ancestor only hears the
152
+ keys its descendants declined. That's the whole protection.
153
+
154
+ The same mechanism gives you a form's default button, one form per popup:
155
+
156
+ | focused widget | Enter | outcome |
157
+ |---|---|---|
158
+ | `TextArea` | consumes it (newline) | the form never sees it |
159
+ | `TextField` with an `on_enter` | consumes it | no double-submit |
160
+ | `TextField` without one | declines | bubbles up → submit |
161
+ | `Button` | consumes it | activates *itself*, not the default |
162
+
163
+ Because bubbling stops at the scope root, two forms in two popups each get
164
+ their own Enter — something a global registry structurally cannot do. This
165
+ is also why registering Enter globally is refused in step 2: the registry
166
+ would take it away from all of them at once.
167
+
168
+ The one thing this shape asks of you is that the *parent* holds the key →
169
+ child table, rather than each widget declaring its own mnemonic. That's a
170
+ fair trade: which key jumps where is a decision about the assembly, and it
171
+ reads well in one place.
172
+
173
+ ## Where the cursor comes in — and where it doesn't
142
174
 
143
175
  A component signals cursor ownership through
144
176
  {Tuile::Component#cursor_position} — return a `Point` and the terminal
145
- cursor is shown there; return `nil` (the default) and there's no cursor.
146
- A {Tuile::Component::TextField} being edited returns its caret position,
147
- which is non-`nil`, so the screen knows a text widget is mid-edit and
148
- suppresses shortcut capture. The "d" flows straight through step 3 to
149
- step 4, where the focused field's `handle_key` inserts it. Tab (step 1)
150
- still works, because it's reserved above all this — so you can always Tab
151
- out of the field, at which point the cursor goes away and shortcuts light
152
- back up.
153
-
154
- That's the entire mechanism: printable keys belong to whoever owns the
155
- cursor, and owning the cursor mutes the sibling shortcuts that would
156
- otherwise pick them off.
177
+ cursor is shown there; return `nil` (the default) and there's no cursor. A
178
+ {Tuile::Component::TextField} being edited returns its caret position, so
179
+ the caret you see blinking is the focused component's answer to that one
180
+ question.
181
+
182
+ That's all it does. It positions the hardware cursor; it does not route
183
+ keys. Tuile briefly used it as a proxy for "this widget is in text-entry
184
+ mode, don't steal its printable keys" — a signal the deleted fourth rung
185
+ needed. With dispatch resting on nothing but "did you return `true`," the
186
+ proxy is gone, and a component's decision to consume a key is the only
187
+ declaration in the system.
157
188
 
158
189
  ## The status bar writes itself
159
190
 
data/book/06-theming.md CHANGED
@@ -38,6 +38,55 @@ colors plus an app-extensible `custom` hash — and that's all. Two are
38
38
  built in: {Tuile::Theme::DARK}, the colors Tuile has always used, and
39
39
  {Tuile::Theme::LIGHT}, counterparts legible on a pale background.
40
40
 
41
+ ## Backgrounds are opt-in
42
+
43
+ The stance above — paint accents, leave the rest to the terminal — is how
44
+ the *framework* paints. But sometimes your *app* wants a real background:
45
+ an overlay panel, a slash-command menu, a dropdown that has to read as one
46
+ solid tinted block floating over the content beneath it — filler rows
47
+ included, not a ragged half-shaded box. That is {Tuile::Component#bg_color}.
48
+
49
+ Set it on a component and that component paints a background behind
50
+ everything it draws; leave it unset (the default) and you get the
51
+ terminal default, exactly as before. The useful part is that it
52
+ **inherits**: set `bg_color` once on a container — a
53
+ {Tuile::Component::Popup}, a layout, a window — and every descendant that
54
+ hasn't set its own picks it up. You tint the panel, and the labels and
55
+ lists inside come out on the same tint without being told individually.
56
+
57
+ That inheritance has to be *manufactured*, because a terminal has no
58
+ transparency: every cell holds exactly one background, and painting a
59
+ glyph replaces the whole cell (chapter 2's opaque grid). So Tuile resolves
60
+ the effective background at paint time by walking up to the nearest
61
+ ancestor that set a `bg_color` — the same read-at-paint discipline the
62
+ theme uses, and for the same reason: change a container's background and
63
+ its subtree is invalidated and simply repaints in the new color. The
64
+ terminal default is just the root of that chain, which is why an unset
65
+ `bg_color` everywhere behaves exactly like it always has.
66
+
67
+ A widget with a background of its *own* keeps it. A text field paints its
68
+ well across its whole rect, so dropping one into a tinted panel shows the
69
+ field in its own well, not the panel tint — the explicit background wins
70
+ over the inherited one, the terminal equivalent of a CSS element that sets
71
+ its own `background`.
72
+
73
+ `bg_color` takes either a concrete {Tuile::Color} or a *live theme
74
+ reference* — `Theme.ref(:panel_bg)` — that names one of your app's custom
75
+ tokens and re-resolves it against the current theme on every paint. The
76
+ reference is the ergonomic path: assign it once and the panel follows
77
+ light and dark on its own, with no `on_theme_changed` handler. That works
78
+ precisely because a background — unlike the baked-in colors of your
79
+ *content* (below) — is resolved *live* at paint, exactly like the
80
+ framework's own accents; `bg_color` is a single value read late, so
81
+ late-binding it to a token costs nothing. It is the one place an app color
82
+ tracks the theme without the hook.
83
+
84
+ A reference reaches your *custom* tokens only, never a framework-imposed
85
+ global, so the "no global background token" line holds either way: the
86
+ framework still paints no background of its own. You opt in per
87
+ component — a concrete `Color` when you want it fixed, a `Theme.ref` when
88
+ you want it to follow the scheme.
89
+
41
90
  ## Read at paint time, never cached
42
91
 
43
92
  There is one rule about *using* the theme that everything else depends
@@ -207,7 +256,10 @@ declaration site says nothing.
207
256
 
208
257
  The built-in components restyle for free because they read the theme at
209
258
  paint time. Your *content* can't always do that — and this is the one
210
- theming responsibility that lands on the app.
259
+ theming responsibility that lands on the app. (A *background* is the
260
+ exception: `bg_color = Theme.ref(:token)` tracks the theme live with no
261
+ handler, because it's resolved at paint — see "Backgrounds are opt-in"
262
+ above. The hook below is for baked-in *content* colors.)
211
263
 
212
264
  The problem: when you build a {Tuile::StyledString} for a
213
265
  {Tuile::Component::Label} or a {Tuile::Component::List} row, its colors