tuile 0.14.0 → 0.15.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/DECISIONS.md +3181 -41
  4. data/README.md +25 -5
  5. data/TERMINOLOGY.md +17 -3
  6. data/book/05-focus.md +63 -2
  7. data/book/06-theming.md +55 -7
  8. data/book/07-components.md +474 -48
  9. data/book/08-testing.md +78 -0
  10. data/book/10-locale.md +216 -0
  11. data/book/README.md +14 -5
  12. data/examples/sampler.rb +265 -25
  13. data/ideas/binder.md +177 -0
  14. data/ideas/composite-field.md +77 -0
  15. data/ideas/focus-accent.md +116 -0
  16. data/ideas/form-layout.md +151 -0
  17. data/ideas/hover/probe.rb +241 -0
  18. data/ideas/hover/probe_spec.rb +82 -0
  19. data/ideas/hover.md +909 -0
  20. data/ideas/new-components.md +26 -6
  21. data/lib/tuile/component/abstract_string_field.rb +106 -58
  22. data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
  23. data/lib/tuile/component/big_decimal_field.rb +52 -79
  24. data/lib/tuile/component/checkbox_group.rb +36 -20
  25. data/lib/tuile/component/combo_box.rb +59 -31
  26. data/lib/tuile/component/date_field.rb +322 -0
  27. data/lib/tuile/component/float_field.rb +57 -82
  28. data/lib/tuile/component/has_bad_input.rb +88 -0
  29. data/lib/tuile/component/has_caption.rb +8 -0
  30. data/lib/tuile/component/has_content.rb +29 -10
  31. data/lib/tuile/component/has_placeholder.rb +62 -0
  32. data/lib/tuile/component/has_validation.rb +115 -0
  33. data/lib/tuile/component/has_value.rb +27 -0
  34. data/lib/tuile/component/integer_field.rb +51 -78
  35. data/lib/tuile/component/label.rb +6 -38
  36. data/lib/tuile/component/layout/box.rb +87 -19
  37. data/lib/tuile/component/layout.rb +13 -3
  38. data/lib/tuile/component/list.rb +11 -6
  39. data/lib/tuile/component/list_dropdown.rb +4 -0
  40. data/lib/tuile/component/overlay.rb +17 -0
  41. data/lib/tuile/component/radio_group.rb +39 -22
  42. data/lib/tuile/component/select.rb +12 -4
  43. data/lib/tuile/component/text_area.rb +14 -8
  44. data/lib/tuile/component/text_field.rb +42 -15
  45. data/lib/tuile/component/text_view.rb +25 -8
  46. data/lib/tuile/component/time_field.rb +454 -0
  47. data/lib/tuile/component/window.rb +26 -13
  48. data/lib/tuile/component.rb +469 -73
  49. data/lib/tuile/fake_screen.rb +11 -1
  50. data/lib/tuile/final.rb +75 -0
  51. data/lib/tuile/locale.rb +851 -0
  52. data/lib/tuile/screen.rb +131 -17
  53. data/lib/tuile/screen_pane.rb +13 -9
  54. data/lib/tuile/testing.rb +198 -0
  55. data/lib/tuile/theme.rb +100 -10
  56. data/lib/tuile/version.rb +1 -1
  57. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  58. data/lib/tuile.rb +1 -0
  59. data/sig/tuile.rbs +3398 -412
  60. metadata +18 -1
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#on_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 `on_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.
@@ -80,10 +81,18 @@ one, not to fill an outline.
80
81
  window conveniences — framed around *when and why* you reach for each.
81
82
  Signatures stay in the rdoc.
82
83
  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.
84
+ `FakeScreen`, asserting against the painted buffer, locating the
85
+ component to drive with `Tuile::Testing`, driving invalidation, and
86
+ PTY-based end-to-end tests of runnable scripts.
85
87
  9. **[Styled text](09-styled-text.md).** A deep dive on
86
88
  `Tuile::StyledString`, the span-based "text plus styling" value type
87
89
  under everything Tuile draws: why spans instead of a `String` full of
88
90
  escape codes, the style-aware algebra (slice/wrap/concat by display
89
91
  column), minimal-diff rendering, and the strict-vs-lenient parser.
