tuile 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/DECISIONS.md +1961 -0
- data/README.md +31 -24
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +82 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +363 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +19 -13
- data/examples/sampler.rb +435 -20
- data/ideas/new-components.md +109 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +133 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +281 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout.rb +3 -17
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +112 -83
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2043 -614
- metadata +18 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 613c66d096b205b29545b61cb0e17be3a7372079d2e4711a06098016fd50cd65
|
|
4
|
+
data.tar.gz: ca88dbc493cf0aba4ec46cfe99d786685c75fa579d722ad2582b7d52f42a1bbc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4fec7e60c1c37f3ac4d0050a5f709444e0e6dd76eb61d96f6128da0136fe38bfa7a91e55c58982d2c16612099e72e248f34e11824ebfd1705b75594a0ca41a82
|
|
7
|
+
data.tar.gz: e1696cf08df68aeb72ccdd13d628c958991ef59000489ac17722f2d7bebb52effccf368ff167db56617b65348113b0e7d1437d5a65ff967c707a932b303e24d9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.10.0] - 2026-08-02
|
|
4
|
+
|
|
5
|
+
- Fix the caret stepping by *character* rather than by grapheme cluster: LEFT/RIGHT, BACKSPACE and DELETE now move over and delete exactly one cluster, so a decomposed `é`, a ZWJ family emoji and a three-jamo Hangul syllable each take one press, and a deletion can no longer strand a combining mark with no base. The caret still counts characters but is now *boundary-locked* — `caret=` and `text=`'s clamp both snap forward to the smallest cluster boundary `>= index`, which makes a mid-cluster caret unrepresentable. Insertion stays character-native so that typing a combining mark still merges into its base. See `DECISIONS.md` `D-cluster-caret`.
|
|
6
|
+
- Add `Component::PasswordField` — a `TextField` painting one mask glyph per character (`mask_char=`, default `*`; rejects a wide or multi-cluster glyph), with a `revealed=` / `revealed?` toggle that shows the real text. While masked, word-jumps collapse to the ends of the buffer so the mask can't leak word boundaries. Constructor takes no arguments.
|
|
7
|
+
- Add `Component::TextField#display_text` — the protected seam a subclass overrides when it paints something other than `text`. Overriding `repaint` alone is a bug: the caret, the horizontal scroll window and click-to-position all measure `display_text`, so painting a substitute behind their backs makes them drift. The contract is one display character per `text` character, in order.
|
|
8
|
+
- Add `Component::ProgressBar` — a display-only fill over a `Range` (`range:`, default `0.0..1.0`), with `fraction` / `percent` readers and an `indeterminate` mode that owns a ticker (5 fps) for as long as the bar is attached. Its `bar_color` slot defaults to `nil` (the terminal's own foreground) and accepts a `Color` or a `Theme::Ref`, the latter live-resolved at paint time so it follows a theme change with no `on_theme_changed` hook. It deliberately stays *out* of `Component::HasValue`: a value that isn't a field shouldn't become focusable or enrol in the seam a forms layer iterates. There is no text on the bar — compose a `Label`. See `DECISIONS.md` `D-progress-bar` and `D-color-slots`.
|
|
9
|
+
- Add `Component#on_attached` / `on_detached` — lifecycle hooks fired once per component per transition as a subtree is mounted or unmounted, letting a component own a mounted-lifetime resource (`ProgressBar`'s ticker is the worked example). `attached?` is guaranteed `true` throughout the former and `false` throughout the latter, so an `invalidate` lands in one and no-ops in the other. They are hooks, not destructors: a process that exits without `Screen#close` fires nothing. See `DECISIONS.md` `D-attach-hooks`.
|
|
10
|
+
- `Screen#close` now unmounts the component tree (via `ScreenPane#detach_all`), so teardown fires `on_detached` across it. The teardown flags are set in an `ensure`, so a raising hook still propagates loudly without leaving a half-closed screen behind.
|
|
11
|
+
- **Breaking:** `Component#children` is final, and reparenting goes through the protected `add_child(child, at:)` / `remove_child(child)` / `detach_child(child)` — they write the array *and* the parent pointer in one call, and `parent=` is the sole firing site for the attach/detach hooks. A custom container must no longer override `children` or hand-wire `child.parent = …`: a derived `children` can disagree with the parent pointers, which `attached?` walks. Named slots (`Window#footer`, `HasContent#content`) are readers *over* the array, never a second copy. See `DECISIONS.md` `D-tree-api`.
|
|
12
|
+
- **Breaking:** `Component#attached?` is now the one-axis type test `root.is_a?(ScreenPane)` — it consults no `Screen`, so it never raises and a tree can be assembled with no screen in the process. Previously it compared `root` against the singleton's current pane, which made `Screen#close` silently mass-detach every tree and made `Screen.instance` a prerequisite for asking the question. See `DECISIONS.md` `D-tree-first`.
|
|
13
|
+
- **Breaking:** the UI-thread guard is rewritten and named. `EventQueue#locked?` is replaced by `#running?` (is a loop active anywhere) and `#on_loop_thread?` (is it mine); `Screen#check_locked` asks both and falls back to the creating thread only when no loop runs, so the loop need not run on the thread that built the tree. The internal `@pretend_ui_lock` — set once in `initialize`, cleared on the loop's first line and never restored, so every UI call during teardown raised "UI lock not held" — is gone, along with `FakeScreen#check_locked`'s bypass: a spec that mutates UI from a *spawned* thread now raises exactly as an app would. Adds `Screen#state` (`:idle` → `:running` → `:idle` → … → `:closed`), and `Screen#close` is now idempotent and refuses from `:running`. See `DECISIONS.md` `D-screen-lifecycle`.
|
|
14
|
+
- **Breaking (behavior):** all glyph measurement is now per grapheme cluster under one emoji policy (`:rgi`), so `"👍🏽"` measures 2 columns rather than 4 and no longer overruns its cell. A cluster is *not* capped at two columns — a non-RGI ZWJ sequence measures 4 — and `Buffer#put_char` models an arbitrary continuation run for it. Custom components measuring with `String#length` or iterating `each_char` were already wrong and are now visibly so; use `StyledString#display_width` / `slice` / `ellipsize`. See `DECISIONS.md` `D-cluster-width`.
|
|
15
|
+
- Fix `Component::TextArea` hanging the UI thread on text containing `\r`, `\v` or `\f` — each matches `/\s/` but not `/[ \t]/` and isn't `"\n"`, so the word-scan measured zero and the wrap loop never advanced (`area.text = File.read(crlf_file)` was enough to lock up an app). The wrap now iterates grapheme clusters, consumes at least one on every branch, and tests `end_with?("\n")` for hard breaks because `"\r\n"` is a single cluster. See `DECISIONS.md` `D-text-area-columns`.
|
|
16
|
+
- **Breaking (behavior):** `Component::TextField` separates the caret's character index from its terminal column, and **scrolls horizontally** instead of being capped by its own width. Previously the two axes were one number: with `"日本語"` the hardware cursor landed mid-glyph, the background well overran the rect by a column per wide glyph, a click mapped its column straight onto a character index, and the capacity check counted characters against a column budget (a 10-wide field accepted 18 columns of CJK). `max_text_length` is now an explicit, settable cap defaulting to `nil` (unbounded) rather than the derived `rect.width - 1`, and it counts **characters** knowingly — it is the one input measure that doesn't use the edit unit. Adds `left_column`. See `DECISIONS.md` `D-text-field-axes`.
|
|
17
|
+
- Add `Component::RadioGroup` — single-select over typed `items` (with an `item_label` proc), whose `value` is the selected item. Composes a `List`, so the cursor, scrolling, scrollbar and per-row hit-testing come free: the cursor **roams** without selecting (it is chrome, not the value), and Space, Enter or a click commits the row under it. `items=` clamps the cursor when the row set shrinks. See `DECISIONS.md` `D-radio-group`.
|
|
18
|
+
- Add `Component::CheckboxGroup` — multi-select over typed `items`, whose `value` is a **frozen** `Set` of the selected items (so `cg.value << item` raises rather than mutating state behind `on_value_change`'s back). `value=` coerces any `Enumerable` before the no-op guard and stores a copy; toggles go through `Set#+`/`#-`. The set iterates in toggle order, so the contract is unordered — use `items & value.to_a` when order matters. Space, Enter or a click toggles the row under the cursor. There is no select-all key. See `DECISIONS.md` `D-checkbox-group`.
|
|
19
|
+
- For both group components — and for `ComboBox` before them — `items=` is chrome and never touches `value`: an absent value renders as nothing selected and survives intact, so a form saved without edits changes nothing silently. There is no reconcile step, clamp or silent drop.
|
|
20
|
+
- `examples/sampler.rb` gains panes for `ProgressBar`, `RadioGroup` and `CheckboxGroup`.
|
|
21
|
+
- Add `Component::Checkbox` — a one-row boolean input (`[x] Enable syslog forwarding`) toggled by Space or a left-click. Its `value` is always `true`/`false` (never `nil`, coerced on assignment) with unchecked as the empty value, and `checked?`/`checked=`/`toggle` are the domain-word face over that one piece of state. Enter is deliberately unhandled so a form's Enter-to-submit bubbles past a focused checkbox. Constructor is `Checkbox.new(caption = nil, value: false)`. See `DECISIONS.md` `D-boolean-fields`.
|
|
22
|
+
- **Breaking (behavior):** `Component::Button#handle_mouse` now fires `on_click` only within the button's painted **extent** — `min(caption.display_width + 4, rect.width)` columns, one row — rather than anywhere in its `rect`. A click on the blank tail of an over-wide rect, or on a lower row of a multi-row one, still *focuses* the button but no longer activates it. `Component::Button#extent` is public, and `Component::Checkbox` follows the identical rule; the extent deliberately does not widen when a `bg_color` paints that tail.
|
|
23
|
+
|
|
24
|
+
- **Breaking:** `Component#key_shortcut` and `Component#find_shortcut_component` are **removed**, along with the *capture* phase of `ScreenPane#handle_key` that scanned the scope for a match, and `Window`'s `[k]-Caption` border prefix. Key dispatch is now three rungs — Tab, the global-shortcut registry, then delivery to `Screen#focused` bubbling up its ancestors — and `Screen#cursor_position` is purely "where to park the hardware cursor," no longer a routing signal. A scope-wide one-key binding (jump-to-pane, a form's default button) belongs on the scope root's own `handle_key`, which gets better semantics for free: it's scoped per popup, a focused `TextField` consumes the key before the ancestor sees it, and there's no registration/lifecycle bookkeeping. Migration: `w.key_shortcut = "1"` becomes a `case` in the containing layout's `handle_key` (and the caption prefix, if you want it, becomes part of the caption you set). See `DECISIONS.md` `D-key-dispatch`.
|
|
25
|
+
- **Breaking:** `Screen#register_global_shortcut` now also rejects `Screen::EDITING_KEYS` — `ENTER`, `BACKSPACE`, `DELETE` and the arrow keys. The registry sits above the component tree with nothing to suppress it, so `register_global_shortcut(Keys::ENTER) { submit }` silently broke `TextArea` newlines and `TextField#on_enter` app-wide; it now raises at registration and the message points at the ancestor-`handle_key` pattern instead. `HOME`/`END`/`PAGE_UP`/`PAGE_DOWN` stay legal (they navigate within a widget, and binding them app-wide is a real use case).
|
|
26
|
+
- Add `Component::IntegerField` — a single-line input whose `value` is an `Integer` (or `nil` when empty/un-parseable). Accepts only `0`–`9` and a single leading `-`; any other printable key is rejected without moving the caret. Up/Down step the value by one (an empty field counting as `0`). Composes a `TextField` (never subclasses one, so its face carries only the typed `HasValue` seam, not the widget's String `text`); `on_value_change` fires eagerly, once per real value change. No `min`/`max`, `+`, or grouping (a forms concern).
|
|
27
|
+
- `Component::HasValue` now carries `focusable? = true` (overridable), the shared input-field default previously duplicated on `AbstractStringField` and `ComboBox`. `tab_stop?` is deliberately *not* folded in: it stays `true` on the leaf `AbstractStringField` and `false` on the composing wrappers (`ComboBox`/`IntegerField`), whose inner field carries the stop.
|
|
28
|
+
- `Component::ComboBox` and `Component::IntegerField` compose their inner field via `Component::HasContent` (the one-child-container mixin) instead of hand-rolling `children`/`rect=`/`on_focus`; this also gives them click-to-position-caret on the field. Their `content`/`content=` are consequently public.
|
|
29
|
+
- Add `Component::HasCaption` — the caption seam, included by `Button` and `Window`: a `StyledString` caption coerced from `String | StyledString | nil`, with no-op detection and invalidation in one place. It also coins the naming split (**caption** is app-authored chrome; **text** is the user-editable value, aliased to `HasValue#value`), and makes tree-walking lookups possible — `is_a?(Component::HasCaption)` plus a caption compare finds "the Button captioned Submit" without a hardcoded class list.
|
|
30
|
+
- `Component::Window#caption` now accepts a `String | StyledString | nil` and the top border is built and clipped by **display** width: a CJK/emoji title no longer overruns the frame or mis-pads the dashes (the bottom border already did this for `footer_text`). An active window's border color claims the caption's own span colors, so the frame reads as one band. Both border lines are also clipped to `rect.width`, so a degenerate 1-column window no longer paints a second corner glyph outside its rect.
|
|
31
|
+
- `Component::Button#caption` now accepts a `String | StyledString | nil` (coerced via `StyledString.parse`, as `Label#text=` does), and the label is ellipsized to `rect.width` by **display** width rather than sliced by character count — a double-width (CJK/emoji) caption no longer paints past `rect.right`. The focus highlight is applied with `with_bg`, so it reads as one band across a styled caption.
|
|
32
|
+
- **Breaking:** `Component::Button#caption` and `Component::Window#caption` return a `StyledString`, not a `String`. Callers measuring one must use `caption.display_width` (the natural button width is `caption.display_width + 4`); `caption.to_s` recovers the plain text.
|
|
33
|
+
- **Breaking:** `Component::TextInput` is renamed `Component::AbstractStringField` (file `text_input.rb` → `abstract_string_field.rb`), and its doc now scopes it as the *String-valued* base of `TextField`/`TextArea` — a field whose value isn't a `String` composes one rather than subclassing it. `TextField`/`TextArea` are unaffected; only code that referenced `TextInput` directly must update the constant.
|
|
34
|
+
|
|
3
35
|
## [0.9.0] - 2026-07-05
|
|
4
36
|
|
|
5
37
|
Layout is now strictly top-down: a parent assigns each child's `rect`, and components no longer advertise how big they want to be. The book's chapter 3 is the long-form rationale.
|