tuile 0.14.0 → 0.16.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -49
  3. data/README.md +53 -17
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +152 -22
  6. data/book/06-theming.md +105 -25
  7. data/book/07-components.md +531 -50
  8. data/book/08-testing.md +100 -20
  9. data/book/10-locale.md +216 -0
  10. data/book/README.md +19 -9
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +654 -40
  14. data/lib/tuile/component/abstract_string_field.rb +114 -68
  15. data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
  16. data/lib/tuile/component/big_decimal_field.rb +52 -79
  17. data/lib/tuile/component/button.rb +8 -8
  18. data/lib/tuile/component/checkbox.rb +9 -9
  19. data/lib/tuile/component/checkbox_group.rb +38 -21
  20. data/lib/tuile/component/combo_box.rb +102 -59
  21. data/lib/tuile/component/confirm_window.rb +7 -5
  22. data/lib/tuile/component/date_field.rb +347 -0
  23. data/lib/tuile/component/date_time_field.rb +275 -0
  24. data/lib/tuile/component/float_field.rb +57 -82
  25. data/lib/tuile/component/has_bad_input.rb +88 -0
  26. data/lib/tuile/component/has_caption.rb +8 -0
  27. data/lib/tuile/component/has_content.rb +32 -13
  28. data/lib/tuile/component/has_placeholder.rb +62 -0
  29. data/lib/tuile/component/has_validation.rb +115 -0
  30. data/lib/tuile/component/has_value.rb +28 -1
  31. data/lib/tuile/component/integer_field.rb +51 -78
  32. data/lib/tuile/component/label.rb +7 -39
  33. data/lib/tuile/component/layout/box.rb +90 -19
  34. data/lib/tuile/component/layout.rb +15 -5
  35. data/lib/tuile/component/list.rb +53 -38
  36. data/lib/tuile/component/list_dropdown.rb +7 -3
  37. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  38. data/lib/tuile/component/menu_bar.rb +18 -18
  39. data/lib/tuile/component/notification.rb +32 -18
  40. data/lib/tuile/component/overlay.rb +26 -8
  41. data/lib/tuile/component/picker_window.rb +27 -8
  42. data/lib/tuile/component/popup.rb +2 -2
  43. data/lib/tuile/component/progress_bar.rb +10 -4
  44. data/lib/tuile/component/radio_group.rb +41 -23
  45. data/lib/tuile/component/select.rb +23 -16
  46. data/lib/tuile/component/slot.rb +3 -3
  47. data/lib/tuile/component/tab_sheet.rb +6 -6
  48. data/lib/tuile/component/tabs.rb +11 -11
  49. data/lib/tuile/component/text_area.rb +26 -18
  50. data/lib/tuile/component/text_field.rb +55 -26
  51. data/lib/tuile/component/text_view.rb +40 -19
  52. data/lib/tuile/component/time_field.rb +479 -0
  53. data/lib/tuile/component/window.rb +26 -13
  54. data/lib/tuile/component.rb +635 -131
  55. data/lib/tuile/event_queue.rb +4 -4
  56. data/lib/tuile/fake_event_queue.rb +1 -1
  57. data/lib/tuile/fake_screen.rb +95 -3
  58. data/lib/tuile/final.rb +75 -0
  59. data/lib/tuile/locale.rb +851 -0
  60. data/lib/tuile/mouse/router.rb +217 -0
  61. data/lib/tuile/mouse.rb +177 -0
  62. data/lib/tuile/screen.rb +219 -68
  63. data/lib/tuile/screen_pane.rb +51 -42
  64. data/lib/tuile/styled_string.rb +5 -5
  65. data/lib/tuile/testing.rb +198 -0
  66. data/lib/tuile/theme.rb +110 -32
  67. data/lib/tuile/version.rb +1 -1
  68. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  69. data/lib/tuile.rb +1 -0
  70. data/sig/tuile.rbs +4595 -825
  71. metadata +14 -9
  72. data/COMPARISON.md +0 -101
  73. data/DECISIONS.md +0 -5422
  74. data/TERMINOLOGY.md +0 -71
  75. data/ideas/arrow-key-navigation.md +0 -221
  76. data/ideas/modal-backdrop.md +0 -24
  77. data/ideas/new-components.md +0 -124
  78. data/ideas/per-component-buffers.md +0 -55
  79. data/lib/tuile/mouse_event.rb +0 -68