92
+ 10. **[Locale](10-locale.md).** The other environment fact, shaped like
93
+ chapter 6's theme: `Tuile::Locale` holds *formatting conventions and
94
+ never prose*, detected from `locale(1)` only when the environment
95
+ actually asked, with `Locale::ISO` as the floor. Why the name tables
96
+ 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
+ for.
data/examples/sampler.rb CHANGED
@@ -109,6 +109,37 @@ module SamplerExample
109
109
  end
110
110
  end
111
111
 
112
+ # A {Tuile::Component::TextArea} that steers a {Tuile::Component::ListDropdown}
113
+ # while it is open: movement keys move the highlight, ENTER accepts and ESC
114
+ # dismisses, and everything else — printables, editing, the ENTER that inserts
115
+ # a newline with no menu up — stays the TextArea's own.
116
+ #
117
+ # Subclassing *is* the seam for this. A component receives keys through
118
+ # `handle_key`, so one that wants different keys overrides it (here its
119
+ # `handle_text_input_key` hook) and calls `super` for the rest, which composes
120
+ # and stacks. None of this is baked into TextArea.
121
+ class SlashCommandTextArea < Tuile::Component::TextArea
122
+ # @param overlay [Tuile::Component::ListDropdown] the menu to steer.
123
+ def initialize(overlay)
124
+ super()
125
+ @overlay = overlay
126
+ end
127
+
128
+ protected
129
+
130
+ def handle_text_input_key(key)
131
+ return super unless @overlay.open?
132
+ return true if @overlay.move(key) # Up/Down/PgUp/PgDn/^U/^D
133
+
134
+ case key
135
+ when Tuile::Keys::ENTER then @overlay.choose
136
+ when Tuile::Keys::ESC then @overlay.close
137
+ else return super
138
+ end
139
+ true
140
+ end
141
+ end
142
+
112
143
  # Top-level sampler component: a shell row across the top — a
113
144
  # {Tuile::Component::MenuBar} of the demos, grouped, and a
114
145
  # {Tuile::Component::ComboBox} jump box at its right end — over one demo
@@ -199,7 +230,11 @@ module SamplerExample
199
230
  Menu.new("Typed", "y", [
200
231
  Entry.new("IntegerField", :build_integer_field, "i"),
201
232
  Entry.new("FloatField", :build_float_field, "f"),
202
- Entry.new("BigDecimalField", :build_big_decimal_field, "b")
233
+ Entry.new("BigDecimalField", :build_big_decimal_field, "b"),
234
+ Entry.new("DateField", :build_date_field, "d"),
235
+ Entry.new("TimeField", :build_time_field, "t"),
236
+ Entry.new("Bad input", :build_bad_input, "a"),
237
+ Entry.new("Validation", :build_validation, "v")
203
238
  ]),
204
239
  Menu.new("Choose", "c", [
205
240
  Entry.new("Checkbox", :build_checkboxes, "c"),
@@ -227,6 +262,7 @@ module SamplerExample
227
262
  Entry.new("Narrow strips", :build_narrow_strips, "n"),
228
263
  Entry.new("Layout", :build_layout, "l"),
229
264
  Entry.new("Background", :build_background, "b"),
265
+ Entry.new("Visibility", :build_visibility, "v"),
230
266
  Entry.new("Focus & Tab", :build_focus_demo, "f")
231
267
  ])
232
268
  ].freeze
@@ -309,10 +345,13 @@ module SamplerExample
309
345
 
310
346
  def build_text_field
311
347
  prompt = Tuile::Component::Label.new
312
- prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work."
348
+ prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work.\n" \
349
+ "While it is empty it shows a placeholder — a hint in ink faint " \
350
+ "enough to miss, which is the point."
313
351
  field = Tuile::Component::TextField.new
352
+ field.placeholder = "dd.mm.yyyy"
314
353
  form do |f|
315
- f.add(prompt, Fixed[1])
354
+ f.add(prompt, Fixed[2])
316
355
  f.add(field, Fixed[1])
317
356
  end
318
357
  end
@@ -408,6 +447,8 @@ module SamplerExample
408
447
  prompt.text = "Tab here, then type digits (and a leading -). Non-digits are ignored.\n" \
