tuile 0.15.0 → 0.17.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 +229 -80
- data/README.md +49 -24
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +17 -16
- data/book/05-focus.md +106 -34
- data/book/06-theming.md +108 -38
- data/book/07-components.md +249 -46
- data/book/08-testing.md +134 -32
- data/book/10-locale.md +3 -3
- data/book/README.md +11 -10
- data/examples/file_commander.rb +52 -32
- data/examples/hello_world.rb +18 -5
- data/examples/sampler.rb +576 -146
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +96 -97
- data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +27 -19
- data/lib/tuile/component/checkbox.rb +21 -19
- data/lib/tuile/component/checkbox_group.rb +17 -18
- data/lib/tuile/component/combo_box.rb +69 -64
- data/lib/tuile/component/confirm_window.rb +34 -27
- data/lib/tuile/component/date_field.rb +50 -18
- data/lib/tuile/component/date_time_field.rb +319 -0
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +99 -28
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +8 -15
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +40 -14
- data/lib/tuile/component/has_value.rb +71 -17
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -60
- data/lib/tuile/component/layout.rb +127 -13
- data/lib/tuile/component/list.rb +233 -120
- data/lib/tuile/component/list_dropdown.rb +151 -91
- data/lib/tuile/component/menu_bar/cascade.rb +102 -32
- data/lib/tuile/component/menu_bar.rb +102 -82
- data/lib/tuile/component/notification.rb +76 -49
- data/lib/tuile/component/overlay.rb +217 -58
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +41 -17
- data/lib/tuile/component/popup.rb +15 -26
- data/lib/tuile/component/progress_bar.rb +17 -11
- data/lib/tuile/component/radio_group.rb +16 -17
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +26 -43
- data/lib/tuile/component/slot.rb +4 -5
- data/lib/tuile/component/tab_sheet.rb +27 -34
- data/lib/tuile/component/tabs.rb +49 -34
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +32 -28
- data/lib/tuile/component/text_field.rb +68 -50
- data/lib/tuile/component/text_view.rb +157 -89
- data/lib/tuile/component/time_field.rb +51 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +653 -323
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +18 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +120 -7
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +233 -0
- data/lib/tuile/mouse.rb +244 -0
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +510 -138
- data/lib/tuile/screen_pane.rb +185 -67
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +144 -14
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +316 -42
- data/lib/tuile/theme.rb +192 -53
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +6084 -1507
- metadata +19 -17
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -8562
- data/TERMINOLOGY.md +0 -85
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/binder.md +0 -177
- data/ideas/composite-field.md +0 -77
- data/ideas/focus-accent.md +0 -116
- data/ideas/form-layout.md +0 -151
- data/ideas/hover/probe.rb +0 -241
- data/ideas/hover/probe_spec.rb +0 -82
- data/ideas/hover.md +0 -909
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -144
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,154 @@
|
|
|
1
|
+
<!-- An entry is one sentence — `Add` / `Fix` / `**Breaking:**`, the symbol, what changed, ≈40
|
|
2
|
+
words; a trailing See `D_<slug>` doesn't count, and a breaking entry earns a second sentence
|
|
3
|
+
for the migration only. Group `Add`, then `Fix`, then `**Breaking:**`; a themed release may
|
|
4
|
+
carry a ≤3-sentence preamble under its version heading, once. -->
|
|
5
|
+
|
|
1
6
|
## [Unreleased]
|
|
2
7
|
|
|
8
|
+
## [0.17.0] - 2026-09-23
|
|
9
|
+
|
|
10
|
+
- Add derived theme tokens — any `Theme` token may be a Proc of the terminal background (and optionally a resolver reading sibling tokens), which the screen re-derives into a `Color` whenever the theme or `Screen#background_color` changes, walking the tree once. See `design/decisions.md` `D_derived_tokens`.
|
|
11
|
+
- Add `Theme#resolve(background)` and `Theme#derived?` — the resolution the screen runs, for reading a derived token outside a screen; reading one on an unresolved theme raises `Tuile::Error`.
|
|
12
|
+
- Add `Component::Fill` — paints one glyph into every cell of its rect in a `color` slot taking a live `Theme.ref`, so a one-column `Fill.new("│")` is a vertical rule between borderless panes, sized by its parent and following theme changes on its own. See `design/decisions.md` `D_color_slots`.
|
|
13
|
+
- Add `StyledString.validate_glyph(char, name)` — the check every glyph knob runs at assignment, raising unless `char` is one grapheme cluster one column wide.
|
|
14
|
+
- Add `Color#rgb` — the `[r, g, b]` behind an RGB color or a palette index 16..255, for app color math such as a contrast check; `nil` for the 16 named colors, in either spelling, whose look the terminal's scheme decides.
|
|
15
|
+
- Add `Component::Layout::Percent#clamp(range)` — bounds a `Box` child's share with an inclusive `Range` of cells, so `Percent[50].clamp(..60)` says "half the width, but never more than 60 columns", on either axis. See `design/decisions.md` `D_box_layouts`.
|
|
16
|
+
- Add `Tuile::ComponentBackground` — a component's background chain, reached through the protected `Component#bg`: `bg.default_color=` states a widget's own well, `ComponentBackground::INPUT_WELL` being the input one, and `bg.effective` / `bg.ambient` answer what to paint. See `design/decisions.md` `D_bg_surface`.
|
|
17
|
+
- Add `StyledString#ellipsize`'s `at:` — `:start` keeps the *tail* and prepends the ellipsis, for text whose end identifies it and whose head is context (`…/shared/markdown/`); `:end` stays the default.
|
|
18
|
+
- Add `StyledString#under_fg` — the foreground counterpart of `#under_bg`: sets `fg` only on spans that carry none, skipping `inverse` spans, so a caption takes a default tone while a part the app colored itself keeps its own. See [#63](https://github.com/mvysny/tuile/issues/63).
|
|
19
|
+
- Add `StyledString#ljust`, `#rjust` and `#center` — pad with unstyled fill to a *display* width, never truncating, so `s.ellipsize(w).ljust(w)` is exactly `w` columns; a custom pad must be one cluster one column wide.
|
|
20
|
+
- Add `Component::Scroller#scrollbar_visibility = :auto` — the bar shows only while `content_rows` exceeds the viewport, and the content gets the full width back while it fits. See [#62](https://github.com/mvysny/tuile/issues/62).
|
|
21
|
+
- Add `Component::TextView#scrollbar_visibility = :auto` — the bar shows only while the text, wrapped at the full width, overflows the viewport, rewrapping two columns narrower when it appears and back when it goes. See [#62](https://github.com/mvysny/tuile/issues/62).
|
|
22
|
+
- Add `Component::List#scrollbar_visibility = :auto` — the bar shows only while the items outnumber the rows and hands its column back while they fit, so a borderless pane shows no idle track yet still signals overflow; `ListDropdown` now uses it. See [#62](https://github.com/mvysny/tuile/issues/62).
|
|
23
|
+
- Add `Component::List#interactive=` — `false` makes a display-only list, neither focusable nor a tab stop, whose rows a press no longer chooses, for a summary pane beside the field the user types in; the wheel still scrolls it. See [#64](https://github.com/mvysny/tuile/issues/64).
|
|
24
|
+
- Add `Tuile.strict_layout` — the stale-rect diagnostic: under `:raise` or `:warn`, a `Component#rect` read the app makes while an ancestor owes a `relayout` names that ancestor and the read site instead of answering the previous pass's rectangle; off by default outside a `FakeScreen`. See `design/decisions.md` `D_strict_layout`.
|
|
25
|
+
- Add `Tuile.without_strict_layout` — runs a block with the diagnostic off, for a read that is pre-settle on purpose, and restores whatever was in force, default included.
|
|
26
|
+
- Add `Testing.place(component, rect)` — moves a component within the parent it already has, through whatever places it there, since `rect=` raises outside the parent's `relayout`: a parentless root in a throwaway holder, an `Absolute` child by its constraint, an open overlay by `At[rect]`; it never attaches or re-parents.
|
|
27
|
+
- Add `Testing.paint(component)` — paints a component and its subtree, attached or not, into a `Buffer` of its own whose `(0, 0)` is the component's top-left; ancestors don't clip it and popups over it don't show.
|
|
28
|
+
- Add `Buffer#text` — every row's plain text, `region_text` over the whole buffer, so `Testing.paint(window).text` is the whole paint.
|
|
29
|
+
- Add `Screen#canvas_for(component, backend:, root:)` and `Screen#clip_for(component, root:)` — a canvas onto another backend, positioned and bounded within an ancestor rather than the screen.
|
|
30
|
+
- Add `Component#rect_stale?` — whether `rect` is the previous pass's, because an ancestor owes a `relayout`; `Component#layout_dirty?`, which answers the same question about a container's *children*, is public now.
|
|
31
|
+
- Add `Component#handle_rect_changed(old_rect)` — the hook a component reacts to a new rect in, since `rect=` can no longer be overridden.
|
|
32
|
+
- Add `FakeScreen#resize_terminal(width, height)` — the terminal reporting a new size, routed as its own report would be.
|
|
33
|
+
- Add `Screen.fake(width:, height:)` — the terminal size a spec starts at, 160×50 unless given, so a narrow or short terminal is a `before` hook rather than a resize.
|
|
34
|
+
- Add `Component::VerticalScrollBar` — a one-column bar in the tree that the user drags, and presses the track of to page a viewport; it moves nothing itself, firing `on_scroll_request` for its owner to assign. `Scroller` now holds one. See `design/decisions.md` `D_draggable_scrollbar`.
|
|
35
|
+
- Add `Component::FormItem` — the chrome around one field: a `caption` row carrying an optional `required:` marker, the field, and the message it reports against itself — a verdict, or input it cannot parse — mirrored into the last row, which doubles as the gap so nothing reflows when a field goes invalid. See `design/decisions.md` `D_form_item`.
|
|
36
|
+
- Add `Component::FormLayout` — the column that stacks `FormItem`s: `add(field, caption:, required:, rows:)` wraps the field and returns the item, captions above, no `spacing` because the message row is the gap, and overflow clipped rather than scrolled. See `design/decisions.md` `D_form_layout`.
|
|
37
|
+
- Add a *FormLayout* pane to `examples/sampler.rb` under Shell — first name, surname and date of birth as captioned items, where each name field's own `on_value_change` writes `error_message`, so the message row fills and empties as you type and nothing below it moves.
|
|
38
|
+
- Add a *Scroller* pane to `examples/sampler.rb` under Shell — an eight-field `FormLayout` in a ten-row `Scroller`, each field validated non-empty, so Tab walks focus past the bottom edge and the view follows, message rows included.
|
|
39
|
+
- Add `Component::FormItem#scroll_to_visible` — a field scrolled into view brings its caption and message row along; when the item is taller than the viewport, the field's own request wins.
|
|
40
|
+
- Add `Tuile::Listeners` — a listener slot holding many callables instead of one, registered through the reader (`button.on_click { save }`) and removed with the expression that added them; there is no setter and no `clear`, so a claim can never be silently replaced. See `design/decisions.md` `D_listeners`.
|
|
41
|
+
- Add `Tuile::Listeners::Declare` — the `listener :on_foo` macro a class or module extends in, building the slot lazily on first read and taking an optional block fired on the empty↔non-empty transition.
|
|
42
|
+
- Add `Component::HasBadInput#on_bad_input_change` — the notice for whoever has cells to paint the message in: it fires the *showable* report, `bad_input_message` gated by `bad_input_settled?` and diffed, so a field announces once rather than per keystroke. See `design/decisions.md` `D_bad_input`.
|
|
43
|
+
- Add `Component::HasValidation#shown_message` — the one place the two error channels' precedence is stated: the field's own bad-input report when it is showable, else the verdict a validator wrote. See `design/decisions.md` `D_has_validation`.
|
|
44
|
+
- Add `Component::HasBadInput#wears_bad_input_ink?` — the protected hook a composite answers `false` from while a child holds the fault, so the well reddens where it happened; `bad_input_settled?` is public now, gating the notice as well as the ink.
|
|
45
|
+
- Add `Tuile::Event` — the marker every event includes, now carried by all five `Mouse` events and all seven `EventQueue` ones, so `is_a?(Tuile::Event)` spans the three namespaces; it mandates no members and supplies no defaults.
|
|
46
|
+
- Add `Tuile::Canvas` — the frozen paint context handed to `Component#repaint`, carrying the background it applies to every write; derive one for a span with `with(bg_color:) { |canvas| … }`, which raises without a block. See `design/decisions.md` `D_canvas`.
|
|
47
|
+
- Add `Tuile::Canvas#origin` — where a canvas's `(0, 0)` lands in its backend, set by `Screen#canvas_for` to where the component sits on screen and carried through `with(bg_color:)`, so a `repaint` paints in its own coordinates. See `design/decisions.md` `D_canvas`.
|
|
48
|
+
- Add `Component#local_rect` / `#local_extent_rect` — the component's rect and extent at `(0, 0)`: the region argument `canvas.fill` takes, and the space its children's rects are measured in.
|
|
49
|
+
- Add `Rect#moved_by(point)` — `#at`'s relative counterpart, the same size shifted, for converting a rectangle between two coordinate spaces one offset apart.
|
|
50
|
+
- Add `Component#absolute_rect` / `#absolute_extent_rect` / `#to_screen(point)` / `#to_local(point)` — the named ways out of a component's own coordinates into the screen's and back, summing every ancestor's offset, derived per call and never cached. See `design/decisions.md` `D_relative_rect`.
|
|
51
|
+
- Add `Point::ZERO` — the frozen `(0, 0)`, named for the value rather than a role: every coordinate space has an origin, and `Canvas#origin` is a different point entirely.
|
|
52
|
+
- Add `Tuile::Canvas::Backend` — the mixin naming where a canvas's cells land (`set_text` / `set_char` / `fill`, fully resolved styles). `Tuile::Buffer` includes it unadapted, so `Screen#canvas` is a `Canvas` over the back buffer and `Screen#canvas_for(component)` derives one carrying that component's background.
|
|
53
|
+
- Add `Testing.click` / `Testing.set_value` — gestures that drive a located component only where a user could have: `click` routes a real press at the cell it paints, `set_value` refuses a field outside the key scope and moves no focus.
|
|
54
|
+
- Add `Testing::Gestures` — a refinement giving those receiver syntax (`button._click`, `field._value = 25`), activated with `using Tuile::Testing::Gestures` per file or per `describe`; nothing is added to `Component` itself.
|
|
55
|
+
- Add `ScreenPane#key_scope` — the root keys bubble to and Tab cycles within (the topmost modal popup, else `content`), extracted from the three places that computed it.
|
|
56
|
+
- Add `Screen#clip_for` — the cells a component may write, its own `rect` intersected with every ancestor's: a write past the rect a component was given is dropped rather than landing on a neighbour, and a parent may hand out a rect it will not show in full. No component declares or widens it. See `design/decisions.md` `D_clip`.
|
|
57
|
+
- Add `Tuile::Canvas#clip` — the backend-space region a canvas may write to, built by `Screen#canvas_for` from `clip_for` and carried through `with(bg_color:)`; a row crossing an edge is sliced, a glyph the edge falls inside blanked rather than split, and a caret outside it hides the cursor. See `design/decisions.md` `D_clip`.
|
|
58
|
+
- Add `Component::Scroller` — a viewport onto one taller content child: `content_rows=` says how tall the content is (nothing measures), the wheel and every focus change scroll it, and a `VerticalScrollBar` takes the right-hand column. It claims no keys. See `design/decisions.md` `D_scroller`.
|
|
59
|
+
- Add `Component#scroll_to_visible(rect = local_extent_rect)` — a component's "show me this" request, climbing the parent chain re-expressed a level at a time to whatever scrolls above it; `Screen#focused=` makes it between `handle_focus` and `on_focus_changed`, so Tab brings a scrolled-out child into view. A hidden component, or a request reaching a hidden ancestor, raises.
|
|
60
|
+
- Add a warning to `Screen#focused=`: a target whose `clip_for` is still empty after `scroll_to_visible` — a stale `Scroller#content_rows`, a `Fixed[0]` collapse — logs to `Tuile.logger` rather than silently taking keys the user cannot see.
|
|
61
|
+
- Add `Rect#intersect(other)` — the region both cover, half-open like `#contains?` and yielding an empty rectangle rather than `nil` when they are disjoint, so a chain of clips folds with no nil test per level.
|
|
62
|
+
- Add `Component#flush_layout` — the force-now that runs every pending `relayout` in a subtree, for reading a rect in the same turn that dirtied it and for a tree assembled before any `Screen` exists. See `design/decisions.md` `D_deferred_layout`.
|
|
63
|
+
- Add `Component::AbstractStringField#escape_clears_focus` — the named opt-out for the field's own ESC blur, `true` by default and the whole representation of it, so giving ESC another meaning no longer means removing a pre-registered listener by identity and discarding the answer. See `design/decisions.md` `D_escape_opt_out`.
|
|
64
|
+
- Add `HasValue::ValueChangeEvent#from_user?` and `HasValue#set_value(value, from_user:)` — every value change says whether the user made it (typing, a paste, a key, a click, a pick, `Testing.set_value`) or code did (`value=`), and an app writing on the user's behalf declares `true`. See [#61](https://github.com/mvysny/tuile/issues/61), `design/decisions.md` `D_from_user`.
|
|
65
|
+
- Fix `Component#invalidate_layout` to drop a mark made during the container's own `relayout` before it places a child, so hiding or adding a child there first no longer costs a second pass or raises a false stale-rect error on a freshly built tree. See `design/decisions.md` `D_deferred_layout`.
|
|
66
|
+
- Fix `Component#rect_stale?`, and so `Tuile.strict_layout`, missing a rect the running `relayout` had not placed yet — a sibling read from a `handle_rect_changed` or `handle_focus` fired mid-pass now reports as stale. See `design/decisions.md` `D_strict_layout`.
|
|
67
|
+
- Fix `Screen#focused=` inside a `relayout` — a focused child hidden there, a menu closed from `handle_rect_changed` — scrolling and notifying against rects the pass had not placed yet: `scroll_to_visible` and `on_focus_changed` now wait for the drain, and `flush_layout` raises inside a pass. See `design/decisions.md` `D_deferred_layout`.
|
|
68
|
+
- Fix `ScreenPane` stranding its content and popups at their last rects when the terminal shrinks to nothing: the content gets the empty pane rect and every popup collapses, both restored on the next resize. See `design/decisions.md` `D_empty_ancestor`.
|
|
69
|
+
- Fix `Screen#flush_layout` and `Component#flush_layout` hanging the UI thread on a layout that never settles — they raise `Tuile::Error` after 50 rounds, naming the containers still marking, and `Screen#close` still unmounts such a tree. See `design/decisions.md` `D_deferred_layout`.
|
|
70
|
+
- Fix `Component::Layout::Absolute` leaving a moved child's old cells painted: `Component#rect=` now invalidates the parent too, so every container blanks what a child vacated without re-invalidating itself in its `relayout`.
|
|
71
|
+
- Fix `Screen#flush_layout` running a container twice when it was marked while already waiting in the current drain round.
|
|
72
|
+
- Fix `Component::List#auto_scroll` ignoring a wheel notch or a PgUp: both bypassed `scroll_top_row=` and left `following?` armed, so the next incoming row yanked the viewport back to the tail.
|
|
73
|
+
- Fix `Component::List`'s and `Component::TextView`'s scrollbars being ink you could not touch: a visible one is a `Component::VerticalScrollBar` child now, so its handle drags (wanting `run_event_loop(capture_mouse: :drag)`) and its track pages, and it sits up to a row from where the old painter drew it. See `design/decisions.md` `D_draggable_scrollbar`.
|
|
74
|
+
- Fix a left press on a `Component::List`'s scrollbar column choosing the item behind it: the bar claims that column now and scrolls instead.
|
|
75
|
+
- Fix `Component::MenuBar` drilling into a submenu you only arrowed past: a menu stepped to sideways now opens with no row highlighted, so Right steps on and Down, Enter or Space moves in (Up onto the last row). See `design/decisions.md` `D_menu_bar`.
|
|
76
|
+
- Fix the sideways walk dying on a top-level item with no menu: the bar now keeps *menu mode* across it, so the next Right opens its neighbour's menu, and ESC there leaves the mode rather than reaching the app's quit key. See `design/decisions.md` `D_menu_bar`.
|
|
77
|
+
- Fix a mouse click past column 223 being dead: `Screen#run_event_loop` also requests the SGR encoding (mode 1006), whose coordinates are uncapped, and `Mouse.parse` reads both wire forms — a terminal ignoring the request keeps sending X10. See `design/research.md` `R_mouse_reporting`.
|
|
78
|
+
- Fix `Component::List`, `Component::TextView` and `Component::TextArea` ignoring a height-only resize: an `auto_scroll` list or view re-pins to the bottom as it grows taller, and a text area keeps its caret in view as it shrinks.
|
|
79
|
+
- **Breaking:** `Component::List#renderer` is now called as `(item, text_width) -> row` — the columns the row body gets, so a row can align a right-hand column down the pane or elide a path from the left, re-rendered whenever that width changes. Give every renderer a second parameter (`->(u) { … }` becomes `->(u, _w) { … }`); a one-argument callable raises when a row is rendered. See `design/decisions.md` `D_list_items`.
|
|
80
|
+
- **Breaking:** a `FakeScreen` now defaults `Tuile.strict_layout` to `:raise`, so a spec that reads a rect it has not settled raises instead of asserting against the previous pass's rectangle. Settle it (`component.flush_layout`), or wrap a deliberately unsettled read in `Tuile.without_strict_layout { … }`; `Tuile.strict_layout = false` opts a whole suite out.
|
|
81
|
+
- **Breaking:** an overlay's placement must now include `Component::Overlay::Placement` and answer `rect_for(overlay, screen_size, anchor_rect)`, checked where the app hands it over rather than failing as a `NoMethodError` inside the pane a settle later. Add the `include` and the third parameter; a placement hanging off a widget answers `anchor` with the component (or a screen `Rect`, or `nil` for none) and the pane resolves it.
|
|
82
|
+
- **Breaking:** `Component::Overlay` and its subclasses now raise unless they are adopted as one of `ScreenPane#popups` — `Overlay#open` is the only door, and the pane's `content` slot is refused too. Replace `layout.add(overlay)` / `screen.content = overlay` with `overlay.open(placement)`.
|
|
83
|
+
- **Breaking:** `Component#default_bg_color` is no longer an override hook — a widget states its well once at construction with `bg.default_color = …`, taking a `Color`, a `Theme::Ref` or a state Hash. Replace `def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color` with `bg.default_color = ComponentBackground::INPUT_WELL` in `initialize`, and hand any other theme color over as a `Theme.ref`.
|
|
84
|
+
- **Breaking:** `Component::BG_INHERIT` and `Component::BG_STATES` are now `ComponentBackground::INHERIT` and `ComponentBackground::STATES`, and the protected `Component#effective_bg_color` is `bg.effective`. Rename the references; `bg_color` / `bg_color=` and the `error_bg_color` hook are unchanged.
|
|
85
|
+
- **Breaking:** `Component#rect=` is protected and raises unless the component's parent is running its `relayout` — a component with no parent included — so a rect is only ever assigned by what places it. Move a child through its parent (`Layout::Absolute#constrain`, `Layout::Box#constrain`, `Overlay#placement=`), size a detached tree by holding it in a `Layout::Absolute`, and move a `rect=` override's body into `handle_rect_changed`.
|
|
86
|
+
- **Breaking:** An overlay opens with a placement and `ScreenPane`'s layout pass assigns its rect, again on every resize — `Overlay#open(Overlay::At[rect])`, `Overlay::Centered[]` (a `Popup`'s default) or `Overlay::TopRight[]` (a `Notification`'s), and `Overlay#placement=` moves an open one. Replace `overlay.rect = r; overlay.open` with `overlay.open(Overlay::At[r])`; `Popup#center` is gone, and `Overlay#reposition` now asks the pane to place the overlay again rather than being an override point — override `declared_size_in` for a derived size.
|
|
87
|
+
- **Breaking:** `ListDropdown#anchor_to` and `#anchor_beside` take no `rows:` (the item count is read live), accept the driver component itself as the anchor, which the dropdown then follows as it moves, and open the dropdown when it is closed. Replace `drop.open; drop.anchor_to(field.absolute_rect, rows: n)` with `drop.anchor_to(field)`.
|
|
88
|
+
- **Breaking:** `Component::Layout::Absolute` now places each child at a fixed `Rect` — `add(child, rect)`, `constrain(child, rect)` to move it — and the base to subclass with your own `relayout` is `Component::Layout`. Change `class Foo < Layout::Absolute` to `< Layout`, and a bare `Absolute` whose children you sized by writing their `rect` to `add(child, rect)`.
|
|
89
|
+
- **Breaking:** `Component#relayout` is the sole place a container assigns its children's rects, replacing the `rect=` override, `HasContent#layout(content)` and `ScreenPane#layout`. Move the body of your `rect=` override into a protected zero-arg `relayout` dividing `local_rect`, and drop the `super`. See `design/decisions.md` `D_relayout`.
|
|
90
|
+
- **Breaking:** Layout is deferred — a mutation marks, and `Screen#dispatch` settles after each event — so a rect read in the same turn that dirtied it is stale, detached trees included. Call `Component#flush_layout` where you need one now. See `design/decisions.md` `D_deferred_layout`.
|
|
91
|
+
- **Breaking:** `Component#handle_child_visibility_changed` is gone; `visible=` now marks *and* invalidates the parent for every container, so a `relayout` override already covers what the hook was overridden for. Delete the override. See `design/decisions.md` `D_relayout`.
|
|
92
|
+
- **Breaking:** `Testing::LookupError` is gone; every lookup and gesture raises `Testing::AssertionError`, an `Exception` rather than a `Tuile::Error`, since an assertion is not production's error and nobody should catch one. Rename it in `assert_raises`, and replace a `rescue Tuile::Error` around a lookup with the class itself.
|
|
93
|
+
- **Breaking:** `Testing.find` / `.get` no longer take `caption:` — lookup handles are structural (a class, an `id`, a subtree), never what a component shows. Replace `get(Button, caption: "Save")` with an `id:`, or with the block that already did this: `get(Component::Button) { _1.caption.to_s == "Save" }`. See `design/decisions.md` `D_component_lookup`.
|
|
94
|
+
- **Breaking:** Every listener slot is a list with no setter — `on_foo=` is gone and the reader registers. Write `button.on_click { save }` for `button.on_click = -> { save }`, `field.on_change << cb` for `field.on_change = cb`, and `field.on_change.remove(cb)` to unsubscribe. See `design/decisions.md` `D_listeners`.
|
|
95
|
+
- **Breaking:** A slot fires one `Tuile::Event` rather than positional arguments, so `->(index, item) { … }` becomes `{ |e| … e.index, e.item }`. A listener taking no arguments is unaffected; each slot's rdoc names its event class, and one needing two arguments now raises at registration.
|
|
96
|
+
- **Breaking:** `Screen#on_error` ships no default listener — an *empty* slot is what re-raises. Only code that read or called the old re-raising `Proc` needs changing; registering a listener still takes the error over.
|
|
97
|
+
- **Breaking:** `ListDropdown#on_item_chosen=` and `#on_cursor_changed=` are gone, along with `AbstractWrappingField#on_enter=`. Reach the dropdown's rows through the new `ListDropdown#list` (`drop.list.on_item_chosen { … }`); `AbstractWrappingField#on_enter` is now an ordinary slot that still commits before your listener runs.
|
|
98
|
+
- **Breaking:** `AbstractStringField#on_escape` is an append-only list and `#default_on_escape` is gone, the blur it performed having become the `escape_clears_focus` flag. Write `field.escape_clears_focus = false` where you wrote `field.on_escape = nil`, and put the same line ahead of an `on_escape <<` that is meant to replace the blur rather than follow it.
|
|
99
|
+
- **Breaking:** `Component#repaint` takes a required `Tuile::Canvas`, and `#draw_text` / `#draw_char` / `#clear_background` are gone — the canvas applies the background. An override becomes `def repaint(canvas)` painting through `canvas.set_text` / `canvas.set_char` / `canvas.fill(area)`, `super` forwards the canvas unchanged, and `screen.canvas_for(component)` builds one for painting a single component.
|
|
100
|
+
- **Breaking:** the protected `Component#clear_inside_extent` and `#clear_outside_extent` hooks take the `Tuile::Canvas` too. Change an override to `def clear_inside_extent(canvas)` and paint through the canvas it is handed.
|
|
101
|
+
- **Breaking:** A canvas paints in the component's own coordinates — `(0, 0)` is its `rect`'s top-left, not the screen's. Drop the offset from every paint call (`canvas.set_text(rect.left + x, rect.top + y, …)` becomes `canvas.set_text(x, y, …)`) and pass `local_rect` where you passed `rect`.
|
|
102
|
+
- **Breaking:** `Component#rect` is measured **inside its parent**, so a container adds no position of its own. `child.rect = Rect.new(rect.left + x, rect.top + y, w, h)` becomes `Rect.new(x, y, w, h)`, "fill me" is `local_rect`, and anything wanting the screen — a dropdown's `anchor_to`, a spec reading the buffer — asks for `absolute_rect`. See `design/decisions.md` `D_relative_rect`.
|
|
103
|
+
- **Breaking:** A `Mouse::Event` reaches a component in that component's own coordinates, and `Component#cursor_position` answers in them. Drop the `event.x - rect.left` and `rect.top +` from every mouse handler and cursor position; `Screen#cursor_position` still reports screen coordinates, and `FakeScreen#click` / `#drag` still take them.
|
|
104
|
+
- **Breaking:** `Component#extent_rect` is gone — it was the parent-space form, and nothing asks in that space now. Use `local_extent_rect` (hit-testing, clearing) or `absolute_extent_rect` (anchoring an overlay).
|
|
105
|
+
- **Breaking:** `Tuile::VerticalScrollBar`, the geometry-only painter, is gone — the name now belongs to `Component::VerticalScrollBar`, the draggable bar in the tree, which also owns the two app-global glyphs. Move the knob: `Tuile::Component::VerticalScrollBar.handle_char = "▐"`.
|
|
106
|
+
- **Breaking:** `Component::AbstractStringField#text=` is removed — `value=` was the same write, and `text` stays as the reader. Replace `field.text = s` with `field.value = s` on a `TextField`, `PasswordField` or `TextArea`; `Label#text=` and `TextView#text=` are unaffected. See `design/decisions.md` `D_has_value`.
|
|
107
|
+
- **Breaking:** `Component::AbstractStringField#on_change` and its `ChangeEvent` are removed — `on_value_change` fired alongside it for every change. Subscribe with `field.on_value_change { |e| … e.value … }` in place of `field.on_change { |e| … e.text … }`. See `design/decisions.md` `D_has_value`.
|
|
108
|
+
- **Breaking:** `Component#handle_width_changed` is gone — `rect=` marks the component itself, so its `relayout` already runs after every change on either axis. Move the override's body into `relayout` (keying anything costly on the width it was built at), or into `handle_rect_changed(old_rect)` for a reaction to the change itself. See `design/decisions.md` `D_relayout`.
|
|
109
|
+
- **Breaking:** a `HasValue` includer's override point is `set_value(new_value, from_user:)`, never `value=`, which the contract suite now fails. Rename `def value=(v)` to `def set_value(v, from_user:)` and pass `from_user:` on to `super` or the editor's `set_value`.
|
|
110
|
+
|
|
111
|
+
## [0.16.0] - 2026-09-18
|
|
112
|
+
|
|
113
|
+
0.16.0 settles two vocabularies. Every override point is now `handle_foo` and
|
|
114
|
+
every listener slot `on_foo=`, with a trailing `?` marking exactly the handlers a
|
|
115
|
+
dispatcher routes, so a name says what it is and what it owes. The mouse is
|
|
116
|
+
rebuilt on that footing: `Tuile::Mouse` gives each wire event its own class, and
|
|
117
|
+
`Mouse::Router` owns every walk — resolving the path under the pointer, focusing,
|
|
118
|
+
bubbling a press to one claimant and holding the grab until the release — so no
|
|
119
|
+
widget hit-tests or `super`s a walk of its own again.
|
|
120
|
+
|
|
121
|
+
- Add `Tuile::Mouse` — one class per mouse event, `DownEvent` / `UpEvent` / `ScrollEvent` / `MoveEvent` off the wire plus the router-made `DragEvent` (which `Screen#handle_mouse` refuses, naming the `MoveEvent` to post instead), each including the `Mouse::Event` marker and its `point`; `Mouse.parse` replaces `MouseEvent.parse`. See `design/decisions.md` `D_mouse_dispatch`.
|
|
122
|
+
- Add `Tuile::Mouse::Router` — the dispatcher that owns every mouse walk: it resolves the path under the pointer, focuses, bubbles a press to one claimant, holds the grab and diffs the hovered chain. `Screen#handle_mouse` is its public door. See `design/decisions.md` `D_mouse_dispatch`.
|
|
123
|
+
- Add `Screen#grabbed` and `Screen#hovered` — the component holding the mouse grab, and the innermost one under the pointer.
|
|
124
|
+
- Add `capture_mouse: :drag` / `:hover` to `Screen#run_event_loop` — the rungs unlocking `handle_mouse_drag` (mode 1002) and `handle_mouse_move?` with the enter/exit hooks (1003). `true` still means `:clicks`, and an unknown level now raises. See `design/research.md` `R_mouse_reporting`.
|
|
125
|
+
- Add `FakeScreen#click` / `#press` / `#release` / `#scroll` / `#move` / `#drag` — a spec posts the gesture the terminal would, at screen coordinates, instead of calling a handler; `#drag` plays press-moves-release over the points handed it and interpolates none.
|
|
126
|
+
- Add a *Mouse* pane to `examples/sampler.rb` — a canvas that strokes `X` on a drag, trails on a hover and lifts on a right-drag, beside a log of the discrete events and a live pointer row; it takes the sampler to `capture_mouse: :hover`.
|
|
127
|
+
- Add `Component::DateTimeField` — a `DateField` and a `TimeField` side by side on one row behind a single `DateTime` value, with the two halves exposed read-only as `date_field` / `time_field` for tuning. See `design/decisions.md` `D_date_time_field`.
|
|
128
|
+
- Add `Component#clear_inside_extent` — the protected hook a container overrides to decline the default's blank of its extent, so ink it paints into a face cell no child covers stays off the wire. See `design/decisions.md` `D_extent`.
|
|
129
|
+
- Add `Component::AbstractWrappingField#notify_on_edit?` — the protected hook a field whose grammar is not prefix-closed answers `false`, settling `on_value_change` onto the commit gestures instead of firing it per edit. See `design/decisions.md` `D_date_field`.
|
|
130
|
+
- Add `Component#on_theme_changed` and `#on_locale_changed` readers — every listener slot is now a full `attr_accessor`, the two dual names that forced `attr_writer` having been renamed away. See `design/decisions.md` `D_handler_naming`.
|
|
131
|
+
- Add `Component::Notification::View` — the toast's own `TextView`, which refuses the wheel so queued messages wait for the ticker rather than becoming mouse-only scrollable text. See `design/decisions.md` `D_notification`.
|
|
132
|
+
- Fix `Component#repaint` leaving the cells of a declared `extent` that no child covers — a `Layout::Box`'s `spacing` column, its trailing slack, a hidden child's abandoned span — unblanked, so they kept stale glyphs across a resize. See `design/decisions.md` `D_extent`.
|
|
133
|
+
- **Breaking:** `Component#handle_mouse` is gone, and with it the walk every override had to `super` into: a component overrides one handler per event, three routed (`handle_mouse_down?`, `handle_mouse_scroll?`, `handle_mouse_move?`) and four not (`handle_mouse_up`, `handle_mouse_drag`, `handle_mouse_enter`, `handle_mouse_exit`). Move an old body into `handle_mouse_down?`, drop the `super` and the hit test the router now does, and return `true` when you acted. See `design/decisions.md` `D_mouse_dispatch`.
|
|
134
|
+
- **Breaking:** `Tuile::MouseEvent` is gone, replaced by one class per wire event under `Tuile::Mouse`. Build a press as `Mouse::DownEvent.new(:left, x, y)`, a release as `Mouse::UpEvent.new(x, y)` — it carries no button — and read `direction` on a `Mouse::ScrollEvent` where you read `button: :scroll_up`.
|
|
135
|
+
- **Breaking:** Click-to-focus moved into `Mouse::Router`, which focuses the innermost `focusable?` under the pointer before any handler runs. Delete a widget's own `extent_rect` hit test: a press only reaches it where the extent contains the point.
|
|
136
|
+
- **Breaking:** A press is claimed by exactly one component and stops there, where it used to reach every level containing the point, and the claimant holds the mouse grab until the release, any key or the next press. A container acting on presses aimed at its children must claim them itself.
|
|
137
|
+
- **Breaking:** `ScreenPane#handle_mouse` is gone; the pane answers the router's two tree questions instead, `#mouse_root_at` and `#dismissing_popups_outside`. Outside-click dismissal is unchanged.
|
|
138
|
+
- **Breaking:** `List::Cursor#handle_mouse` is now `#handle_mouse_down?`, so a custom cursor strategy renames its override. `List` and `TextView` now decline a wheel notch they cannot act on, which bubbles it to an ancestor scroller instead of swallowing it.
|
|
139
|
+
- **Breaking:** Every override hook is now `handle_*`, so `on_` names a listener slot and nothing else: `on_attached`, `on_detached`, `on_focus`, `on_blur`, `on_child_removed`, `on_child_visibility_changed`, `on_width_changed`, `on_text_mutated`, `on_caret_mutated`, `on_editor_change`, `on_half_change`, `on_theme_changed`, `on_locale_changed`, and `Screen`'s `on_color_scheme` / `on_background_color`. Rename each override and call `super` from it; every `on_foo=` slot keeps its name, so `label.on_theme_changed = …` is untouched. See `design/decisions.md` `D_handler_naming`.
|
|
140
|
+
- **Breaking:** The routed handlers that return a verdict now say so with a `?` — `Component#handle_key?`, `Screen#handle_key?`, `ScreenPane#handle_key?`, `AbstractStringField#handle_text_input_key?` and `MenuBar#handle_mnemonic?`. Rename each override and each direct call, `Testing.get(…).handle_key?(Keys::ENTER)` included; the return values are unchanged. See `design/decisions.md` `D_handler_naming`.
|
|
141
|
+
- **Breaking:** `Component#handle_paste` returns `void` rather than a Boolean. Drop the trailing `true` from an override; nothing read the answer. See `design/decisions.md` `D_bracketed_paste`.
|
|
142
|
+
- **Breaking:** `Component#on_tree` and `#on_shown_tree` are now `#walk_tree` and `#walk_shown_tree` — the `on_` prefix is reserved for listener slots, and these are traversals, not events. Rename the call; nothing else about either walk changed.
|
|
143
|
+
- **Breaking:** `EventQueue#on_loop_thread?` is now `#in_loop_thread?`, for the same reserved prefix. Same answer, same semantics — only the spelling moved.
|
|
144
|
+
- **Breaking:** `Component::DateField` and `Component::TimeField` fire `on_value_change` at the commit gestures — leaving the field, or ENTER — rather than per keystroke. A listener wanting the live reading polls `value`; a `value=`, an arrow-key step and a `clear` still announce themselves as they happen. See `design/decisions.md` `D_date_field`.
|
|
145
|
+
- **Breaking:** The contributor docs moved under `design/` and no longer ship in the packaged gem — `DECISIONS.md`, `TERMINOLOGY.md`, `RELEASING.md` and `ideas/` are now `design/decisions.md`, `terminology.md`, `releasing.md` and `design/ideas/`. Update any link you kept and read them on GitHub; nothing under `lib/`, `book/`, `examples/` or `sig/` changed.
|
|
146
|
+
- **Breaking:** `COMPARISON.md` and `design/requirements.md` are retired — the toolkit survey is now `design/research.md`'s `R_ruby_tui_toolkits` / `R_ratatui` / `R_charm_ruby` / `R_curses_bindings` entries, and the one requirement is the **Promises** section of `AGENTS.md`.
|
|
147
|
+
- **Breaking:** Remove `Theme#hint_color` and `Theme#hint` — a "de-emphasized text" shade is the global fg token Tuile declines to carry. Carry it as a `custom` token instead and render it with `Theme#fg(:hint, text)`, pairing it in a `ThemeDef` so it survives an appearance flip. See `design/decisions.md` `D_no_hint_color`.
|
|
148
|
+
- **Breaking:** `lib/tuile/AGENTS.md` and `lib/tuile/component/layout/AGENTS.md` are retired, and the two that remain trade their per-file lists for a sub-directory map. Their rules already lived in each symbol's rdoc and `design/decisions.md` `D_box_layouts`; `ls` is the file index.
|
|
149
|
+
- **Breaking:** `Component::ComboBox` no longer includes `Component::HasContent` — its inner `TextField` holds a transient query it owns, not content you populate — so `combo.content` and `combo.content=` are gone. A spec reaches the field with `Testing.get(Component::TextField, in: combo)`. See `design/decisions.md` `D_has_content`.
|
|
150
|
+
- **Breaking:** `Component::PickerWindow` no longer colors option captions; they paint in the terminal's own foreground. `Option#caption` is now a `StyledString`, coerced through `StyledString.parse`, so hand in a `StyledString` (or the ANSI String `Theme#fg` returns) to color one — per option.
|
|
151
|
+
|
|
3
152
|
## [0.15.0] - 2026-09-05
|
|
4
153
|
|
|
5
154
|
0.15.0 is about the form. A field can now report input its type cannot
|
|
@@ -14,60 +163,60 @@ component as if it were detached while it stays in the tree. And `Tuile::Testing
|
|
|
14
163
|
lets a spec drive that UI by looking components up instead of reading the
|
|
15
164
|
painted buffer.
|
|
16
165
|
|
|
17
|
-
- Add `Component#visible=` / `#visible?` — hides a component as if it were detached while it stays in the tree: it paints nothing, takes no space in a `Layout::Box`, and is unreachable by focus, Tab, keys, the mouse and `Testing.find`, while keeping its parent, rect, state and any running resource. See `
|
|
166
|
+
- Add `Component#visible=` / `#visible?` — hides a component as if it were detached while it stays in the tree: it paints nothing, takes no space in a `Layout::Box`, and is unreachable by focus, Tab, keys, the mouse and `Testing.find`, while keeping its parent, rect, state and any running resource. See `design/decisions.md` `D_visibility` and book ch7.
|
|
18
167
|
- Add `Component#on_shown_tree` — `on_tree` with hidden subtrees pruned; the walk every "can the user reach it" question takes, and the one a new focus traversal must use.
|
|
19
168
|
- Add `Component#on_child_visibility_changed` — the protected notice a container with layout arithmetic overrides to re-divide space when a direct child is hidden or shown; `Layout::Box` relayouts from it.
|
|
20
169
|
- Add a visibility rule to `Testing.find` / `.get`: they simulate a user, so a hidden component is never returned, and a failed lookup counts the hidden matches it excluded. `Screen#focused=` likewise raises on a hidden target, as it already did on a detached one.
|
|
21
|
-
- Add `Tuile::Testing` — `Testing.get` / `.find`, which locate a component in the tree by class, mixin, `id`, caption or a block, so a spec can drive the UI instead of only reading the painted buffer. A failed lookup dumps the tree it searched. See `
|
|
170
|
+
- Add `Tuile::Testing` — `Testing.get` / `.find`, which locate a component in the tree by class, mixin, `id`, caption or a block, so a spec can drive the UI instead of only reading the painted buffer. A failed lookup dumps the tree it searched. See `design/decisions.md` `D_component_lookup`.
|
|
22
171
|
- Add `Component#id` — a `Symbol` tag for finding a component again; nothing paints it and nothing enforces uniqueness, since `Testing.get` raising on two matches is the enforcement.
|
|
23
172
|
- Add `Component#inspect` — one line naming the class, `id`, rect and each mixin's contribution via the protected `inspect_details` hook, replacing an `Object#inspect` that walked `parent`, `children` and the `Screen`.
|
|
24
|
-
- Add `Tuile::Final` — the `final` keyword Ruby lacks: a class `extend`s it, marks the methods a subclass may not redefine, and calls `verify_final!` from its own `initialize`. See `
|
|
25
|
-
- Add `Component#default_bg_color` — a protected hook where a widget declares the background it paints itself; the level the resolution chain was missing, sitting under `bg_color` and over the parent's. See `
|
|
26
|
-
- Add `Component::BG_INHERIT` — assign it to `bg_color` to contribute no background of your own and take what surrounds you, skipping this component's `default_bg_color`; it is how a widget owned by a bigger one drops its well. See `
|
|
173
|
+
- Add `Tuile::Final` — the `final` keyword Ruby lacks: a class `extend`s it, marks the methods a subclass may not redefine, and calls `verify_final!` from its own `initialize`. See `design/decisions.md` `D_final_tree`.
|
|
174
|
+
- Add `Component#default_bg_color` — a protected hook where a widget declares the background it paints itself; the level the resolution chain was missing, sitting under `bg_color` and over the parent's. See `design/decisions.md` `D_bg_surface`.
|
|
175
|
+
- Add `Component::BG_INHERIT` — assign it to `bg_color` to contribute no background of your own and take what surrounds you, skipping this component's `default_bg_color`; it is how a widget owned by a bigger one drops its well. See `design/decisions.md` `D_bg_surface`.
|
|
27
176
|
- Add a state-keyed form of `Component#bg_color`: `{ normal: …, active: … }` over `Component::BG_STATES`, so an app can keep a focus shade of its own rather than flattening one. An absent key falls through to the next level of the chain.
|
|
28
|
-
- Add `Component::HasBadInput` — `bad_input?` / `bad_input_message`, the report a field owes when its input is something its value cannot represent (a lone `-` in an `IntegerField` reads `nil`, exactly like an empty one). Included by the three numeric fields; ask it before `empty?`. See `
|
|
29
|
-
- Add `Component::HasValidation` — `error_message` plus `on_error_message_change`, the verdict slot a validator writes and the field shows as a red well; included by `HasValue`, so every field has it, and `bad_input?` reddens the well too. See `
|
|
177
|
+
- Add `Component::HasBadInput` — `bad_input?` / `bad_input_message`, the report a field owes when its input is something its value cannot represent (a lone `-` in an `IntegerField` reads `nil`, exactly like an empty one). Included by the three numeric fields; ask it before `empty?`. See `design/decisions.md` `D_bad_input` and book ch7.
|
|
178
|
+
- Add `Component::HasValidation` — `error_message` plus `on_error_message_change`, the verdict slot a validator writes and the field shows as a red well; included by `HasValue`, so every field has it, and `bad_input?` reddens the well too. See `design/decisions.md` `D_has_validation` and book ch7.
|
|
30
179
|
- Add `Component#error_bg_color` — a protected hook for the background a component paints while signalling an error, resolving *above* `bg_color` so tinting a panel cannot switch the signal off on the fields inside it.
|
|
31
180
|
- Add `VerticalScrollBar.handle_char=` / `.track_char=` — the two bar glyphs as an app-global look-and-feel knob (`▐` over `│` for a lazygit-style bar); a glyph that is not exactly one cluster one column wide is rejected at assignment.
|
|
32
|
-
- Add `Component::HasPlaceholder` — `placeholder=` paints a hint into a field's empty well (`dd.mm.yyyy`) in the new barely-visible `Theme#placeholder_color`, never touching the value; carried by `TextField`, forwarded by the four composed fields. See `
|
|
33
|
-
- Add `Component::AbstractWrappingField` — the base for a field that carries a typed value but paints nothing itself, owning and hiding one inner editor, committing on both commit gestures and offering subclasses an `on_editor_change` hook; `IntegerField`, `FloatField` and `BigDecimalField` are built on it. See `
|
|
34
|
-
- Add `Component::DateField` — a typed `Date`/`nil` field over a list of strftime `formats` taken from `Screen#locale`: parsing tries them in order, leaving the field rewrites the buffer in the first, Up/Down step a day, and unparseable input is reported through `bad_input?` rather than filtered. See `
|
|
35
|
-
- Add `Component::TimeField` — a typed `Time`/`nil` time of day on a fixed epoch date, spelled the way `Screen#locale` says: `step` is both the Up/Down stride and the precision, PageUp/PageDown step an hour, and unparseable input is reported through `bad_input?`. See `
|
|
36
|
-
- Add `Locale#time_formats` — the strftime patterns a time field accepts, detected from glibc's `t_fmt` and carrying its full precision, since dropping the seconds is a form field's policy and a clock display wants them. See `
|
|
37
|
-
- Add `Component::HasBadInput#bad_input_settled?` — the protected gate deciding whether bad input may paint the invalid well *yet*; `true` by default (the numeric fields redden as you type), latched to the commit gestures by `DateField`, whose every prefix is bad input. The `bad_input?` pull is unaffected. See `
|
|
38
|
-
- Add `Tuile::Locale` — a frozen value type of formatting conventions (date formats, calendar, month/weekday names, decimal separator), detected from `locale -k` by `Locale.system` and never holding prose. `Locale::ISO` is the only preset and the fallback. See `
|
|
39
|
-
- Add `Screen#locale` / `#locale=` — the session's conventions, seeded at construction; assigning fires `Component#on_locale_changed` across the tree and repaints it, so a `DateField` respells the dates it holds. See `
|
|
181
|
+
- Add `Component::HasPlaceholder` — `placeholder=` paints a hint into a field's empty well (`dd.mm.yyyy`) in the new barely-visible `Theme#placeholder_color`, never touching the value; carried by `TextField`, forwarded by the four composed fields. See `design/decisions.md` `D_placeholder` and book ch7.
|
|
182
|
+
- Add `Component::AbstractWrappingField` — the base for a field that carries a typed value but paints nothing itself, owning and hiding one inner editor, committing on both commit gestures and offering subclasses an `on_editor_change` hook; `IntegerField`, `FloatField` and `BigDecimalField` are built on it. See `design/decisions.md` `D_wrapping_field`.
|
|
183
|
+
- Add `Component::DateField` — a typed `Date`/`nil` field over a list of strftime `formats` taken from `Screen#locale`: parsing tries them in order, leaving the field rewrites the buffer in the first, Up/Down step a day, and unparseable input is reported through `bad_input?` rather than filtered. See `design/decisions.md` `D_date_field` and book ch7.
|
|
184
|
+
- Add `Component::TimeField` — a typed `Time`/`nil` time of day on a fixed epoch date, spelled the way `Screen#locale` says: `step` is both the Up/Down stride and the precision, PageUp/PageDown step an hour, and unparseable input is reported through `bad_input?`. See `design/decisions.md` `D_time_field` and book ch7.
|
|
185
|
+
- Add `Locale#time_formats` — the strftime patterns a time field accepts, detected from glibc's `t_fmt` and carrying its full precision, since dropping the seconds is a form field's policy and a clock display wants them. See `design/decisions.md` `D_time_field` and book ch10.
|
|
186
|
+
- Add `Component::HasBadInput#bad_input_settled?` — the protected gate deciding whether bad input may paint the invalid well *yet*; `true` by default (the numeric fields redden as you type), latched to the commit gestures by `DateField`, whose every prefix is bad input. The `bad_input?` pull is unaffected. See `design/decisions.md` `D_has_validation`.
|
|
187
|
+
- Add `Tuile::Locale` — a frozen value type of formatting conventions (date formats, calendar, month/weekday names, decimal separator), detected from `locale -k` by `Locale.system` and never holding prose. `Locale::ISO` is the only preset and the fallback. See `design/decisions.md` `D_locale` and book ch10.
|
|
188
|
+
- Add `Screen#locale` / `#locale=` — the session's conventions, seeded at construction; assigning fires `Component#on_locale_changed` across the tree and repaints it, so a `DateField` respells the dates it holds. See `design/decisions.md` `D_locale`.
|
|
40
189
|
- Add `Component#on_locale_changed` — the protected hook (plus an assignable listener) for state derived from the old conventions and *pushed* somewhere; anything read at paint or parse time needs nothing, since the locale change repaints everything.
|
|
41
190
|
- Add `Screen.instance?` — whether `Screen.instance` would answer rather than raise, for code that must work with no screen in the process, as a detached component tree legitimately is.
|
|
42
|
-
- Add `Component::DateField#calendar_start` / `#calendar_start=` — the calendar the field parses and formats against, inheriting `Screen#locale` until you set one and again when set back to `nil`; proleptic Gregorian by default, not Ruby's `Date::ITALY`. See `
|
|
191
|
+
- Add `Component::DateField#calendar_start` / `#calendar_start=` — the calendar the field parses and formats against, inheriting `Screen#locale` until you set one and again when set back to `nil`; proleptic Gregorian by default, not Ruby's `Date::ITALY`. See `design/decisions.md` `D_date_field`.
|
|
43
192
|
- Add a `DateField` pane to `examples/sampler.rb` under Input → Typed, where typing `4.9.2026` and Tabbing away shows the buffer rewrite.
|
|
44
|
-
- Add readline's kill keys to both text inputs (`AbstractStringField`): `Ctrl+W` deletes the word behind the caret, `Ctrl+U` everything before it — back to index 0 in a `TextField`, to the caret's row start in a `TextArea`. See `
|
|
193
|
+
- Add readline's kill keys to both text inputs (`AbstractStringField`): `Ctrl+W` deletes the word behind the caret, `Ctrl+U` everything before it — back to index 0 in a `TextField`, to the caret's row start in a `TextArea`. See `design/decisions.md` `D_kill_keys` and book ch7.
|
|
45
194
|
- Add `Component#invalidate_children` — the protected repaint cascade as a named call, so a container that paints its own rect can skip the default's blanket clear without silently dropping the one line it may not.
|
|
46
|
-
- Add an `at:` keyword to `Component::Layout::Box#add`, placing the child at an index instead of appending, so a pane hidden with `#remove` goes back where it was. See `
|
|
195
|
+
- Add an `at:` keyword to `Component::Layout::Box#add`, placing the child at an index instead of appending, so a pane hidden with `#remove` goes back where it was. See `design/decisions.md` `D_empty_ancestor`.
|
|
47
196
|
- Add `Component::Layout::Box#constrain` — re-constrains a child already added, a `nil` axis keeping whatever it had, so a pane can be collapsed with `Fixed[0]` without removing and re-adding it.
|
|
48
|
-
- Add `Component#on_blur` — the protected mirror of `on_focus`, fired on the component that just lost focus, so a field can commit or canonicalize what the user typed even when they Tab away. See `
|
|
49
|
-
- Fix `Component::Window` and its subclasses re-emitting their whole border on every unchanged repaint — 925 bytes for an 80x25 window, paid on each focus change — by no longer blanking the rect it is about to repaint. See `
|
|
197
|
+
- Add `Component#on_blur` — the protected mirror of `on_focus`, fired on the component that just lost focus, so a field can commit or canonicalize what the user typed even when they Tab away. See `design/decisions.md` `D_on_blur` and book ch5.
|
|
198
|
+
- Fix `Component::Window` and its subclasses re-emitting their whole border on every unchanged repaint — 925 bytes for an 80x25 window, paid on each focus change — by no longer blanking the rect it is about to repaint. See `design/decisions.md` `D_component_contract`.
|
|
50
199
|
- Fix a `Component::Window` with `scrollbar = true` painting its right border under the content's scrollbar, dirtying that column into every frame; the border now leaves the column it gave away alone.
|
|
51
|
-
- Fix `Component::Layout::Box` stranding its children at their old rects when its own rect went empty, so the next full repaint — which any popup close runs — painted the hidden subtree back at stale coordinates. See `
|
|
200
|
+
- Fix `Component::Layout::Box` stranding its children at their old rects when its own rect went empty, so the next full repaint — which any popup close runs — painted the hidden subtree back at stale coordinates. See `design/decisions.md` `D_empty_ancestor`.
|
|
52
201
|
- Fix `Screen#repaint` painting a component that sits under an empty-rect ancestor: the drain filter now drops it, as it already drops a detached one, so a container that forgets to zero its children is inert rather than wrong.
|
|
53
202
|
- Fix `Screen`'s pane carrying no rect until the first `#layout`, which runs from the event loop; it is sized at construction from `#size`, so the tree is not filtered out of the repaint as sitting under an empty ancestor.
|
|
54
|
-
- Fix `Component::IntegerField`, `FloatField` and `BigDecimalField` accepting *pasted* text their own filter forbids — `"abc"` pasted into an integer field left it showing `abc42` while `value` silently read `nil`. Each now filters in its inner field's `insert_text`, which typing and pasting both pass through. See `
|
|
203
|
+
- Fix `Component::IntegerField`, `FloatField` and `BigDecimalField` accepting *pasted* text their own filter forbids — `"abc"` pasted into an integer field left it showing `abc42` while `value` silently read `nil`. Each now filters in its inner field's `insert_text`, which typing and pasting both pass through. See `design/decisions.md` `D_input_filters`.
|
|
55
204
|
- Fix the numeric fields occupying their inner field's `on_key` slot for Up/Down stepping, so an app that set `field.content.on_key` silently killed the spinner. They use `on_key_up` / `on_key_down` now, and `on_key` is free.
|
|
56
205
|
- Fix `Component#bg_color` being inert on `Component::TextField`, `TextArea`, `Select` and `ComboBox`, which reached past it to the theme — contradicting what `bg_color=` and `effective_bg_color` documented. Setting one on a field now wins over its well.
|
|
57
|
-
- Fix `Component::TextView` wrapping text right up against a visible scrollbar (`…to show the█`): the bar now reserves a blank column beside it, as `List` rows always have. See `
|
|
58
|
-
- Fix the scrollbar painting a solid full-height `█` when the content fits: with nothing to scroll the bar now shows only its track, keeping its column and its content width. See `
|
|
206
|
+
- Fix `Component::TextView` wrapping text right up against a visible scrollbar (`…to show the█`): the bar now reserves a blank column beside it, as `List` rows always have. See `design/decisions.md` `D_scrollbar_reserve`.
|
|
207
|
+
- Fix the scrollbar painting a solid full-height `█` when the content fits: with nothing to scroll the bar now shows only its track, keeping its column and its content width. See `design/decisions.md` `D_scrollbar_ink`.
|
|
59
208
|
- **Breaking:** `Locale::DateFormats::DIRECTIVE`, `::LOCALE_LOOKALIKES` and `.each_directive` moved to the new `Locale::Formats`, the strftime lexer `DateFormats` and `TimeFormats` now share. Reference them under `Locale::Formats`; `REF`, `HINTS`, `.validate`, `.humanize` and `.widen` are unmoved.
|
|
60
|
-
- **Breaking:** `Component::AbstractStringField#on_key` is gone — the pre-dispatch interceptor that could claim any key. Subclass and override `handle_text_input_key` instead, calling `super` for keys you don't claim; a key you decline still bubbles to an ancestor. See `
|
|
61
|
-
- **Breaking:** A paste is delivered to `Screen#focused` alone and no longer bubbles up the focus chain; `ScreenPane#handle_paste` returns false when the focused component declines. A container that relied on catching a paste its child refused must move that `handle_paste` onto the focusable component. See `
|
|
62
|
-
- **Breaking:** `Component::TextField#preprocess_paste` keeps the paste's first line instead of flattening every newline to a space, so a copied whole line no longer arrives with an invisible trailing space. Override it to restore the old behavior. See `
|
|
63
|
-
- **Breaking:** `Theme` carries a further token, `placeholder_color`, the ink `Component::HasPlaceholder` paints an empty field's hint in. A hand-rolled `Theme.new(...)` must now pass it; `Theme::DARK.with(...)` is unaffected. See `
|
|
64
|
-
- **Breaking:** `Theme` carries four further tokens — `scrollbar_color`, plus `error_color` / `error_bg_color` / `error_active_bg_color` — so a scrollbar is themed rather than painted in the terminal's default foreground, and an invalid field shows a well that still reads as focused. A hand-rolled `Theme.new(...)` must now pass all four; `Theme::DARK.with(...)` is unaffected. See `
|
|
209
|
+
- **Breaking:** `Component::AbstractStringField#on_key` is gone — the pre-dispatch interceptor that could claim any key. Subclass and override `handle_text_input_key` instead, calling `super` for keys you don't claim; a key you decline still bubbles to an ancestor. See `design/decisions.md` `D_no_key_interceptor` and book ch7.
|
|
210
|
+
- **Breaking:** A paste is delivered to `Screen#focused` alone and no longer bubbles up the focus chain; `ScreenPane#handle_paste` returns false when the focused component declines. A container that relied on catching a paste its child refused must move that `handle_paste` onto the focusable component. See `design/decisions.md` `D_bracketed_paste`.
|
|
211
|
+
- **Breaking:** `Component::TextField#preprocess_paste` keeps the paste's first line instead of flattening every newline to a space, so a copied whole line no longer arrives with an invisible trailing space. Override it to restore the old behavior. See `design/decisions.md` `D_paste_newlines`.
|
|
212
|
+
- **Breaking:** `Theme` carries a further token, `placeholder_color`, the ink `Component::HasPlaceholder` paints an empty field's hint in. A hand-rolled `Theme.new(...)` must now pass it; `Theme::DARK.with(...)` is unaffected. See `design/decisions.md` `D_placeholder`.
|
|
213
|
+
- **Breaking:** `Theme` carries four further tokens — `scrollbar_color`, plus `error_color` / `error_bg_color` / `error_active_bg_color` — so a scrollbar is themed rather than painted in the terminal's default foreground, and an invalid field shows a well that still reads as focused. A hand-rolled `Theme.new(...)` must now pass all four; `Theme::DARK.with(...)` is unaffected. See `design/decisions.md` `D_scrollbar_ink` and `D_has_validation`.
|
|
65
214
|
- **Breaking:** `Component::Label#bg` / `#bg=` are gone. Use `bg_color`, which now covers the text, the trailing padding and the blank rows alike; to override a span's own background as `#bg` did, restyle the text with `label.text = text.with_bg(c)`.
|
|
66
215
|
- **Breaking:** `Component#effective_bg_color` is protected and final. A widget declares its own background by overriding `default_bg_color`; nothing else needs to read the resolved value, since `draw_text` / `draw_char` / `clear_background` apply it.
|
|
67
216
|
- **Breaking:** `Component::AbstractStringField#background` is gone. A subclass painting its own row calls `draw_text`, which applies the well; to change the well, override `default_bg_color`.
|
|
68
217
|
- **Breaking:** `Component::FINAL_METHODS` is gone, replaced by the `Tuile::Final` declaration it became. Read `Component.final_methods` instead; `Component.verify_final!` is unchanged.
|
|
69
|
-
- **Breaking:** `content` / `content=` are gone from `Component::IntegerField`, `FloatField` and `BigDecimalField`, whose inner editor is private machinery. Use the field's own `placeholder` / `on_enter` / `cursor_position` / `clear`, write a forwarder for anything else, and reach the editor from a *spec* with `Testing.get(Component::TextField, in: field)`. See `
|
|
70
|
-
- **Breaking:** `content` is now `list` on `Component::CheckboxGroup` and `RadioGroup`, and read-only — an app tunes the composed `List` but must not swap it, since the group's renderer and selection are wired into that one. Rename `group.content` to `group.list`; there is no `content=` replacement. See `
|
|
218
|
+
- **Breaking:** `content` / `content=` are gone from `Component::IntegerField`, `FloatField` and `BigDecimalField`, whose inner editor is private machinery. Use the field's own `placeholder` / `on_enter` / `cursor_position` / `clear`, write a forwarder for anything else, and reach the editor from a *spec* with `Testing.get(Component::TextField, in: field)`. See `design/decisions.md` `D_wrapping_field`.
|
|
219
|
+
- **Breaking:** `content` is now `list` on `Component::CheckboxGroup` and `RadioGroup`, and read-only — an app tunes the composed `List` but must not swap it, since the group's renderer and selection are wired into that one. Rename `group.content` to `group.list`; there is no `content=` replacement. See `design/decisions.md` `D_has_content`.
|
|
71
220
|
|
|
72
221
|
## [0.14.0] - 2026-08-31
|
|
73
222
|
|
|
@@ -81,22 +230,22 @@ terminal untouched by the app — and the terminal's own background becomes
|
|
|
81
230
|
readable as a `Color`. On top of that sit the dialogs: `ConfirmWindow`, an
|
|
82
231
|
`InfoWindow` prose body, and `LogTextView` for a frameless log.
|
|
83
232
|
|
|
84
|
-
- Add `Component::Overlay` — the bare floating layer every overlay is built on: a mount/dismiss lifecycle, `owner`, `on_close` and outside-click dismissal, at a rect the caller assigns. See `
|
|
85
|
-
- Add `Component::Slot` — a one-child region for content that may be absent, arrive late, or be swapped; a container gives each of its regions one, wired at construction, so a swap never has to compute an insert index. See `
|
|
86
|
-
- Add `Component#extent`, `#extent_rect` and `#clear_outside_extent` — the size a widget actually paints inside the rect it was assigned (`nil` by default, meaning undeclared), and what it hit-tests, anchors and blanks against. See `
|
|
233
|
+
- Add `Component::Overlay` — the bare floating layer every overlay is built on: a mount/dismiss lifecycle, `owner`, `on_close` and outside-click dismissal, at a rect the caller assigns. See `design/decisions.md` `D_overlay` and book ch7.
|
|
234
|
+
- Add `Component::Slot` — a one-child region for content that may be absent, arrive late, or be swapped; a container gives each of its regions one, wired at construction, so a swap never has to compute an insert index. See `design/decisions.md` `D_slots`.
|
|
235
|
+
- Add `Component#extent`, `#extent_rect` and `#clear_outside_extent` — the size a widget actually paints inside the rect it was assigned (`nil` by default, meaning undeclared), and what it hit-tests, anchors and blanks against. See `design/decisions.md` `D_extent`.
|
|
87
236
|
- Add `Component::Select#extent` and `Component::ComboBox#extent` — the one row the widget paints, which is what its dropdown anchors to; a single-slot container assigns these widgets far more height than they use.
|
|
88
|
-
- Add `Component#size`, `#width` and `#height` — read-only shorthands for the matching `rect` field. Reports of the geometry a parent assigned, never requests: there is no writer and no container consults them. See `
|
|
237
|
+
- Add `Component#size`, `#width` and `#height` — read-only shorthands for the matching `rect` field. Reports of the geometry a parent assigned, never requests: there is no writer and no container consults them. See `design/decisions.md` `D_declared_size`.
|
|
89
238
|
- Add `Component#handle_mouse` routing to every child whose `rect` contains the point, so a container gets click delivery without writing one; a widget that resolves clicks itself still overrides without `super`.
|
|
90
|
-
- Add `Component::ConfirmWindow` — the confirm/alert dialog: a caption, a wrapping message and a row of buttons, opened by the `alert` / `confirm` / `yes_no` factories or built button-by-button via `#button`; every route out of it fires `on_dismiss` exactly once. See `
|
|
91
|
-
- Add `Component::InfoWindow#message=` — the prose body, wrapped by a scrollable `TextView` (`ConfirmWindow`'s seam); `#lines=` stays as the rows presentation, and the constructor and `.open` pick the presentation by the body's type. See `
|
|
239
|
+
- Add `Component::ConfirmWindow` — the confirm/alert dialog: a caption, a wrapping message and a row of buttons, opened by the `alert` / `confirm` / `yes_no` factories or built button-by-button via `#button`; every route out of it fires `on_dismiss` exactly once. See `design/decisions.md` `D_confirm_window` and book ch7.
|
|
240
|
+
- Add `Component::InfoWindow#message=` — the prose body, wrapped by a scrollable `TextView` (`ConfirmWindow`'s seam); `#lines=` stays as the rows presentation, and the constructor and `.open` pick the presentation by the body's type. See `design/decisions.md` `D_info_window_body`.
|
|
92
241
|
- Add `Component::LogTextView` — `LogWindow`'s innards as a standalone `TextView`, so a frameless pane composes the log view directly: the any-thread `#log` and the `IO` adapter live on the view, and `LogWindow` reduces to a `Window` framing one (`LogWindow::IO` stays as an alias).
|
|
93
|
-
- Add `Screen#color_depth` and `ColorDepth.detect` — how many colors the terminal can show (`:truecolor` / `:palette256` / `:ansi16`), detected from `COLORTERM` and `TERM` at construction and overridable with `TUILE_COLOR_DEPTH`. See `
|
|
242
|
+
- Add `Screen#color_depth` and `ColorDepth.detect` — how many colors the terminal can show (`:truecolor` / `:palette256` / `:ansi16`), detected from `COLORTERM` and `TERM` at construction and overridable with `TUILE_COLOR_DEPTH`. See `design/decisions.md` `D_color_depth` and book ch6.
|
|
94
243
|
- Add `Color#quantize` — this color as the nearest one a depth can show, returning the receiver when nothing needs degrading. `Buffer#flush` applies it to every color it emits, so a computed RGB tint renders on a 256-color terminal without the app quantizing anything.
|
|
95
244
|
- Add a `color_depth:` keyword to `Buffer.new`, which degrades colors as it flushes them; cells keep whatever color a component painted, so `region_ansi` and friends still report it unchanged.
|
|
96
245
|
- Add a pinned `:truecolor` depth to `FakeScreen`, so a spec asserting frame bytes is unaffected by the runner's `COLORTERM`; a PTY spec asserting bytes passes `TUILE_COLOR_DEPTH` to the child instead.
|
|
97
|
-
- Add `Screen#background_color` — the terminal's own background as a `Color`, nil when it reported none; re-probed on every OS appearance flip, and a changed color fires `Component#on_theme_changed`. See `
|
|
246
|
+
- Add `Screen#background_color` — the terminal's own background as a `Color`, nil when it reported none; re-probed on every OS appearance flip, and a changed color fires `Component#on_theme_changed`. See `design/decisions.md` `D_background_rgb` and book ch6.
|
|
98
247
|
- Add `FakeScreen#background_color=` — plays the terminal answering the re-probe, so a spec can drive app code that derives colors from it.
|
|
99
|
-
- Add `StyledString::Style#inverse` — SGR 7 reverse video, swapping whatever fg/bg are in effect at the cell: parsed and emitted like the other attributes, applied whole-string via `StyledString#with_inverse`, and skipped by `#under_bg` the way an explicit bg is. See `
|
|
248
|
+
- Add `StyledString::Style#inverse` — SGR 7 reverse video, swapping whatever fg/bg are in effect at the cell: parsed and emitted like the other attributes, applied whole-string via `StyledString#with_inverse`, and skipped by `#under_bg` the way an explicit bg is. See `design/decisions.md` `D_inverse`.
|
|
100
249
|
- Add a *ConfirmWindow* pane to `examples/sampler.rb` — the three factories, the layer-1 three-way builder and a scrolling Terms-of-Service dialog, with a status row naming which callback each route out of a dialog landed in.
|
|
101
250
|
- Add a terminal-derived tint to the *Background* pane of `examples/sampler.rb` — the borderless-pane step of +10 per channel off `Screen#background_color`, re-derived in `on_theme_changed` and degrading to a "none reported" entry.
|
|
102
251
|
- Fix a click on `Component::Window` chrome not landing focus on the window, which its `focusable?` has claimed all along.
|
|
@@ -104,11 +253,11 @@ readable as a `Color`. On top of that sit the dialogs: `ConfirmWindow`, an
|
|
|
104
253
|
- Fix `Component::Select` opening its dropdown from a click anywhere in its rect, including the tail its own `repaint` clears — it now hit-tests its `extent`, the one row it paints, as `Checkbox` already did on the width axis.
|
|
105
254
|
- Fix a click on a `Component::ListDropdown` margin being able to land focus outside the key scope, killing every keystroke until Tab; the dropdown is now non-focusable by inheritance rather than by geometry.
|
|
106
255
|
- Fix a lower popup's content bleeding through a popup stacked over it: `Screen#repaint`'s drain loop now re-asserts every popup above any layer that repainted, so a repaint cascade spilling into a later iteration can no longer paint over the popups on top.
|
|
107
|
-
- Fix `Screen#theme=` dying with `NoMethodError` when a component overrode `on_theme_changed` under `protected`, aborting the restyle mid-walk. The hook is now protected plumbing fanned out with `__send__`, so an override may declare any visibility. See `
|
|
108
|
-
- **Breaking:** `Component#children`, `#parent`, `#parent=`, `#add_child`, `#remove_child` and `#detach_child` are final — overriding any of them raises `Tuile::Error` at construction. Derive nothing: reparent through `add_child` / `remove_child` / `detach_child`, and hold a `Component::Slot` for a swappable region. See `
|
|
256
|
+
- Fix `Screen#theme=` dying with `NoMethodError` when a component overrode `on_theme_changed` under `protected`, aborting the restyle mid-walk. The hook is now protected plumbing fanned out with `__send__`, so an override may declare any visibility. See `design/decisions.md` `D_hook_visibility`.
|
|
257
|
+
- **Breaking:** `Component#children`, `#parent`, `#parent=`, `#add_child`, `#remove_child` and `#detach_child` are final — overriding any of them raises `Tuile::Error` at construction. Derive nothing: reparent through `add_child` / `remove_child` / `detach_child`, and hold a `Component::Slot` for a swappable region. See `design/decisions.md` `D_final_tree`.
|
|
109
258
|
- **Breaking:** a `Component::Window` footer now lives in a `Component::Slot`, so `window.children` always holds that slot and `footer.parent` is the slot rather than the window. Reach the footer through `window.footer`, which is unchanged.
|
|
110
259
|
- **Breaking:** `Component#extent` is a `Size` (or `nil`) rather than a `Rect`, and `Button`, `Checkbox`, `Tabs` and `MenuBar` narrowed with it — an extent always sits at the rect's top-left. Read `#extent_rect` where you need the positioned rectangle.
|
|
111
|
-
- **Breaking:** `Component::Popup#size` is renamed `#declared_size`, and the `Popup.new` / `Component::InfoWindow.open` keyword with it, freeing `size` to mean `rect.size` on any component. Rename the accessor and the keyword at your call sites. See `
|
|
260
|
+
- **Breaking:** `Component::Popup#size` is renamed `#declared_size`, and the `Popup.new` / `Component::InfoWindow.open` keyword with it, freeing `size` to mean `rect.size` on any component. Rename the accessor and the keyword at your call sites. See `design/decisions.md` `D_declared_size`.
|
|
112
261
|
- **Breaking:** `Component::Popup` is always modal — `Popup.new(modal: false)` now raises `ArgumentError`. Build a non-modal overlay with `Component::Overlay.new`, which carries the same lifecycle, `owner`, `on_close` and `close_on_outside_click` members, and place it with `rect=`.
|
|
113
262
|
- **Breaking:** `Component::Notification` and `Component::ListDropdown` subclass `Component::Overlay` rather than `Component::Popup`, neither ever having been modal. An `is_a?(Popup)` test over the popup stack becomes `is_a?(Overlay)`.
|
|
114
263
|
- **Breaking:** `TerminalBackground.detect` returns a `TerminalBackground::Result` (`scheme` plus the reported `color`, nil under the `COLORFGBG` fallback) instead of a bare `Symbol`; nil still means undetectable. Call `.scheme` on the result where you read the symbol.
|
|
@@ -122,38 +271,38 @@ fills the terminal and an app builds its own status line. A paste also stops
|
|
|
122
271
|
being a burst of keystrokes, arriving as one `Component#handle_paste` instead
|
|
123
272
|
of one keystroke per character.
|
|
124
273
|
|
|
125
|
-
- Add `Screen#on_focus_changed=` — a no-arg callback fired after the focused component *changes*, including to and from `nil` and on the focus repair a closing popup runs. Edge-triggered, so re-focusing what already has focus fires nothing. See `
|
|
126
|
-
- Add `mnemonic:` to `Component::MenuBar#add_item` and `MenuBar::Item#add_item` — a letter that activates the item, underlined in its caption, matched level-scoped with no fallback, so only siblings can clash (which raises). See `
|
|
274
|
+
- Add `Screen#on_focus_changed=` — a no-arg callback fired after the focused component *changes*, including to and from `nil` and on the focus repair a closing popup runs. Edge-triggered, so re-focusing what already has focus fires nothing. See `design/decisions.md` `D_status_bar` and book ch5.
|
|
275
|
+
- Add `mnemonic:` to `Component::MenuBar#add_item` and `MenuBar::Item#add_item` — a letter that activates the item, underlined in its caption, matched level-scoped with no fallback, so only siblings can clash (which raises). See `design/decisions.md` `D_menu_bar` and book ch7.
|
|
127
276
|
- Add `Component::List#select` and `Component::ListDropdown#select` — moves the cursor to an item by index, scrolling it into view and firing `on_cursor_changed`; the positional member of the `select_next` / `select_prev` family.
|
|
128
|
-
- Add `Component::MenuBar` — a one-row strip of menu captions, each dropping a cascade of submenus that nests without limit, built from `MenuBar::Item` handles minted and nested by `#add_item`, each carrying an optional no-arg `on_click`. See `
|
|
277
|
+
- Add `Component::MenuBar` — a one-row strip of menu captions, each dropping a cascade of submenus that nests without limit, built from `MenuBar::Item` handles minted and nested by `#add_item`, each carrying an optional no-arg `on_click`. See `design/decisions.md` `D_menu_bar` and book ch7.
|
|
129
278
|
- Add a `MenuBar` shell to `examples/sampler.rb` — the side nav list becomes a menu bar grouped like the README's Components table, plus a `ComboBox` jump box at its right end, and the demo window fills everything below.
|
|
130
279
|
- Add a *MenuBar* pane to `examples/sampler.rb` — a three-deep File menu, an Edit menu, a top-level leaf that acts as a button, and a status line naming the last activated item.
|
|
131
280
|
- Add `Component::ListDropdown#anchor_beside` — places a panel against a row's right edge, flipping left when there is no room and sliding vertically, which is the cascading-submenu counterpart of `#anchor_to`.
|
|
132
281
|
- Add `Component::ListDropdown#cursor_row_rect` and `#on_cursor_changed=` — the highlighted row's rect, and the highlight-moved pass-through a cascading driver needs to drop the panels below the row it left.
|
|
133
|
-
- Add `Component::Tabs` — a one-row strip of captions with one selected: Left/Right switch immediately, a click selects, `#on_tab_selected` reports every change (`nil, nil` once the last tab goes), and tabs are `Tabs::Tab` handles minted by `#add_tab`. See `
|
|
282
|
+
- Add `Component::Tabs` — a one-row strip of captions with one selected: Left/Right switch immediately, a click selects, `#on_tab_selected` reports every change (`nil, nil` once the last tab goes), and tabs are `Tabs::Tab` handles minted by `#add_tab`. See `design/decisions.md` `D_tabs` and book ch7.
|
|
134
283
|
- Add horizontal scrolling to `Component::Tabs` and `Component::MenuBar` — a strip narrower than its captions now scrolls to keep the selected or highlighted caption whole in view, instead of clipping it.
|
|
135
284
|
- Add a *TabSheet* pane to `examples/sampler.rb` — three tabs over a form, a `List` and a `TextView`, with a status line reporting every pane's state on each switch, so a hidden pane keeping its scroll position is visible rather than asserted.
|
|
136
|
-
- Add `Component::TabSheet` — a `Tabs` strip on its top row plus the selected tab's pane below it, added with `#add_tab(caption, pane)`; unselected panes are *detached* rather than hidden by a flag, so they keep their state and stay out of the Tab cycle. See `
|
|
285
|
+
- Add `Component::TabSheet` — a `Tabs` strip on its top row plus the selected tab's pane below it, added with `#add_tab(caption, pane)`; unselected panes are *detached* rather than hidden by a flag, so they keep their state and stay out of the Tab cycle. See `design/decisions.md` `D_tabs`.
|
|
137
286
|
- Add `Screen#beep` and `Ansi::BEL` — rings the terminal bell for a keystroke that went nowhere. It writes immediately rather than riding the next frame, since the keys worth beeping at are precisely the ones that invalidate nothing.
|
|
138
287
|
- Add `StyledString#with_underline` — applies underline to every span, preserving each span's colors and other attributes; the underline counterpart of `#with_bold`. Slice and rejoin to underline part of a string, as a one-character mnemonic cue does.
|
|
139
288
|
- Add `StyledString#with_bold` — applies bold to every span, preserving each span's colors and other attributes; the bold-attribute counterpart of `#with_fg` / `#with_bg`. There is no `under_bold`, since bold has no inherited-unset state.
|
|
140
|
-
- Add `Component#handle_paste` — pasted text, whole and `\n`-normalized, delivered down the focus chain like a key but off the key ladder; the default returns false and `AbstractStringField` inserts at the caret in one mutation. See `
|
|
289
|
+
- Add `Component#handle_paste` — pasted text, whole and `\n`-normalized, delivered down the focus chain like a key but off the key ladder; the default returns false and `AbstractStringField` inserts at the caret in one mutation. See `design/decisions.md` `D_bracketed_paste` and book ch5.
|
|
141
290
|
- Add `Screen#run_event_loop(bracketed_paste:)` — on by default, mirroring `capture_mouse:`; pass false for a terminal that mishandles mode 2004.
|
|
142
291
|
- Add `Keys::BRACKETED_PASTE_ON` / `_OFF`, `Keys::PASTE_START` / `PASTE_END`, `Keys.read_paste` and `Keys.normalize_paste` — the terminal-layer half: markers, a raw drain to the terminator, and CR/CRLF-to-`\n` plus a UTF-8 scrub.
|
|
143
292
|
- Add `EventQueue::PasteEvent` — the whole clipboard as one frozen event, posted by the key thread.
|
|
144
293
|
- Add `FakeScreen#paste` — normalizes and dispatches like the real key thread, so a spec can hand it the CR line endings terminals actually send.
|
|
145
294
|
- Add `Component::AbstractStringField#preprocess_paste` — the paste-side input filter; the base drops the C0 controls a text buffer cannot hold and `TextField` also flattens newlines to spaces and trims to `max_text_length`.
|
|
146
295
|
- Add a *Paste* pane to `examples/sampler.rb` — a submit-on-Enter prompt with submit/paste counters, so the distinction is visible (and PTY-testable).
|
|
147
|
-
- Add `Component::Popup#close_on_outside_click?` — a left click outside an open popup now closes it, modal or not (default true; `Component::Notification` opts out). Fixes dropdowns and menu cascades stranded by a click on inert decoration. See `
|
|
296
|
+
- Add `Component::Popup#close_on_outside_click?` — a left click outside an open popup now closes it, modal or not (default true; `Component::Notification` opts out). Fixes dropdowns and menu cascades stranded by a click on inert decoration. See `design/decisions.md` `D_outside_click`.
|
|
148
297
|
- Add `Component::Popup#owner` — names the component an overlay is part of, so a click inside it doesn't dismiss the popup hosting it. `ComboBox`, `Select` and each `MenuBar` cascade panel set it; an overlay without one is independent.
|
|
149
298
|
- Add `Component::Popup#on_close` — a no-arg callback fired once the popup has left the screen, however it left; for a driver keeping its own record of what is open.
|
|
150
299
|
- **Fix:** `Component::TabSheet` no longer holds a removed tab's pane against re-use — `Tabs::Tab#remove` bypasses `TabSheet#remove_tab`, and the stale mapping made `add_tab` reject that pane as still in use.
|
|
151
|
-
- **Fix:** a container whose children exactly tile its rect no longer swallows the repaint cascade — content under it survives an ancestor's `clear_background` instead of vanishing until the next unrelated repaint (visible in the sampler as rows blanking when focus moved). See `
|
|
300
|
+
- **Fix:** a container whose children exactly tile its rect no longer swallows the repaint cascade — content under it survives an ancestor's `clear_background` instead of vanishing until the next unrelated repaint (visible in the sampler as rows blanking when focus moved). See `design/decisions.md` `D_repaint_cascade`.
|
|
152
301
|
- **Fix:** a multi-line paste into a `Component::TextArea` subclass that rebinds ENTER no longer fires that binding once per pasted line ([#4](https://github.com/mvysny/tuile/issues/4)).
|
|
153
302
|
- **Fix:** `Component::TextArea`'s rdoc had the two line-break bytes backwards — a pasted break arrived as `\r`, not `\n`.
|
|
154
|
-
- **Fix:** `Component::TextView#handle_key` no longer refuses every key while the view is unfocused — a vestigial `active?` guard that 0.8.0's dispatch overhaul dropped from every other widget — so hand-feeding it a scroll key scrolls, as with any other component. See `
|
|
303
|
+
- **Fix:** `Component::TextView#handle_key` no longer refuses every key while the view is unfocused — a vestigial `active?` guard that 0.8.0's dispatch overhaul dropped from every other widget — so hand-feeding it a scroll key scrolls, as with any other component. See `design/decisions.md` `D_text_view_scroll_verbs`.
|
|
155
304
|
- **Breaking:** `Component::TextField#left_column` is now private — the horizontal scroll offset was internal state with no caller outside the field. A spec asserting the scrolling reads it through `send(:left_column)`.
|
|
156
|
-
- **Breaking:** the framework status bar is removed — `ScreenPane#status_bar` is gone and the pane no longer reserves the bottom row, so `content` now fills the whole terminal. Build a status line into your own layout and fill it from `Screen#on_focus_changed=`; `examples/hello_world.rb` and `examples/file_commander.rb` show the shape. See `
|
|
305
|
+
- **Breaking:** the framework status bar is removed — `ScreenPane#status_bar` is gone and the pane no longer reserves the bottom row, so `content` now fills the whole terminal. Build a status line into your own layout and fill it from `Screen#on_focus_changed=`; `examples/hello_world.rb` and `examples/file_commander.rb` show the shape. See `design/decisions.md` `D_status_bar`.
|
|
157
306
|
- **Breaking:** `Component#keyboard_hint` is removed, along with its overrides on `Popup`, `PickerWindow`, `MenuBar`, `Tabs`, `Select`, `ComboBox` and `Notification` — four of them were unreachable in every configuration. Keep the method on your own window classes and call it from your status line; nothing in Tuile consults it now.
|
|
158
307
|
- **Breaking:** `Screen#register_global_shortcut` no longer takes `hint:` — the registry runs actions, it does not describe them. Drop the argument and write the hint into your own status line, next to the registration.
|
|
159
308
|
- **Breaking:** `Screen#active_window` is removed — it existed only to pick the component the status bar asked for a hint. Walk up from `Screen#focused` instead, which is the direction a key actually bubbles.
|
|
@@ -167,14 +316,14 @@ folded onto it. The framework also settles its scrolling vocabulary in one
|
|
|
167
316
|
pass: `row` is the terminal grid unit everywhere, `line` means exactly what
|
|
168
317
|
`String#lines` returns, and `items` are the domain objects a widget renders.
|
|
169
318
|
|
|
170
|
-
- Add `Component::List#items` / `#items=` and `#renderer` — the list holds typed items, one row each, and a renderer turns an item into its row. See `
|
|
319
|
+
- Add `Component::List#items` / `#items=` and `#renderer` — the list holds typed items, one row each, and a renderer turns an item into its row. See `design/decisions.md` `D_list_items` and book ch7.
|
|
171
320
|
- Add `Component::List#refresh_rows` — re-renders every row when the renderer's *inputs* changed (a group's selection) while the items and the renderer did not.
|
|
172
321
|
- Add `Component::List#build_lines` — the verb-named builder that `#lines`'s block form used to be: it yields a growing `Array` and assigns it through `#lines=`.
|
|
173
322
|
- Add `Component::ListDropdown#items` / `#items=` / `#renderer=`, forwarding to its list.
|
|
174
|
-
- Add `Component::Notification` — the corner toast: `Notification.show("Saved")` floats a non-modal box in the top-right for three seconds, stacking a burst into one box that drains one message every tick. See `
|
|
175
|
-
- Add `Component::TextArea#caret_row` and `#row_count` — the two readers that answer "has Up/Down anywhere left to go?", so a subclass can claim the key at the text's edge and delegate elsewhere. See `
|
|
176
|
-
- Add `Component::TextView#scroll_half_page_up` / `#scroll_half_page_down` — the programmatic form of `Ctrl+U` / `Ctrl+D`, for a host paging a view it does not focus. See `
|
|
177
|
-
- Add `
|
|
323
|
+
- Add `Component::Notification` — the corner toast: `Notification.show("Saved")` floats a non-modal box in the top-right for three seconds, stacking a burst into one box that drains one message every tick. See `design/decisions.md` `D_notification` and book ch7.
|
|
324
|
+
- Add `Component::TextArea#caret_row` and `#row_count` — the two readers that answer "has Up/Down anywhere left to go?", so a subclass can claim the key at the text's edge and delegate elsewhere. See `design/decisions.md` `D_text_area_rows`.
|
|
325
|
+
- Add `Component::TextView#scroll_half_page_up` / `#scroll_half_page_down` — the programmatic form of `Ctrl+U` / `Ctrl+D`, for a host paging a view it does not focus. See `design/decisions.md` `D_text_view_scroll_verbs`.
|
|
326
|
+
- Add `design/terminology.md` — a glossary of Tuile's house words, looked up by term; the rules live in `AGENTS.md`'s *Nomenclature* section and the reasoning in `design/decisions.md` `D_scroll_nomenclature`.
|
|
178
327
|
- `Component::List` now renders lazily: only the rows in the viewport, memoized until `items=`, `renderer=` or a width change. A renderer therefore runs at paint time and must stay pure and cheap.
|
|
179
328
|
- `Component::List#lines=` is unchanged and stays supported: it splits on `\n` and stores the resulting `StyledString`s *as* the items, so a line-populated list behaves exactly as before.
|
|
180
329
|
- `Component::List::Cursor`'s count parameters are renamed `item_count` and `viewport_rows` (a cursor indexes items; only the paging half counts rows). They are positional, so no caller changes — a `Cursor` subclass overrides against the new names.
|
|
@@ -183,38 +332,38 @@ pass: `row` is the terminal grid unit everywhere, `line` means exactly what
|
|
|
183
332
|
- **Fix:** `examples/file_commander.rb` navigates again — Enter on a directory called `Rainbow.uncolor` on a `StyledString` and raised.
|
|
184
333
|
- **Breaking:** `Component::List#on_item_chosen` and `#on_cursor_changed` now receive `(index, item)` rather than `(index, line)`. A list populated by `lines=` is unaffected (its items *are* the `StyledString` rows); one populated by `items=` must expect its own objects.
|
|
185
334
|
- **Breaking:** `Component::List#lines` (the reader) is removed — it returned the items, and the name lies once a `renderer` is set. Read `#items`; for the block form call `#build_lines`; to assert what a list *shows*, assert the painted buffer.
|
|
186
|
-
- **Breaking:** `Component::ListDropdown#lines=` / `#lines` are removed — use `#items=` with a `#renderer=`. See `
|
|
187
|
-
- **Breaking:** `Component::List#add_line` and `#add_lines` are removed — an append is a statement about a collection the list owns, which a lazily-sourced provider has nothing to mutate. Keep your own array and assign it whole (`list.items = mine`); for incremental append use `Component::TextView`. See `
|
|
188
|
-
- **Breaking:** `Component::Popup.open` (the class method) is removed — it hardcoded `Popup.new`, so every subclass inherited a factory that silently built a bare `Popup`. Write `Popup.new(...).open`, which now returns the popup. See `
|
|
335
|
+
- **Breaking:** `Component::ListDropdown#lines=` / `#lines` are removed — use `#items=` with a `#renderer=`. See `design/decisions.md` `D_list_items`.
|
|
336
|
+
- **Breaking:** `Component::List#add_line` and `#add_lines` are removed — an append is a statement about a collection the list owns, which a lazily-sourced provider has nothing to mutate. Keep your own array and assign it whole (`list.items = mine`); for incremental append use `Component::TextView`. See `design/decisions.md` `D_list_items`.
|
|
337
|
+
- **Breaking:** `Component::Popup.open` (the class method) is removed — it hardcoded `Popup.new`, so every subclass inherited a factory that silently built a bare `Popup`. Write `Popup.new(...).open`, which now returns the popup. See `design/decisions.md` `D_popup_open`.
|
|
189
338
|
- **Breaking:** `Buffer#set_line` is now `#set_text` and `Component#draw_line` is now `#draw_text` — both write a `StyledString` starting at `(x, y)` and never filled a row. Rename the calls; behavior is unchanged.
|
|
190
339
|
- **Breaking:** `Component::List#top_line`/`=` and `Component::TextView#top_line`/`=` are now `#scroll_top_row`/`=`, and `Component::TextArea#top_display_row` is now `#scroll_top_row`. Rename the accessors.
|
|
191
340
|
- **Breaking:** `VerticalScrollBar.new(line_count:, top_line:)` is now `.new(row_count:, scroll_top_row:)`. Rename the keywords.
|
|
192
341
|
|
|
193
342
|
## [0.11.0] - 2026-08-12
|
|
194
343
|
|
|
195
|
-
- Add `Component::Select` — a one-row enum field that drops open a `ListDropdown` of its typed `items`; `value` is the selected item, and Enter, Space or Down opens it. It claims no printable key but Space, so a form's own letter bindings keep working while it has focus. See `
|
|
196
|
-
- Add `Component::Layout::Vertical` and `Component::Layout::Horizontal` (on the abstract `Component::Layout::Box`) — declarative 1-D layouts where a caller *declares* each child's extent (`Fixed` / `Percent` / `Expand`, plus box-global `spacing`, `padding` and a per-child `align:`) instead of computing it. See `
|
|
197
|
-
- Add `Component::BigDecimalField` — the money field: the shape of `IntegerField`/`FloatField` with an exact `BigDecimal` (or `nil`) value, where assigning a `Float` raises rather than converting. See `
|
|
198
|
-
- Add `Component::FloatField` — the `IntegerField` twin whose `value` is a `Float` (or `nil`), accepting a single `.` and stepping by `1.0` on Up/Down; `value=` raises on a NaN or infinity. See `
|
|
344
|
+
- Add `Component::Select` — a one-row enum field that drops open a `ListDropdown` of its typed `items`; `value` is the selected item, and Enter, Space or Down opens it. It claims no printable key but Space, so a form's own letter bindings keep working while it has focus. See `design/decisions.md` `D_select` and book ch7.
|
|
345
|
+
- Add `Component::Layout::Vertical` and `Component::Layout::Horizontal` (on the abstract `Component::Layout::Box`) — declarative 1-D layouts where a caller *declares* each child's extent (`Fixed` / `Percent` / `Expand`, plus box-global `spacing`, `padding` and a per-child `align:`) instead of computing it. See `design/decisions.md` `D_box_layouts` and book ch3.
|
|
346
|
+
- Add `Component::BigDecimalField` — the money field: the shape of `IntegerField`/`FloatField` with an exact `BigDecimal` (or `nil`) value, where assigning a `Float` raises rather than converting. See `design/decisions.md` `D_bigdecimal_field`.
|
|
347
|
+
- Add `Component::FloatField` — the `IntegerField` twin whose `value` is a `Float` (or `nil`), accepting a single `.` and stepping by `1.0` on Up/Down; `value=` raises on a NaN or infinity. See `design/decisions.md` `D_float_field`.
|
|
199
348
|
- Add `Component::ListDropdown#anchor_to(anchor, rows:, width:, max_rows:)` — placement now lives on the dropdown: below the driver, flipped above when the rows won't fit beneath, and slid left to stay on screen. Width stays a caller decision.
|
|
200
349
|
- `bigdecimal` is Tuile's first optional dependency and is deliberately not in the gemspec: only an app naming `Component::BigDecimalField` loads it, and doing so without the gem raises `LoadError` with the fix in the message. A Bundler app on Ruby 3.4+ must name `bigdecimal` in its own `Gemfile`.
|
|
201
350
|
- `examples/sampler.rb` gains `Select`, `FloatField` and `BigDecimalField` panes, and its demo panes are ported to the box layouts.
|
|
202
|
-
- **Fix (behavior):** `StyledString#wrap` no longer eats a **first-line** indent, so indented text is displayable at all (every `Component::TextView` line goes through `wrap`). `plain(" ").wrap(5)` is now `[" "]` rather than `[""]`; continuations still start at column 0. See `
|
|
351
|
+
- **Fix (behavior):** `StyledString#wrap` no longer eats a **first-line** indent, so indented text is displayable at all (every `Component::TextView` line goes through `wrap`). `plain(" ").wrap(5)` is now `[" "]` rather than `[""]`; continuations still start at column 0. See `design/decisions.md` `D_wrap_leading_space`.
|
|
203
352
|
- **Fix (behavior):** a `Component::ListDropdown` that scrolls now shows a scrollbar — `List`'s `scrollbar_visibility` defaults to `:gone` and the dropdown never changed it, so an 11-match `ComboBox` looked identical to a 10-match one.
|
|
204
353
|
- **Fix:** `Component::ComboBox` no longer hands its inner `TextField` a one-row rect when its own rect is zero-height — a child painting outside its parent, which a box layout can provoke by starving an over-subscribed child.
|
|
205
354
|
- **Breaking:** `Component::ComboBox::MAX_VISIBLE_ROWS` moved to `Component::ListDropdown::MAX_VISIBLE_ROWS`. Update the constant reference.
|
|
206
|
-
- **Breaking (behavior):** `Component::Checkbox` now toggles on **Enter** as well as Space, matching a checkable row inside a `List`. A focused checkbox therefore consumes Enter, so an ancestor's Enter-to-submit must move to a key no focused field claims. See `
|
|
355
|
+
- **Breaking (behavior):** `Component::Checkbox` now toggles on **Enter** as well as Space, matching a checkable row inside a `List`. A focused checkbox therefore consumes Enter, so an ancestor's Enter-to-submit must move to a key no focused field claims. See `design/decisions.md` `D_boolean_fields`.
|
|
207
356
|
|
|
208
357
|
## [0.10.0] - 2026-08-02
|
|
209
358
|
|
|
210
|
-
- Add `Component::Checkbox` — a one-row boolean input (`[x] Enable syslog forwarding`) toggled by Space or a left-click, whose `value` is always `true`/`false`, with `checked?`/`checked=`/`toggle` as the domain-word face over it. See `
|
|
211
|
-
- Add `Component::CheckboxGroup` — multi-select over typed `items` whose `value` is a **frozen** `Set` of the selected items, iterating in toggle order (use `items & value.to_a` when order matters). See `
|
|
212
|
-
- Add `Component::RadioGroup` — single-select over typed `items` whose `value` is the selected item; it composes a `List`, so the cursor roams without selecting and Space, Enter or a click commits the row under it. See `
|
|
359
|
+
- Add `Component::Checkbox` — a one-row boolean input (`[x] Enable syslog forwarding`) toggled by Space or a left-click, whose `value` is always `true`/`false`, with `checked?`/`checked=`/`toggle` as the domain-word face over it. See `design/decisions.md` `D_boolean_fields`.
|
|
360
|
+
- Add `Component::CheckboxGroup` — multi-select over typed `items` whose `value` is a **frozen** `Set` of the selected items, iterating in toggle order (use `items & value.to_a` when order matters). See `design/decisions.md` `D_checkbox_group`.
|
|
361
|
+
- Add `Component::RadioGroup` — single-select over typed `items` whose `value` is the selected item; it composes a `List`, so the cursor roams without selecting and Space, Enter or a click commits the row under it. See `design/decisions.md` `D_radio_group`.
|
|
213
362
|
- Add `Component::IntegerField` — a single-line input whose `value` is an `Integer` (or `nil`), accepting only digits and a single leading `-`, with Up/Down stepping by one; it composes a `TextField` rather than subclassing one.
|
|
214
363
|
- Add `Component::PasswordField` — a `TextField` painting one mask glyph per character (`mask_char=`, default `*`) with a `revealed=`/`revealed?` toggle; while masked, word-jumps collapse to the ends of the buffer so the mask can't leak word boundaries.
|
|
215
364
|
- Add `Component::TextField#display_text` — the protected seam a subclass overrides when it paints something other than `text`, since the caret, the scroll window and click-to-position all measure it. The contract is one display character per `text` character, in order.
|
|
216
|
-
- Add `Component::ProgressBar` — a display-only fill over a `Range` with `fraction`/`percent` readers and an `indeterminate` mode owning a 5 fps ticker; it stays deliberately outside `Component::HasValue`. See `
|
|
217
|
-
- Add `Component#on_attached` / `on_detached` — lifecycle hooks fired once per component per transition, letting a component own a mounted-lifetime resource. They are hooks, not destructors: a process exiting without `Screen#close` fires nothing. See `
|
|
365
|
+
- Add `Component::ProgressBar` — a display-only fill over a `Range` with `fraction`/`percent` readers and an `indeterminate` mode owning a 5 fps ticker; it stays deliberately outside `Component::HasValue`. See `design/decisions.md` `D_progress_bar` and `D_color_slots`.
|
|
366
|
+
- Add `Component#on_attached` / `on_detached` — lifecycle hooks fired once per component per transition, letting a component own a mounted-lifetime resource. They are hooks, not destructors: a process exiting without `Screen#close` fires nothing. See `design/decisions.md` `D_attach_hooks`.
|
|
218
367
|
- Add `Component::HasCaption` — the caption seam included by `Button` and `Window`, coining the naming split (**caption** is app-authored chrome, **text** is the user-editable value) and making `is_a?`-plus-caption tree lookups possible.
|
|
219
368
|
- `Component::HasValue` now carries `focusable? = true` (overridable). `tab_stop?` is deliberately not folded in: it stays `true` on the leaf `AbstractStringField` and `false` on the composing wrappers, whose inner field carries the stop.
|
|
220
369
|
- `Component::ComboBox` and `Component::IntegerField` compose their inner field via `Component::HasContent` instead of hand-rolling `children`/`rect=`/`on_focus`, so their `content`/`content=` are consequently public.
|
|
@@ -222,17 +371,17 @@ pass: `row` is the terminal grid unit everywhere, `line` means exactly what
|
|
|
222
371
|
- `Screen#close` now unmounts the component tree (via `ScreenPane#detach_all`), so teardown fires `on_detached` across it.
|
|
223
372
|
- `items=` is chrome on `ComboBox` and on both group components: it never touches `value`, so an absent value renders as nothing selected and survives intact, with no reconcile step, clamp or silent drop.
|
|
224
373
|
- `examples/sampler.rb` gains panes for `ProgressBar`, `RadioGroup` and `CheckboxGroup`.
|
|
225
|
-
- Fix the caret stepping by *character* rather than by grapheme cluster: LEFT/RIGHT, BACKSPACE and DELETE now move over and delete exactly one cluster, and the caret is boundary-locked so a mid-cluster position is unrepresentable. See `
|
|
226
|
-
- Fix `Component::TextArea` hanging the UI thread on text containing `\r`, `\v` or `\f` — the word-scan measured zero and the wrap loop never advanced, so `area.text = File.read(crlf_file)` was enough to lock up an app. See `
|
|
227
|
-
- **Breaking:** `Component#children` is final, and reparenting goes through the protected `add_child(child, at:)` / `remove_child(child)` / `detach_child(child)`. A custom container must stop overriding `children` or hand-wiring `child.parent = …`; named slots become readers over the array. See `
|
|
228
|
-
- **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. See `
|
|
229
|
-
- **Breaking:** the UI-thread guard is rewritten — `EventQueue#locked?` becomes `#running?` plus `#on_loop_thread?`, the internal `@pretend_ui_lock` and `FakeScreen#check_locked`'s bypass are gone, and `Screen#state` is added. A spec mutating UI from a *spawned* thread now raises exactly as an app would. See `
|
|
230
|
-
- **Breaking:** `Component#key_shortcut` and `#find_shortcut_component` are removed, along with `ScreenPane#handle_key`'s capture phase and `Window`'s `[k]-Caption` border prefix. Migration: `w.key_shortcut = "1"` becomes a `case` in the containing layout's own `handle_key`. See `
|
|
374
|
+
- Fix the caret stepping by *character* rather than by grapheme cluster: LEFT/RIGHT, BACKSPACE and DELETE now move over and delete exactly one cluster, and the caret is boundary-locked so a mid-cluster position is unrepresentable. See `design/decisions.md` `D_cluster_caret`.
|
|
375
|
+
- Fix `Component::TextArea` hanging the UI thread on text containing `\r`, `\v` or `\f` — the word-scan measured zero and the wrap loop never advanced, so `area.text = File.read(crlf_file)` was enough to lock up an app. See `design/decisions.md` `D_text_area_columns`.
|
|
376
|
+
- **Breaking:** `Component#children` is final, and reparenting goes through the protected `add_child(child, at:)` / `remove_child(child)` / `detach_child(child)`. A custom container must stop overriding `children` or hand-wiring `child.parent = …`; named slots become readers over the array. See `design/decisions.md` `D_tree_api`.
|
|
377
|
+
- **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. See `design/decisions.md` `D_tree_first`.
|
|
378
|
+
- **Breaking:** the UI-thread guard is rewritten — `EventQueue#locked?` becomes `#running?` plus `#on_loop_thread?`, the internal `@pretend_ui_lock` and `FakeScreen#check_locked`'s bypass are gone, and `Screen#state` is added. A spec mutating UI from a *spawned* thread now raises exactly as an app would. See `design/decisions.md` `D_screen_lifecycle`.
|
|
379
|
+
- **Breaking:** `Component#key_shortcut` and `#find_shortcut_component` are removed, along with `ScreenPane#handle_key`'s capture phase and `Window`'s `[k]-Caption` border prefix. Migration: `w.key_shortcut = "1"` becomes a `case` in the containing layout's own `handle_key`. See `design/decisions.md` `D_key_dispatch`.
|
|
231
380
|
- **Breaking:** `Screen#register_global_shortcut` now also rejects `Screen::EDITING_KEYS` — `ENTER`, `BACKSPACE`, `DELETE` and the arrows — which silently broke `TextArea` newlines app-wide. Bind those on an ancestor's `handle_key` instead; `HOME`/`END`/`PAGE_UP`/`PAGE_DOWN` stay legal.
|
|
232
381
|
- **Breaking:** `Component::TextInput` is renamed `Component::AbstractStringField` (file `text_input.rb` → `abstract_string_field.rb`). Only code referencing the constant directly must update.
|
|
233
382
|
- **Breaking:** `Component::Button#caption` and `Component::Window#caption` return a `StyledString`, not a `String`. Measure with `caption.display_width` and recover the plain text with `caption.to_s`.
|
|
234
|
-
- **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. Custom components measuring with `String#length` or iterating `each_char` must move to `StyledString#display_width` / `slice` / `ellipsize`. See `
|
|
235
|
-
- **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. `max_text_length` is now an explicit, settable cap defaulting to `nil` (unbounded) rather than the derived `rect.width - 1`. See `
|
|
383
|
+
- **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. Custom components measuring with `String#length` or iterating `each_char` must move to `StyledString#display_width` / `slice` / `ellipsize`. See `design/decisions.md` `D_cluster_width`.
|
|
384
|
+
- **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. `max_text_length` is now an explicit, settable cap defaulting to `nil` (unbounded) rather than the derived `rect.width - 1`. See `design/decisions.md` `D_text_field_axes`.
|
|
236
385
|
- **Breaking (behavior):** `Component::Button#handle_mouse` fires `on_click` only within the button's painted **extent**, not anywhere in its `rect`, and `Component::Checkbox` follows the identical rule. A click on the blank tail still focuses the button but no longer activates it.
|
|
237
386
|
|
|
238
387
|
## [0.9.0] - 2026-07-05
|