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/README.md CHANGED
@@ -148,6 +148,15 @@ read — `Screen#background_color` — for panes tinted a few percent off the
148
148
  terminal's own, LazyVim-style.
149
149
  → [chapter 6](book/06-theming.md)
150
150
 
151
+ **A `Locale` holds conventions, never prose.** Date formats, the calendar,
152
+ month and weekday names, the decimal separator — detected from `locale(1)` at
153
+ startup, but only when the environment actually asked for something, since the
154
+ POSIX default is American and "said nothing" is indistinguishable from "wants
155
+ American". Everything else falls back to `Locale::ISO`. Tuile ships no message
156
+ catalogue and no per-country presets: this is the formatting half of what POSIX
157
+ splits, and the wording half stays your app's.
158
+ → [chapter 10](book/10-locale.md)
159
+
151
160
  ## Components
152
161
 
153
162
  Every component lives under `Tuile::Component::*`, and every one of them is a
@@ -198,6 +207,8 @@ carries the per-method reference: `bundle exec rake yard`, or
198
207
  | `IntegerField` | A one-row field whose `value` is an `Integer` or `nil`, filtering input to digits and one leading `-`. |
199
208
  | `FloatField` | The same, one Ruby type over: `value` is a `Float` or `nil`. |
200
209
  | `BigDecimalField` | The same for money, where a binary `Float` is the wrong answer. Tuile's one optional dependency — add `bigdecimal` yourself if you name this component. |
210
+ | `DateField` | A one-row field whose `value` is a `Date` or `nil`, over a list of strftime formats taken from `Screen#locale`: it accepts any of them and writes the first one back when you leave the field. Manual entry — there is no calendar popup yet. |
211
+ | `TimeField` | A one-row field whose `value` is a time of day — a `Time` on a fixed epoch date, or `nil` — spelled the way `Screen#locale` says. `step` is both the Up/Down stride and the precision: it shows seconds only when set below a minute. |
201
212
 
202
213
  ### Choosing from a set — [book ch7](book/07-components.md#choosing-from-a-set)
203
214
 
@@ -231,10 +242,13 @@ carries the per-method reference: `bundle exec rake yard`, or
231
242
  | `LogWindow` | A `Window` framing a `LogTextView` — the framed log pane. |
232
243
 
233
244
  The mixins those share — `HasValue` (the `value` / `empty?` / `clear` /
234
- `on_value_change` seam every input speaks), `HasContent` (one-child
235
- containers) and `HasCaption` (app-authored chrome text) — are the seams to
236
- include when you write your own; chapter 7's "value seam" section is the
237
- walkthrough.
245
+ `on_value_change` seam every input speaks), `HasValidation` (`error_message`,
246
+ the verdict a validator writes and the field shows as a red well), `HasBadInput`
247
+ (`bad_input?`, for a field whose input can be something its value cannot
248
+ represent — a lone `-` in a number), `HasContent` (a primary child the caller
249
+ populates, named `content`) and
250
+ `HasCaption` (app-authored chrome text) — are the seams to include when you
251
+ write your own; chapter 7's "value seam" section is the walkthrough.
238
252
 
239
253
  ## Geometry primitives
240
254
 
@@ -258,7 +272,7 @@ Tuile.logger = Logger.new(Tuile::Component::LogTextView::IO.new(view))
258
272
  ## Testing
259
273
 
260
274
  Tuile ships with a `Tuile::FakeScreen` that you install in place of the real
261
- screen for unit tests. It fixes the viewport at 160×50, disables the UI lock,
275
+ screen for unit tests. It fixes the viewport at 160×50,
262
276
  paints into an in-memory back buffer (assert on it for painted content) while
263
277
  capturing cursor/housekeeping escapes into an array, and uses a synchronous
264
278
  `FakeEventQueue` (submitted blocks run inline; posted events are discarded).
@@ -303,6 +317,12 @@ Key hooks:
303
317
  to its current value should typically *not* invalidate.
304
318
  - `Screen.instance.clear` — drops accumulated `prints` without resetting
305
319
  invalidation.
320
+ - `Tuile::Testing.get` / `.find` — locate a component to drive, by class,
321
+ mixin, `Component#id`, caption or a block:
322
+ `Testing.get(Component::Button, caption: "Save")`. `get` demands exactly
323
+ one match and prints the tree it searched when it doesn't get one; `find`
324
+ returns all matches and takes `count:`. Both take `in:` to scope the
325
+ search, and default to the whole screen. Call them qualified.
306
326
 