409
448
  "Up/Down step the value by one; an empty field counts as 0."
410
449
  field = Tuile::Component::IntegerField.new
450
+ # Set on the composed field, not on the TextField inside it.
451
+ field.placeholder = "1-65535"
411
452
  status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
412
453
  field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
413
454
  form do |f|
@@ -454,6 +495,185 @@ module SamplerExample
454
495
  end
455
496
  end
456
497
 
498
+ # DateField: several formats in, one format out. Type a date in any of the
499
+ # three spellings this pane accepts and Tab away — the field rewrites it in
500
+ # the *first* one, which is how the user sees that it understood them. Both
501
+ # halves of the settling rule are visible here too: nothing reddens while a
502
+ # date is being typed, and a buffer that never parsed reddens on the way
503
+ # out. The echo row is deliberately driven by `on_value_change` alone, so
504
+ # garbage leaves it stale: bad_input? is a *pull*, and the button is the "a
505
+ # form asks once, at the click" gesture that consults it (it also gives Tab
506
+ # somewhere to go, which is what makes both behaviours visible at all).
507
+ def build_date_field
508
+ prompt = Tuile::Component::Label.new
509
+ prompt.text = "Tab here, then type 4.9.2026 — or 09/04/2026, or 2026-09-04.\n" \
510
+ "Tab away or press Enter and a buffer that parses is rewritten as yyyy-mm-dd;\n" \
511
+ "one that doesn't is left as you typed it, and *then* the well goes red.\n" \
512
+ "Up/Down step a day, and an empty field steps to today."
513
+ field = Tuile::Component::DateField.new
514
+ field.formats = ["%Y-%m-%d", "%d.%m.%Y", "%m/%d/%Y"]
515
+ status = Tuile::Component::Label.new
516
+ # to_s, not inspect: Date#inspect spells out the Julian day and the
517
+ # calendar reform, which is noise next to the one fact this row is for.
518
+ report = -> { status.text = "value: #{field.value&.to_s || "nil"} bad_input?: #{field.bad_input?}" }
519
+ report.call
520
+ field.on_value_change = ->(_value) { report.call }
521
+ ask = Tuile::Component::Button.new("Ask again") { report.call }
522
+ form do |f|
523
+ f.add(prompt, Fixed[4])
524
+ f.add(field, Fixed[1], cross: Fixed[20])
525
+ f.add(status, Fixed[1])
526
+ f.add(ask, Fixed[1], cross: Fixed[button_width(ask)])
527
+ end
528
+ end
529
+
530
+ # TimeField: one knob for two things, and the reason it is one. Both fields
531
+ # below sit under a *Finnish* spelling that this pane installs on the
532
+ # screen, because the whole point is invisible under ISO: switching
533
+ # precision must not cost you the locale's separator. So the left field
534
+ # shows 13.45 and the right 13.45.00 — a dot either way, which a per-field
535
+ # format override could not have managed.
536
+ def build_time_field
537
+ # Assigned here rather than detected, so the pane demonstrates the same
538
+ # thing on an American box as on a Finnish one.
539
+ Tuile::Screen.instance.locale = Tuile::Locale::ISO.with(time_formats: ["%H.%M.%S", "%H:%M:%S"])
540
+ prompt = Tuile::Component::Label.new
541
+ prompt.text = "The session spells times the Finnish way (13.45). Tab into either field.\n" \
542
+ "Up/Down step a minute on the left, a second on the right (PageUp/PageDown an hour in both) —\n" \
543
+ "one knob, because precision is a property of the format the buffer holds.\n" \
544
+ "Note the dot survives the switch: that is what a per-field format would cost."
545
+ minutes = time_field_with(step: 60)
546
+ seconds = time_field_with(step: 1)
547
+ status = Tuile::Component::Label.new
548
+ report = lambda do
549
+ status.text = "minutes: #{minutes.value&.strftime("%H:%M:%S") || "nil"} (#{minutes.formats.first}) " \
550
+ "seconds: #{seconds.value&.strftime("%H:%M:%S") || "nil"} (#{seconds.formats.first})"
551
+ end
552
+ report.call
553
+ [minutes, seconds].each { _1.on_value_change = ->(_value) { report.call } }
554
+ form do |f|
555
+ f.add(prompt, Fixed[4])
556
+ f.add(labelled("Minute stride", minutes, field_width: 12), Fixed[1], cross: Fixed[30])
557
+ f.add(labelled("Second stride", seconds, field_width: 12), Fixed[1], cross: Fixed[30])
558
+ f.add(status, Fixed[1])
559
+ end
560
+ end
561
+
562
+ # @param step [Integer]
563
+ # @return [Tuile::Component::TimeField]
564
+ def time_field_with(step:)
565
+ Tuile::Component::TimeField.new.tap do |field|
566
+ field.step = step
567
+ field.set_to(13, 45)
568
+ end
569
+ end
570
+
571
+ # HasBadInput: the one fact on_value_change cannot carry. Type a lone "-"
572
+ # and watch the echo row stay silent — the value was nil before and is nil
573
+ # after, so there is no diff to report — while Save, which asks bad_input?
574
+ # instead of empty?, refuses and names the field.
575
+ def build_bad_input
576
+ prompt = Tuile::Component::Label.new
577
+ prompt.text = "Type a lone '-' into Amount (or '1e' into Rate), then press Save.\n" \
578
+ "Both read value: nil and empty?: true, exactly like an untouched field —\n" \
579
+ "which is why a form must ask bad_input? before it saves a nil over your work."
580
+ # The one pane that tags its widgets with `id`, so the sampler's own spec
581
+ # can drive them through Tuile::Testing.get by name.
582
+ amount = Tuile::Component::IntegerField.new.tap { _1.id = :amount }
583
+ rate = Tuile::Component::FloatField.new.tap { _1.id = :rate }
584
+ echo = Tuile::Component::Label.new.tap { _1.text = "on_value_change: (nothing yet)" }
585
+ amount.on_value_change = ->(v) { echo.text = "on_value_change: amount = #{v.inspect}" }
586
+ rate.on_value_change = ->(v) { echo.text = "on_value_change: rate = #{v.inspect}" }
587
+ save = Tuile::Component::Button.new("Save") { save_form("Amount" => amount, "Rate" => rate) }
588
+ save.id = :save
589
+ rows = group do |g|
590
+ g.add(labelled("Amount", amount), Fixed[1])
591
+ g.add(labelled("Rate", rate), Fixed[1])
592
+ end
593
+ form do |f|
594
+ f.add(prompt, Fixed[3])
595
+ f.add(rows, Fixed[2])
596
+ f.add(echo, Fixed[1])
597
+ f.add(save, Fixed[1], cross: Fixed[button_width(save)])
598
+ end
599
+ end
600
+
601
+ # The login form of `D_has_validation`: the click writes a verdict onto
602
+ # each field and the field paints itself red, while *this* pane owns the
603
+ # cells the messages go in — one Label per field, refilled from
604
+ # `on_error_message_change`. Note the handler sets *or clears* on every
605
+ # pass, which is the whole writer discipline; and that no field computes
606
+ # anything, so nothing here fights the fields' own `bad_input?`.
607
+ def build_validation
608
+ prompt = Tuile::Component::Label.new
609
+ prompt.text = "Press Log in empty, then with a 2-letter username: the message lands in\n" \
610
+ "this pane's cells (via a listener) and the field's well turns red.\n" \
611
+ "Tab between them — an invalid field still shows which one has focus."
612
+ username = Tuile::Component::TextField.new
613
+ password = Tuile::Component::PasswordField.new
614
+ fields = { "Username" => username, "Password" => password }
615
+ rows = group do |g|
616
+ fields.each { |caption, field| g.add(validated_row(caption, field), Fixed[1]) }
617
+ end
618
+ login = Tuile::Component::Button.new("Log in") { validate_login(fields) }
619
+ form do |f|
620
+ f.add(prompt, Fixed[3])
621
+ f.add(rows, Fixed[2])
622
+ f.add(login, Fixed[1], cross: Fixed[button_width(login)])
623
+ end
624
+ end
625
+
626
+ # @param caption [String]
627
+ # @param field [Tuile::Component] a field carrying {Tuile::Component::HasValidation}.
628
+ # @return [Tuile::Component] a row of caption, field, and the error Label
629
+ # the field's listener keeps current.
630
+ def validated_row(caption, field)
631
+ error = Tuile::Component::Label.new
632
+ field.on_error_message_change = ->(msg) { error.text = msg || Tuile::StyledString::EMPTY }
633
+ row do |r|
634
+ r.add(Tuile::Component::Label.new(caption), Fixed[14])
635
+ r.add(field, Fixed[22])
636
+ r.add(error, Expand[1])
637
+ end
638
+ end
639
+
640
+ # Two rules, so the pane shows both halves: "required" has nothing to tint,
641
+ # "too short" does.
642
+ # @param fields [Hash{String => Tuile::Component}] caption => field.
643
+ def validate_login(fields)
644
+ fields.each { |caption, field| field.error_message = login_problem(caption, field) }
645
+ return if fields.each_value.any?(&:error_message)
646
+
647
+ Tuile::Component::ConfirmWindow.alert("Logged in", "Welcome, #{fields["Username"].value}.")
648
+ end
649
+
650
+ # @param caption [String]
651
+ # @param field [Tuile::Component]
652
+ # @return [String, nil] the verdict, or `nil` — the *clear* half of
653
+ # set-or-clear-on-every-pass, without which a fixed field stays red.
654
+ def login_problem(caption, field)
655
+ return "#{caption} is required" if field.empty?
656
+ return "at least 3 characters" if field.value.length < 3
657
+
658
+ nil
659
+ end
660
+
661
+ # The Save gate of `D_bad_input`: asked once, at the click, so the
662
+ # continuously-true fact ("2" is a bad date on the way to "2026") is only
663
+ # ever read in a settled state.
664
+ # @param fields [Hash{String => Tuile::Component}] caption => field.
665
+ def save_form(fields)
666
+ bad = fields.filter_map do |caption, field|
667
+ "#{caption}: #{field.bad_input_message}" if field.respond_to?(:bad_input?) && field.bad_input?
668
+ end
669
+ if bad.empty?
670
+ values = fields.map { |caption, field| "#{caption}: #{field.value.inspect}" }
671
+ Tuile::Component::ConfirmWindow.alert("Saved", values.join("\n"))
672
+ else
673
+ Tuile::Component::ConfirmWindow.alert("Cannot save", "#{bad.size} problem(s):\n#{bad.join("\n")}")
674
+ end
675
+ end
676
+
457
677
  # @param value [BigDecimal, nil]
