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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -0
- data/DECISIONS.md +3181 -41
- data/README.md +25 -5
- data/TERMINOLOGY.md +17 -3
- data/book/05-focus.md +63 -2
- data/book/06-theming.md +55 -7
- data/book/07-components.md +474 -48
- data/book/08-testing.md +78 -0
- data/book/10-locale.md +216 -0
- data/book/README.md +14 -5
- data/examples/sampler.rb +265 -25
- data/ideas/binder.md +177 -0
- data/ideas/composite-field.md +77 -0
- data/ideas/focus-accent.md +116 -0
- data/ideas/form-layout.md +151 -0
- data/ideas/hover/probe.rb +241 -0
- data/ideas/hover/probe_spec.rb +82 -0
- data/ideas/hover.md +909 -0
- data/ideas/new-components.md +26 -6
- data/lib/tuile/component/abstract_string_field.rb +106 -58
- data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/checkbox_group.rb +36 -20
- data/lib/tuile/component/combo_box.rb +59 -31
- data/lib/tuile/component/date_field.rb +322 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +29 -10
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +27 -0
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +6 -38
- data/lib/tuile/component/layout/box.rb +87 -19
- data/lib/tuile/component/layout.rb +13 -3
- data/lib/tuile/component/list.rb +11 -6
- data/lib/tuile/component/list_dropdown.rb +4 -0
- data/lib/tuile/component/overlay.rb +17 -0
- data/lib/tuile/component/radio_group.rb +39 -22
- data/lib/tuile/component/select.rb +12 -4
- data/lib/tuile/component/text_area.rb +14 -8
- data/lib/tuile/component/text_field.rb +42 -15
- data/lib/tuile/component/text_view.rb +25 -8
- data/lib/tuile/component/time_field.rb +454 -0
- data/lib/tuile/component/window.rb +26 -13
- data/lib/tuile/component.rb +469 -73
- data/lib/tuile/fake_screen.rb +11 -1
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/screen.rb +131 -17
- data/lib/tuile/screen_pane.rb +13 -9
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +100 -10
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +3398 -412
- 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.
|
|
33
|
-
`Tuile::StyledString`, the text
|
|
34
|
-
|
|
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,
|
|
84
|
-
|
|
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[
|
|
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
|
|
499
|
-
# movement keys to `#move` and Enter to `#choose` while it is open.
|
|
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.
|
|
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
|
-
# `
|
|
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.
|
|
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.
|