307
327
  Because `FakeEventQueue#submit` runs the block immediately on the calling
308
328
  thread, code paths that marshal work back via `screen.event_queue.submit { … }`
data/TERMINOLOGY.md CHANGED
@@ -24,6 +24,8 @@ needs a paragraph of justification, that paragraph belongs in one of those three
24
24
  | **row_count** | how many rows the wrapped content occupies. Public on `TextArea` (with `caret_row`, its companion); also on the private `WrappedText` and as `VerticalScrollBar.new(row_count:)`. Not on `TextView` / `List`, which have no caller for it. |
25
25
  | **caret_row** | the row a text input's caret sits in, counted from the content's first row. `TextArea` only. |
26
26
  | **extent** | the `Size` a widget actually paints inside the `rect` it was given — `Component#extent`, `nil` unless declared, always at the rect's top-left (`Component#extent_rect` positions it). What the widget clears outside of, hit-tests, highlights and anchors its dropdown to. The arithmetic is each widget's own (a `Checkbox`'s glyph plus caption; a `Tabs` strip's segments and separators). Distinct from a *slot extent*. |
27
+ | **handle** | the moving part of a {Tuile::VerticalScrollBar} — the rows standing for the slice of content in view (`handle_start` … `handle_end`, `handle_char`). CSS calls it the *thumb*; Tuile does not. Not drawn at all when the content fits. |
28
+ | **track** | the scrollbar's fixed part: the full viewport height the handle moves within, and the glyph (`track_char`) painted on the rows the handle doesn't cover. Never the bar's *column*, which is "the scrollbar column" (`D_scrollbar_reserve`). |
27
29
  | **segment** | one tab's span on a {Tuile::Component::Tabs} strip: its caption plus a padding column either side. The unit a click resolves to; the separator column between two segments belongs to neither. |
28
30
 
29
31
  **Space rule 1.** An object with only one row space leaves `row` unqualified:
@@ -57,15 +59,27 @@ content-space.
57
59
  | **slot extent** | in a `Layout::Box`, the size a parent *allocates* a child along an axis — what `Fixed` / `Percent` / `Expand` declare, and what `main_extent` / `cross_extent` measure. The parent's allocation, where a component's *extent* is the child's own painted region; `D_extent` turns on the two being different. Here `slot` is the box's allocation for one child and has **nothing** to do with {Tuile::Component::Slot} — the phrase is glossary-only (the code says `main_extent` / `cross_extent`), so read it as one term, never as "the extent of a `Slot`". |
58
60
  | **tile** / **tiled** | the non-popup part of the tree — `ScreenPane#content` and its descendants. Also *to tile*: to cover a rect completely. |
59
61
  | **attached** | reachable from a {Tuile::ScreenPane} via the parent chain — the one axis `attached?` consults. |
60
- | **slot** | a named region of a container, reached by identity (`content`, `footer`) as well as through `children`. Two forms: a plain named child, when the occupant is permanent and integral (`HasContent#content`); or a {Tuile::Component::Slot}, the one-child region component, when the occupant may be absent or swapped (`Window#footer`). Capital-`S` `Slot` always means the class. |
62
+ | **wrapping field** | a field that owns and hides one *inner editor* and carries a typed value over it — {Tuile::Component::AbstractWrappingField} and its subclasses. The editor is private machinery: no public accessor, `children` the only way in. |
63
+ | **inner editor** | the {Tuile::Component::AbstractStringField} a *wrapping field* wraps. Always this phrase — never "the wrapped field", which would name the wrong one of the two fields in play. |
64
+ | **slot** | a named region of a container, reached by identity (`content`, `footer`) as well as through `children`. Two forms: a plain named child the caller populates directly (`HasContent#content`, the primary one); or a {Tuile::Component::Slot}, the one-child region component, wired once so its occupant may be absent or swapped (`Window#footer`). Capital-`S` `Slot` always means the class. |
61
65
  | **cascade** | the stack of open {Tuile::Component::ListDropdown} panels a {Tuile::Component::MenuBar} drives, one per level, the last deepest. Each is an overlay on the pane, not a child of the bar. |
62
66
  | **submenu** | a menu item that opens a further panel instead of doing something — `MenuBar::Item#submenu?`, true iff the item has children. Painted with a trailing `▸`. |