458
678
  # @return [String] the value tripled exactly, next to the same sum in Float.
459
679
  def triple_report(value)
@@ -495,19 +715,17 @@ module SamplerExample
495
715
  # A ListDropdown driven from a TextArea — the same shape {ComboBox} and
496
716
  # {Select} use, but wired by app code onto a field that knows nothing about
497
717
  # it. Focus (and the caret) stays in the TextArea the whole time: an
498
- # `on_change` listener refills the menu, and an `on_key` interceptor hands
499
- # movement keys to `#move` and Enter to `#choose` while it is open. None of
500
- # this is baked into TextArea.
718
+ # `on_change` listener refills the menu, and {SlashCommandTextArea} hands
719
+ # movement keys to `#move` and Enter to `#choose` while it is open.
501
720
  def build_slash_demo
502
721
  prompt = Tuile::Component::Label.new
503
722
  prompt.text = "A ListDropdown driven from a TextArea. Type a slash command\n" \
504
723
  "(try \"/\" or \"/s\"). The menu floats over the field without taking\n" \
505
724
  "focus: Down/Up move the selection, Enter accepts, ESC dismisses, and\n" \
506
725
  "ordinary typing keeps editing the field and refilters the menu."
507
- area = Tuile::Component::TextArea.new
508
-
509
726
  overlay = Tuile::Component::ListDropdown.new