data/book/08-testing.md CHANGED
@@ -95,39 +95,119 @@ lives in the buffer; cursor behavior lives in `prints`. Keeping the two
95
95
  apart is deliberate, and mixing them up is the most common way a first
96
96
  Tuile test goes wrong.
97
97
 
98
+ ## Finding the component to drive
99
+
100
+ Asserting on the buffer needs a `rect`; driving a component needs the
101
+ component itself. For a two-line test you have it already — you just built
102
+ it. The awkward case is the one that shows up as soon as an app grows: the
103
+ widget you want to poke is four levels down inside something a *builder
104
+ method* assembled, and the test never held a reference to it.
105
+
106
+ {Tuile::Testing} is the answer. `Testing.get` walks the tree and returns the
107
+ one component matching a spec:
108
+
109
+ ```ruby
110
+ Testing.get(Component::Button, caption: "Save").handle_key?(Keys::ENTER)
111
+ Testing.get(id: :amount).value = 42
112
+ ```
113
+
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:
118
+ `Testing.find(Component::HasBadInput)` finds every field in the tree whose
119
+ parse can fail, whatever their classes.
120
+
121
+ The `id` in that second line is a plain `Symbol` tag you set on any
122
+ component, purely so a test can ask for it back:
123
+
124
+ ```ruby
125
+ amount = Component::IntegerField.new
126
+ amount.id = :amount # nothing paints this, and nothing reads it
127
+ ```
128
+
129
+ `get` is strict on purpose: it raises unless *exactly one* component
130
+ matches. That is the whole feature. The obvious thing to write by hand is a
131
+ walk that takes the first match —
132
+
133
+ ```ruby
134
+ combo = nil
135
+ window.walk_tree { |c| combo ||= c if c.is_a?(Component::ComboBox) }
136
+ ```
137
+
138
+ — and the day the pane grows a second `ComboBox`, that silently re-points
139
+ your test at a different widget. Nothing fails; the assertions just start
140
+ describing something else. `get` calls that ambiguity what it is, and the
141
+ failure comes with a dump of the tree it searched:
142
+
143
+ ```
144
+ expected 1 Component::ComboBox, found 2
145
+ searched:
146
+ #<ScreenPane rect=(0,0 160x50)>
147
+ #<Window rect=(0,0 40x10) caption="Settings">
148
+ → #<ComboBox rect=(1,1 38x1) value=nil>
149
+ → #<ComboBox rect=(1,2 38x1) value="dark">
150
+ ```
151
+
152
+ Which usually tells you the fix immediately: narrow the search. Every
153
+ lookup takes `in:` to scope it to a subtree, and by default searches the
154
+ whole screen — popups included, since chapter 1's pane holds them under the
155
+ same root as the content.
156
+
157
+ ```ruby
158
+ Testing.get(Component::ComboBox, in: sampler.demo_window)
159
+ ```
160
+
161
+ Its sibling `Testing.find` returns *all* matches as an array, and takes an
162
+ optional `count:` — an Integer for exactly, a Range for a bound — so an
163
+ assertion about how many of something exists is one call:
164
+
165
+ ```ruby
166
+ Testing.find(Component::Checkbox, in: form, count: 3) # raises unless 3
167
+ ```
168
+
169
+ Two habits worth forming. Call these qualified, as `Testing.get(...)`:
170
+ `find` and `get` are the most collision-prone names in a spec suite, so
171
+ Tuile deliberately neither installs them on `Component` nor asks you to mix
172
+ the module in. And remember this is *additive* — it makes driving a tree
173
+ terser, and changes nothing about the assertion channel. What a component
174
+ *shows* is still asserted on the buffer.
175
+
98
176
  ## Driving the system
99
177
 
100
178
  There are two altitudes at which you feed input, and picking the right one
101
179
  is most of writing a good Tuile test.
102
180
 