63
67
  | **mnemonic** | a letter that activates one {Tuile::Component::MenuBar} item, underlined in its caption. Always *level-scoped*: matched against the top-level items while the cascade is closed and the deepest open panel while it is open, never across the two. |
64
68
  | **strip** | the one-row {Tuile::Component::Tabs} component: captions, one selected, no content of its own. A {Tuile::Component::MenuBar} has one too — same word, and the same extent-based hit testing, deliberately not the same look. |
65
69
  | **tab** | a {Tuile::Component::Tabs::Tab} — a caption plus an identity, minted and owned by the strip. Not a component (it never paints itself) and not an *item* (it holds per-element state, and the set is never assigned whole). Say "a tab" and "the Tab key"; never let the two words touch. |
66
- | **pane** | the component a {Tuile::Component::TabSheet} shows for the selected tab. The unselected ones are *detached*, which is how Tuile hides a component. |
70
+ | **pane** | the component a {Tuile::Component::TabSheet} shows for the selected tab. The unselected ones are *detached* — one of the two ways to take something off the screen, and the one that fires the lifecycle hooks. |
71
+ | **hidden** | carrying `visible? == false` — the component's own flag. *Gone*, not merely unpainted: as if detached, but still in the tree, so no lifecycle hook fires. Says nothing about the ancestors. |
72
+ | **shown** | reachable by the user: this component and every ancestor visible. The effective, ancestor-inclusive state, and always the walk's word (`on_shown_tree`, `Box#shown_children`) — there is deliberately no `shown?` reader. |
67
73
  | **invalidate** | record a component as needing repaint; the loop coalesces and repaints once per tick. |
68
74
  | **cursor** | *(two senses, both live)* the hardware terminal cursor (`Screen#cursor_position`), and a `List::Cursor` — the selection position within a list. |
69
- | **well** | the explicit background an input paints over its whole rect (`Theme#input_bg_color` / `#active_bg_color`), which opts it out of `bg_color` inheritance. |
75
+ | **well** | the background an input paints over its whole extent (`Theme#input_bg_color` / `#active_bg_color`), declared as its `default_bg_color`. It terminates inheritance — an ancestor's tint doesn't reach it — but loses to a `bg_color` set on the input itself. Exactly one per widget: a composed field owns the well and marks the field it wraps `Component::BG_INHERIT`. |
70
76
  | **token** | a semantic colour name on {Tuile::Theme} — an accent, never a global fg/bg. |
71
77
  | **scheme** | `:dark` or `:light`; a {Tuile::ThemeDef} pairs one {Tuile::Theme} per scheme. |