510
727
  @slash_overlay = overlay
728
+ area = SlashCommandTextArea.new(overlay)
511
729
 
512
730
  refill = lambda do
513
731
  matches = slash_matches(area)
@@ -524,20 +742,6 @@ module SamplerExample
524
742
 
525
743
  area.on_change = ->(_text) { refill.call }
526
744
  overlay.on_item_chosen = ->(_idx, item) { accept_slash_command(area, item.to_s) }
527
- area.on_key = lambda do |key|
528
- next false unless overlay.open?
529
- next true if overlay.move(key) # Up/Down/PgUp/PgDn/^U/^D
530
-
531
- case key
532
- when Tuile::Keys::ENTER
533
- overlay.choose
534
- when Tuile::Keys::ESC
535
- overlay.close
536
- true
537
- else
538
- false
539
- end
540
- end
541
745
 
542
746
  form do |f|
543
747
  f.add(prompt, Fixed[4])
@@ -668,6 +872,42 @@ module SamplerExample
668
872
  end
669
873
  end
670
874
 
875
+ # `visible=` on a conditional form: the fields a checkbox above them
876
+ # governs. The rows close up completely when they go — a hidden child costs
877
+ # neither its slot nor the box's `spacing` gap, which is what separates it
878
+ # from a `Fixed[0]` collapse — and come back with their constraints and
879
+ # their typed text intact, since nothing was ever detached.
880
+ def build_visibility
881
+ prompt = Tuile::Component::Label.new
882
+ prompt.text = "Tick 'Business customer' to reveal two more fields.\n" \
883
+ "The rows close up with no double gap, Tab skips what is hidden,\n" \
884
+ "and text typed into a field survives being hidden and shown."
885
+ status = Tuile::Component::Label.new
886
+ company = labelled("Company", Tuile::Component::TextField.new)
887
+ vat = labelled("VAT id", Tuile::Component::TextField.new)
888
+ conditional = [company, vat]
889
+ business = Tuile::Component::Checkbox.new("Business customer")
890
+ apply = lambda do
891
+ conditional.each { _1.visible = business.checked? }
892
+ status.text = "visible fields: #{business.checked? ? 4 : 2}"
893
+ end
894
+ business.on_value_change = ->(_) { apply.call }
895
+ # The rows sit flush; the form keeps its blank row around the block.
896
+ rows = group do |g|
897
+ g.add(labelled("Name", Tuile::Component::TextField.new), Fixed[1])
898
+ g.add(labelled("Email", Tuile::Component::TextField.new), Fixed[1])
899
+ g.add(company, Fixed[1])
900
+ g.add(vat, Fixed[1])
901
+ end
902
+ apply.call
903
+ form do |f|
904
+ f.add(prompt, Fixed[3])
905
+ f.add(business, Fixed[1])
906
+ f.add(rows, Fixed[4])
907
+ f.add(status, Fixed[1])
908
+ end
909
+ end
910
+
671
911
  # One filterable log level: the item type a CheckboxGroup holds. Its `value`