103
- **Low: call the component directly.** {Tuile::Component#handle_key} and
104
- `handle_mouse` are public, and calling them straight tests a component's
105
- own logic in isolation — no focus, no dispatch, just "given this key, does
106
- the list move its cursor?" `handle_key` returns whether it consumed the
107
- key, so you assert on that too:
181
+ **Low: call the component directly.** {Tuile::Component#handle_key?} and the
182
+ `handle_mouse_*` handlers are public, and calling one straight tests a
183
+ component's own logic in isolation — no focus, no dispatch, just "given this
184
+ key, does the list move its cursor?" Both answer whether they consumed the
185
+ event, so you assert on that too:
108
186
 
109
187
  ```ruby
110
- list.handle_key(Keys::DOWN_ARROW) # exercises the cursor directly
188
+ list.handle_key?(Keys::DOWN_ARROW) # exercises the cursor directly
189
+ list.handle_mouse_scroll?(Mouse::ScrollEvent.new(:up, 5, 2)) # false at the top: an ancestor gets it
111
190
  ```
112
191
 
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:
192
+ **A press, though, wants the high altitude.** It does not only *do* something:
193
+ it focuses, it dismisses popups, and which component it even reaches is the
194
+ router's answer rather than the component's — so calling `handle_mouse_down?`
195
+ by hand tests a third of what a click is. Drive it through
196
+ {Tuile::FakeScreen}, which posts the gesture the terminal would:
118
197
 
119
198
  ```ruby
120
- screen.content = list # a click focuses; focus needs a tree
199
+ screen.content = list # a press focuses; focus needs a tree
121
200
  list.rect = Rect.new(0, 0, 10, 5)
122
- list.handle_mouse(MouseEvent.new(:left, 5, 2))
201
+ screen.click(5, 2) # press then release, at that cell
123
202
  ```
124
203
 
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.
204
+ `click` is the whole gesture; `press` / `release` are its halves, for a test
205
+ about what the grab does in between, and `scroll` / `move` post the other two
206
+ events. They all take screen-absolute, 0-based coordinates, so a test asserts
207
+ against the rect it assigned — and a press on a cell no component covers
208
+ simply does nothing.
129
209
 
130
- **High: go through the pane.** {Tuile::ScreenPane#handle_key} runs the
210
+ **High: go through the pane.** {Tuile::ScreenPane#handle_key?} runs the
131
211
  dispatch rung from chapter 5 that routing is actually about: delivery to
132
212
  {Tuile::Screen#focused}, then the bubble up its ancestor chain to the scope
133
213
  root. So when your test is about routing — that a layout's one-key pane jump
@@ -137,11 +217,11 @@ drive the pane and let the real machinery run:
137
217
 
138
218
  ```ruby
139
219
  screen.focused = list # focus as production does — or list.focus
140
- assert screen.pane.handle_key("1") # the layout's ancestor binding fires
220
+ assert screen.pane.handle_key?("1") # the layout's ancestor binding fires
141
221
  ```
142
222
 
143
223
  The two rungs *above* the pane have their own doors, because `Screen`'s own
144
- `handle_key` — the top of the ladder — is private: it belongs to the key
224
+ `handle_key?` — the top of the ladder — is private: it belongs to the key
145
225
  thread, not to app code. Tab cycling is {Tuile::Screen#focus_next} /
146
226
  `focus_previous`, both already scoped to the topmost modal popup, which is
147
227
  what "a popup traps Tab" means. A global shortcut is a block you registered,
data/book/10-locale.md ADDED
@@ -0,0 +1,216 @@
1
+ # 10. Locale: the conventions, not the words
2
+
3
+ Chapter 6 told one story about adapting to the user's environment: the
4
+ terminal reports whether it is light or dark, Tuile picks a matching
5
+ {Tuile::Theme}, and the whole UI restyles. This chapter tells the same
6
+ story about a different fact — not what the terminal looks like, but how
7
+ the *person* in front of it expects a date and a number to be spelled.
8
+
9
+ The two are shaped alike on purpose. A frozen value type, detected once
10
+ at startup, held on the screen, read at use time, replaceable at
11
+ runtime, with a hook for anything that baked the old answer in. If you
12
+ have read chapter 6, you already know the shape; what is worth your time
13
+ here is the *boundary* — because this is the feature most likely to grow
14
+ into something Tuile is not.
15
+
16
+ ## The one rule: conventions, never prose
17
+
18
+ > A {Tuile::Locale} holds formatting conventions — how a value is
19
+ > rendered and parsed. It never holds prose.
20
+
21
+ That sentence is the whole design. It is what makes `Locale` a bag of
22
+ about eight members rather than an internationalization subsystem, and
23
+ it is the test a ninth member has to pass.
24
+
25
+ The distinction is easy to feel once you see the two halves side by
26
+ side. "Dates in this session are spelled `dd.mm.yyyy`" is a convention:
27
+ it is a *rule* about rendering, it applies to every date the app will
28
+ ever show, and Tuile can obey it without knowing anything about your
29
+ app. "The date you typed is not valid" is prose: it is one sentence, in
30
+ one language, that belongs to one widget, and translating it is a job
31
+ with a catalogue, interpolation, and pluralization behind it. Tuile does
32
+ the first and stays out of the second — a component's wording stays the
33
+ component's own, settable where it matters.
34
+
35
+ And the line is not Tuile's invention. POSIX drew it first: `LC_MESSAGES`
36
+ carries the language your programs speak, `LC_TIME` and `LC_NUMERIC`
37
+ carry the formatting conventions. Tuile reads the formatting categories
38
+ and never the message one. That is why a session can coherently ask for
39
+ an English UI *and* ISO dates *and* a decimal comma — three categories,
40
+ three independent answers, which is exactly what one of your author's
41
+ machines is configured to want.
42
+
43
+ ## What is in one
44
+
45
+ ```ruby
46
+ locale = Tuile::Screen.instance.locale
47
+
48
+ locale.date_formats # => ["%Y-%m-%d"] strftime patterns, primary first
49
+ locale.time_formats # => ["%H:%M:%S"] ditto, at full precision
50
+ locale.calendar_start # => Date::GREGORIAN
51
+ locale.first_weekday # => 1 Monday, in Date#wday numbering
52
+ locale.month_names[9] # => "September"
53
+ locale.abbr_day_names[1] # => "Mon"
54
+ locale.decimal_separator # => "."
55
+ ```
56
+
57
+ Two things about those last few are worth pausing on.
58
+
59
+ **The name tables are keyed by the `Date` accessor that reads them.**
60
+ `month_names[date.month]`, `day_names[date.wday]` — so you never do
61
+ arithmetic to look a name up. That is why the two have different shapes:
62
+ `Date#month` is 1-based, so the month tables are `Hash`es keyed `1..12`;
63
+ `Date#wday` is 0-based, so the day tables are plain seven-element
64
+ arrays. It looks like an inconsistency and is the opposite of one. A
65
+ 0-based month array would answer `month_names[9] # => "October"` — a
66
+ *plausible wrong answer*, silently, in the calendar header of a
67
+ month-view someone ships. The `Hash` makes that mistake impossible to
68
+ express instead of merely documented against.
69
+
70
+ **`date_formats` is a list, and the first entry is special.** Parsing
71
+ tries them in order and the first whole match wins; the primary —
72
+ `formats.first` — is also what gets *written* when a value is assigned or
73
+ a loosely typed buffer is canonicalized. Lenient in, strict out. Which
74
+ means only the primary has to survive a round-trip; a later entry only
75
+ ever parses, so it is allowed to be lossy. Chapter 7 has the field-side
76
+ story; {Tuile::Component::DateField} has the details.
77
+
78
+ **`time_formats` keeps its seconds, and the field is what drops them.**
79
+ This is the one member whose detected value is deliberately *more* than
80
+ any field shows. glibc's `t_fmt` — where the conventions come from — is a
81
+ **clock display** format, so it carries seconds nearly everywhere:
82
+ `%H:%M:%S` under C, `%H.%M.%S` under Finnish. Honoring that in a form
83
+ would put `:00` in front of every user who never asked for it, which is
84
+ what WinForms and Ant Design both actually ship.
85
+
86
+ So the seconds are dropped — but *in the field*, not here, and the
87
+ distinction is the whole point. Dropping them is a **policy**, and a
88
+ lossy one: a status-bar clock or a log timestamp legitimately wants
89
+ `13:45:30`, and if the locale had already thrown the seconds away, it
90
+ would have no way to ask for them back. A convention that carries less
91
+ than the system said is not a convention any more. So the locale keeps
92
+ the full spelling, {Tuile::Component::TimeField} reduces it to whatever
93
+ its `step` calls for, and a consumer that wants the other answer still
94
+ has one.
95
+
96
+ What *does* happen at the boundary is expansion: libc's shorthands are
97
+ rewritten into their parts (`%T` → `%H:%M:%S`, `%r` → `%I:%M:%S %p`) so
98
+ no consumer has to know them. That is a representation change and
99
+ nothing more, which is the line between what normalizes here and what
100
+ does not.
101
+
102
+ ## Detection: ask the system, and only when it was asked
103
+
104
+ Ruby exposes no locale data at all. There is no `nl_langinfo` binding,
105
+ nothing on `Date`, nothing in `Etc`; `Date::MONTHNAMES` is frozen
106
+ English under every `LC_ALL` you set, and `strftime("%x")` is a fixed
107
+ American `%m/%d/%y` that merely *looks* like a locale channel. So
108
+ {Tuile::Locale.system} shells out to `locale -k` — one subprocess of
109
+ about a millisecond, whose answers are already strftime patterns, which
110
+ is Tuile's own vocabulary rather than a second grammar to translate.
111
+
112
+ The interesting part is not the subprocess. It is the gate in front of
113
+ it.
114
+
115
+ The C/POSIX default date format is American. That means "the user said
116
+ nothing" and "the user wants `%m/%d/%y`" are *indistinguishable* from
117
+ the answer — so a container with no `LANG` set would confidently show
118
+ American dates to a Norwegian. Tuile therefore detects only when the
119
+ environment actually said something: if the POSIX chain for a category
120
+ is empty, or names `C` or `POSIX`, the probe's answer for that category
121
+ is thrown away and {Tuile::Locale::ISO} stands.
122
+
123
+ And the gate is *per category*, which is the part that surprises people.
124
+ Gating once on "any locale variable is set" is wrong on a real machine:
125
+ a session exporting only `LC_NUMERIC=de_DE.UTF-8` would open the gate,
126
+ and then `d_fmt` — resolving through an unset `LC_TIME` and an unset
127
+ `LANG` — comes back American. So the date conventions are kept only if
128
+ `LC_ALL` / `LC_TIME` / `LANG` speaks, and the numeric one only if
129
+ `LC_ALL` / `LC_NUMERIC` / `LANG` does. One subprocess, two gates.
130
+
131
+ Everything else about detection is defensive, because none of it can be
132
+ allowed to fail loudly at startup: `locale(1)`'s exit status is useless
133
+ in both directions (a bad locale name exits 0 and quietly returns C; an
134
+ unknown keyword exits 1 while printing every good key), so the status is
135
+ ignored and each value is validated on its own. Anything that does not
136
+ hold up falls back to its `ISO` member individually — not
137
+ all-or-nothing. A missing binary, a Windows box, a musl container: `ISO`,
138
+ no exception raised.
139
+
140
+ ## The floor is ISO 8601, and that is an argument
141
+
142
+ {Tuile::Locale::ISO} is the only constant Tuile ships. There is no
143
+ `EN_US`, no `DE`, and there will not be: two presets are a catalogue, a
144
+ catalogue implies completeness, and completeness is a promise about the
145
+ world's date conventions that a TUI toolkit has no business making. You
146
+ build your own from `ISO`:
147
+
148
+ ```ruby
149
+ screen.locale = Tuile::Locale::ISO.with(date_formats: ["%d.%m.%Y", "%Y-%m-%d"])
150
+ ```
151
+
152
+ Every member is validated in the constructor, and `Data#with` re-runs the
153
+ constructor — so an invalid `Locale` is unreachable by either route.
154
+
155
+ `ISO` is a defensible floor rather than a default someone picked, because
156
+ three of its members cite the same standard: ISO 8601 dates, an ISO 8601
157
+ week starting Monday, and the proleptic Gregorian calendar ISO 8601
158
+ mandates. The names are Ruby's own frozen English tables, re-keyed but
159
+ not authored — so Tuile ships zero locale data of its own, even in the
160
+ fallback. And the decimal separator is `"."` not because ISO says so (ISO
161
+ 31-0 rather prefers the comma) but because `Float#to_s` writes a dot and
162
+ a field's `value=` goes through it: any other floor would make a numeric
163
+ field disagree with itself before you configured anything.
164
+
165
+ ## Fields follow the session unless you say otherwise
166
+
167
+ A {Tuile::Component::DateField} takes both of its conventions from
168
+ `Screen#locale` until you assign them. So the common cases are one line
169
+ each, and they compose:
170
+
171
+ ```ruby
172
+ screen.locale = Locale::ISO.with(date_formats: ["%d.%m.%Y"]) # every field
173
+ field.formats = "%Y-%m-%d" # this one only
174
+ field.formats = nil # follow again
175
+ ```
176
+
177
+ `nil` restoring inheritance is the same shape `placeholder=` and
178
+ `bg_color=` already use, and it is what keeps the two decisions
179
+ independent: overriding one field is not opting it out of the session
180
+ forever.
181
+
182
+ ## When the locale changes under you
183
+
184
+ {Tuile::Screen#locale=} fires {Tuile::Component#handle_locale_changed}
185
+ across the attached tree and then invalidates all of it — the same
186
+ machinery {Tuile::Screen#theme=} uses, for the same reason.
187
+
188
+ Because everything repaints, anything your code *pulls* at paint or parse
189
+ time needs no hook at all. A date field parses its buffer on read; a
190
+ calendar grid would look month names up while painting. Both simply come
191
+ out right on the next frame.
192
+
193
+ The hook is for state you *pushed* somewhere when you last read the
194
+ conventions. A date field's typing hint is the worked example: it lives
195
+ in its editor's `placeholder`, written when the formats were last set, so
196
+ a repaint alone would faithfully repaint the stale `dd.mm.yyyy`. The
197
+ field overrides `handle_locale_changed` to re-derive it — and, while it is
198
+ there, to rewrite a buffer that still parses into the new primary format.
199
+ Your own code does the same for a date you rendered into a
200
+ {Tuile::Component::Label}, either by overriding the hook or by assigning
201
+ the listener:
202
+
203
+ ```ruby
204
+ label.on_locale_changed = -> { label.text = due.strftime(screen.locale.date_formats.first) }
205
+ ```
206
+
207
+ One consequence to accept rather than defend against: a field holding a
208
+ half-typed buffer when the grammar changes under it may stop parsing, and
209
+ will then read as bad input. That is correct — the text is still there,
210
+ the user can see it, and they can fix it. A locale change is a
211
+ once-a-session event, not something worth contorting a field to survive.
212
+
213
+ The other rule inherited from chapter 6 applies unchanged: **read the
214
+ locale at use time; never cache it in an ivar.** A cached one strands on
215
+ the old conventions the moment someone assigns a new locale, and nothing
216
+ raises to tell you.
data/book/README.md CHANGED
@@ -29,9 +29,10 @@ theming (including live OS light/dark flips). Chapters 7–8 close out
29
29
  **narratively** — a tour of the shipped component toolbox framed around
30
30
  when and why to reach for each, and how to test a Tuile app end to end.
31
31
  Those two lean on the rdoc for the exact APIs; the guide keeps to the
32
- walkthroughs and use-cases. Chapter 9 is a **deep dive** on
33
- `Tuile::StyledString`, the text primitive under everything the framework
34
- draws — read it when you start rendering your own styled content.
32
+ walkthroughs and use-cases. Chapters 9–10 are **deep dives** on one
33
+ piece each, to read when you need them: `Tuile::StyledString`, the text
34
+ primitive under everything the framework draws, and `Tuile::Locale`, the
35
+ formatting conventions a date or a number is spelled by.
35
36
 
36
37
  The book grows organically — a chapter exists when a concept has earned
37
38
  one, not to fill an outline.
@@ -64,26 +65,35 @@ one, not to fill an outline.
64
65
  same queue rather than handled off the signal.
65
66
  5. **[Focus and the keyboard](05-focus.md).** The focus chain and
66
67
  `focusable?`, and the three-rung order in which a keystroke is offered
67
- to the tree — Tab, global shortcuts, then `handle_key` delivered to
68
+ to the tree — Tab, global shortcuts, then `handle_key?` delivered to
68
69
  focus and bubbling up its ancestors. Why scope-wide keys (pane jumps, a
69
70
  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
+ 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.
72
74
  6. **[Theming](06-theming.md).** Semantic color tokens read at paint
73
75
  time, opt-in component backgrounds that inherit down the tree
74
76
  (`bg_color`), light/dark auto-detection at startup and live OS
75
77
  appearance flips, pairing variants in a `ThemeDef`, app-specific custom
76
- tokens, and rebuilding theme-derived content in `on_theme_changed`.
78
+ tokens, and rebuilding theme-derived content in `handle_theme_changed`.
77
79
  7. **[The component library](07-components.md).** A narrative tour of
78
80
  the shipped toolbox — the text inputs and views, the value fields, the
79
81
  selectors, Button, ProgressBar, Window, TabSheet, MenuBar, Popup and the
80
82
  window conveniences — framed around *when and why* you reach for each.
81
83
  Signatures stay in the rdoc.
82
84
  8. **[Testing a Tuile app](08-testing.md).** The testing approach:
83
- `FakeScreen`, asserting against the painted buffer, driving
84
- invalidation, and PTY-based end-to-end tests of runnable scripts.
85
+ `FakeScreen`, asserting against the painted buffer, locating the
86
+ component to drive with `Tuile::Testing`, driving invalidation, and
87
+ PTY-based end-to-end tests of runnable scripts.
85
88
  9. **[Styled text](09-styled-text.md).** A deep dive on
86
89
  `Tuile::StyledString`, the span-based "text plus styling" value type
87
90
  under everything Tuile draws: why spans instead of a `String` full of
88
91
  escape codes, the style-aware algebra (slice/wrap/concat by display
89
92
  column), minimal-diff rendering, and the strict-vs-lenient parser.
93
+ 10. **[Locale](10-locale.md).** The other environment fact, shaped like
94
+ chapter 6's theme: `Tuile::Locale` holds *formatting conventions and
95
+ never prose*, detected from `locale(1)` only when the environment
96
+ actually asked, with `Locale::ISO` as the floor. Why the name tables
97
+ are keyed by the `Date` accessor that reads them, how a field follows
98
+ the session until you override it, and what `handle_locale_changed` is
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,
@@ -45,7 +53,7 @@ module FileCommanderExample
45
53
  attr_reader :cwd
46
54
  attr_accessor :on_cwd_changed
47
55
 
48
- def handle_key(key)
56
+ def handle_key?(key)
49
57
  return false unless active?
50
58
 
51
59
  if Tuile::Keys::BACKSPACES.include?(key)
@@ -56,7 +64,7 @@ module FileCommanderExample
56
64
  end
57
65
  end
58
66
 
59
- def on_focus
67
+ def handle_focus
60
68
  super
61
69
  @on_cwd_changed&.call
62
70
  end
@@ -135,13 +143,13 @@ module FileCommanderExample
135
143
  # The status line. Every key here works in both panes, so the row never
136
144
  # changes and nothing needs to watch focus — a status line is only worth
137
145
  # 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
146
+ # with the focused component. `theme.fg` bakes its colors in, so the
139
147
  # one thing this label does watch is a light/dark flip.
140
148
  @status = Tuile::Component::Label.new
141
149
  render_status = lambda do
142
150
  t = screen.theme
143
- @status.text = "q #{t.hint("quit")} Tab #{t.hint("Switch")} " \
144
- "Enter #{t.hint("Open")} Bksp #{t.hint("Up")}"
151
+ @status.text = "q #{t.fg(:hint, "quit")} Tab #{t.fg(:hint, "Switch")} " \
152
+ "Enter #{t.fg(:hint, "Open")} Bksp #{t.fg(:hint, "Up")}"
145
153
  end
146
154
  render_status.call
147
155
  @status.on_theme_changed = render_status
@@ -180,6 +188,7 @@ unless File.directory?(start_dir)
180
188
  end
181
189
 
182
190
  screen = Tuile::Screen.new
191
+ screen.theme_def = FileCommanderExample::APP_THEME
183
192
  commander = FileCommanderExample::FileCommander.new(start_dir, start_dir)
184
193
  screen.content = commander
185
194
  commander.left_window.focus
@@ -11,18 +11,31 @@
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
40
  status.on_theme_changed = render_status
28
41