78
+
79
+ ## Locale
80
+
81
+ | term | means |
82
+ |---|---|
83
+ | **conventions** | the formatting facts a {Tuile::Locale} carries — how a value is *rendered and parsed* (date formats, calendar, month and weekday names, decimal separator). Deliberately the opposite pole from *prose*, which a `Locale` never holds. |
84
+ | **prose** | wording: a message in one language, belonging to one component. Outside `Locale` by rule, and outside Tuile by default — the wording fork of `D_bad_input` is where a translated one arrives. |
85
+ | **primary format** | `formats.first` of a date field or a {Tuile::Locale#date_formats} list — the one a value is *written* in, and the only one that must survive a `strftime`/`strptime` round-trip. The rest only ever parse. |
data/book/05-focus.md CHANGED
@@ -73,6 +73,24 @@ that scope in tree order and advances by one, wrapping around; Shift+Tab
73
73
  walks backward. This is what keeps Tab from escaping an open dialog — the
74
74
  scope is the dialog, so cycling stays inside it.
75
75
 
76
+ There is a third gate, and it isn't a predicate you override:
77
+ {Tuile::Component#visible?}. A hidden component — or any component under a
78
+ hidden one — is skipped by Tab, by click-to-focus, and by the forwarding a
79
+ container does, because it is skipped by the walk that collects candidates
80
+ at all. You never write a `visible?` check yourself; chapter 7 covers what
81
+ the flag is for.
82
+
83
+ The rule to know here is what happens when you hide the thing that
84
+ currently *has* focus. Focus does not stay there, and it does not become
85
+ nothing either: it moves to the hidden component's parent, which forwards
86
+ it on to the first field it can reach — exactly what happens when a
87
+ component is removed from the tree. Nothing is stashed, so bringing the
88
+ component back does not bring focus back with it. That is the same
89
+ behaviour a browser and every desktop toolkit settle on, and the reason
90
+ Tuile can't simply drop focus is worth knowing: with focus at nothing, keys
91
+ reach nobody at all, so an unhandled `q` would fall through to the event
92
+ loop and quit your app.
93
+
76
94
  ## The dispatch order
77
95
 
78
96
  When a key arrives, it's offered to the tree in a fixed order, and the
@@ -207,8 +225,10 @@ marker, reads the payload raw up to the terminator, and posts it as a
207
225
  single `PasteEvent` — which never enters the ladder at all:
208
226
 
209
227
  - no Tab traversal, no global shortcuts, no `handle_key`;
210
- - straight to {Tuile::Component#handle_paste}, delivered down the focus
211
- chain and bubbling exactly like a key;
228
+ - straight to {Tuile::Component#handle_paste} on the focused component —
229
+ and *only* it: unlike a key, a paste does not bubble to ancestors, because
230
+ the reasons a key does are all about scope-wide bindings and none of them
231
+ wants a clipboard;
212
232
  - the whole clipboard as one `String`, `\n`-normalized.
213
233
 
214
234
  The default `handle_paste` returns `false` and the text is dropped.
@@ -273,6 +293,47 @@ needed. With dispatch resting on nothing but "did you return `true`," the
273
293
  proxy is gone, and a component's decision to consume a key is the only
274
294
  declaration in the system.
275
295
 
296
+ ## A component that reacts to its own focus
297
+
298
+ Two hooks tell a component about itself, and a component overrides them —
299
+ nothing else calls them. {Tuile::Component#on_focus} fires when it gains
300
+ focus; {Tuile::Component#on_blur} fires when it loses it. Both fire on that
301
+ one component, never on the ancestors that light up and go dark with it.
302
+
303
+ `on_focus` is the forwarding hook. It's how a {Tuile::Component::Window}
304
+ handed focus passes it to its content, and how a {Tuile::Component::Layout}
305
+ skips ahead to the first tab stop underneath it — which is also why it fires
306
+ on *every* assignment, even one that re-focuses what's already focused.
307
+
308
+ `on_blur` is the commit point. Nothing else in Tuile is one: Tab is
309
+ unconditional, so a user leaving a half-finished field usually leaves by a
310
+ key no component ever sees, and `on_enter` never fires. If your field wants
311
+ to tidy up what was typed, this is where:
312
+
313
+ ```ruby
314
+ class TrimmedField < Tuile::Component::TextField
315
+ protected def on_blur = (self.text = text.strip)
316
+ end
317
+ ```
318
+
319
+ Both hooks fire on one component, so a *composed* widget — a
320
+ {Tuile::Component::ComboBox}, whose inner field is what actually holds focus —
321
+ gets the blur on the child, not on itself. When the question is "has focus left
322
+ this whole widget", override `active=` instead and compare before and after;
323
+ that's exactly what the combo box does to close its dropdown when you tab away.
324
+
325
+ It is a notification, not a veto — focus has already moved by the time you
326
+ hear about it, and a handler that tries to hold focus is picking a fight with
327
+ the one key nothing can suppress. It also fires wherever focus is *dropped*,
328
+ not only where a user moved it: when a popup closes, the component being
329
+ blurred has usually been detached already, so an `invalidate` there does
330
+ nothing; and `screen.close` blurs the focused component on its way out. Keep
331
+ the handler cheap and it won't matter.
332
+
333
+ The framework sends both hooks with `__send__`, so declare them public,
334
+ protected or private as you like — the example above groups `on_blur` under
335
+ `protected`, which is where framework-invoked plumbing belongs.
336
+
276
337
  ## Writing a status line
277
338
 
278
339
  Chapter 1 pointed out that Tuile draws no status bar. This is the chapter
data/book/06-theming.md CHANGED
@@ -23,18 +23,28 @@ defaults for free.
23
23
 
24
24
  What Tuile *does* color is the small set of cues that signal
25
25
  interaction: the highlight behind the focused list row, the border of the
26
- active window, the resting "well" of a text field, the shortcut captions
26
+ active window, the resting "well" of a text field, the scrollbar down a
27
+ scrollable pane's edge, the shortcut captions
27
28
  in a status line you write. Those are the accents, and they are exactly the tokens
28
29
  a {Tuile::Theme} carries — `active_bg_color`, `active_border_color`,
29
- `input_bg_color`, `hint_color`. There is no global `bg` or `fg` token,
30
+ `input_bg_color`, `scrollbar_color`, `hint_color`, the three that mark a field
31
+ invalid (`error_color` and the two error wells), and `placeholder_color` for
32
+ the hint an empty field paints into itself. There is no global `bg` or `fg` token,
30
33
  and that absence is intentional: adding one would mean painting over the
31
34
  terminal's defaults everywhere, which is precisely the thing that makes a
32
35
  TUI look wrong on someone else's color scheme. The theme touches only
33
36
  what the framework must color to be legible, and leaves the rest to the
34
37
  terminal.
35
38
 
36
- So a {Tuile::Theme} is a frozen value type — a `Data.define` of four
37
- colors plus an app-extensible `custom` hash — and that's all. Two are
39
+ Two of them are worth a second look, because they pull in opposite
40
+ directions. `hint_color` is an *accent* — a blue that draws the eye to a
41
+ shortcut caption you want noticed. `placeholder_color` is its temperamental
42
+ opposite: a grey tuned to sit just above invisible, because a placeholder is
43
+ a hint the reader is welcome to miss. Reaching for the wrong one of the two
44
+ makes an empty field louder than a filled one.
45
+
46
+ So a {Tuile::Theme} is a frozen value type — a `Data.define` of colors
47
+ plus an app-extensible `custom` hash — and that's all. Two are
38
48
  built in: {Tuile::Theme::DARK}, the colors Tuile has always used, and
39
49
  {Tuile::Theme::LIGHT}, counterparts legible on a pale background.
40
50
 
@@ -66,9 +76,47 @@ terminal default is just the root of that chain, which is why an unset
66
76
 
67
77
  A widget with a background of its *own* keeps it. A text field paints its
68
78
  well across its whole rect, so dropping one into a tinted panel shows the
69
- field in its own well, not the panel tint — the explicit background wins
70
- over the inherited one, the terminal equivalent of a CSS element that sets
71
- its own `background`.
79
+ field in its own well, not the panel tint — the terminal equivalent of a CSS
80
+ element that sets its own `background`. That is a *default*, though, not a
81
+ refusal: set `bg_color` on the field itself and it wins, because the chain
82
+ asks three questions in order — what did the app set on this component, what
83
+ background does this widget claim of its own, and what surrounds it.
84
+
85
+ Which is also how you get a field that reads as plain text inside a tinted
86
+ prompt. You *can* name the panel's colour again on the field — but there is a
87
+ shorter way to say "I have no background of my own, use whatever is behind me":
88
+
89
+ ```ruby
90
+ field.bg_color = Component::BG_INHERIT
91
+ ```
92
+
93
+ That is CSS's `background: inherit`, and it is different from leaving
94
+ `bg_color` unset: unset means "ask *my* default first", which for a field is
95
+ its well. `BG_INHERIT` skips the well and goes straight to what surrounds it.
96
+
97
+ It is the same mechanism Tuile uses internally. A
98
+ {Tuile::Component::ComboBox} is one widget with one surface, built out of a
99
+ {Tuile::Component::TextField} plus a `▾` — so the ComboBox paints the well and
100
+ marks its inner field `BG_INHERIT`. Exactly one well per widget, which is what
101
+ lets you tint the ComboBox and have the tint reach the cells the field draws.
102
+
103
+ Backgrounds can differ by state. An input is brighter while it holds focus,
104
+ and a flat `bg_color` replaces *both* shades — fine for a field, which shows a
105
+ caret when focused, and a deliberate choice for something like a
106
+ {Tuile::Component::Select}, which has no caret and nothing else to indicate
107
+ focus with. Name the states when you want to keep the distinction:
108
+
109
+ ```ruby
110
+ field.bg_color = grey # flat: focused or not
111
+ field.bg_color = { normal: grey, active: blue } # your own pair
112
+ field.bg_color = { active: blue } # keep the widget's own well,
113
+ # override only the focus shade
114
+ ```
115
+
116
+ The keys are a small closed set (`:normal`, `:active`) that Tuile defines —
117
+ a state with no key simply isn't answered there, and the question falls
118
+ through to the next level of the chain, which is what makes the third line
119
+ above mean what it reads as.
72
120
 
73
121
  `bg_color` takes either a concrete {Tuile::Color} or a *live theme
74
122
  reference* — `Theme.ref(:panel_bg)` — that names one of your app's custom