672
912
  # is a Set of *these*, never of the labels shown on the rows.
673
913
  LogLevel = Data.define(:label, :tag, :color)
@@ -808,7 +1048,7 @@ module SamplerExample
808
1048
  short_size = ->(bytes) { bytes < 1024 ? bytes.to_s : "#{(bytes / 1024.0).round}k" }
809
1049
 
810
1050
  update_status = lambda do
811
- under_cursor = SORT_ORDERS[group.content.cursor.position]
1051
+ under_cursor = SORT_ORDERS[group.list.cursor.position]
812
1052
  status.text = "value: #{group.value.label} — cursor: #{under_cursor&.label}"
813
1053
  end
814
1054
  resort = lambda do
@@ -819,9 +1059,9 @@ module SamplerExample
819
1059
  end
820
1060
  resort.call
821
1061
  group.on_value_change = ->(_order) { resort.call }
822
- # `content` is the composed List, which is where the cursor lives.
1062
+ # `list` is the composed List, which is where the cursor lives.
823
1063
  # Watching it is what makes the chrome/value split visible above.
824
- group.content.on_cursor_changed = ->(_idx, _line) { update_status.call }
1064
+ group.list.on_cursor_changed = ->(_idx, _line) { update_status.call }
825
1065
 
826
1066
  # Side-by-side body on a rect-callback {Panel}, as in the CheckboxGroup
827
1067
  # demo — the sidebar width is a capped proportion, not a constraint.