tuile 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -27
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +2342 -158
  5. data/README.md +151 -505
  6. data/TERMINOLOGY.md +15 -5
  7. data/book/01-first-app.md +22 -17
  8. data/book/02-repaint.md +18 -5
  9. data/book/03-layout.md +28 -20
  10. data/book/05-focus.md +137 -19
  11. data/book/06-theming.md +103 -2
  12. data/book/07-components.md +567 -27
  13. data/book/08-testing.md +34 -4
  14. data/book/09-styled-text.md +3 -3
  15. data/book/README.md +7 -5
  16. data/examples/file_commander.rb +23 -17
  17. data/examples/hello_world.rb +17 -5
  18. data/examples/sampler.rb +527 -108
  19. data/ideas/arrow-key-navigation.md +17 -1
  20. data/ideas/modal-backdrop.md +24 -0
  21. data/ideas/new-components.md +28 -27
  22. data/lib/tuile/ansi.rb +10 -0
  23. data/lib/tuile/buffer.rb +51 -3
  24. data/lib/tuile/color.rb +143 -0
  25. data/lib/tuile/color_depth.rb +80 -0
  26. data/lib/tuile/component/abstract_string_field.rb +36 -0
  27. data/lib/tuile/component/button.rb +3 -3
  28. data/lib/tuile/component/checkbox.rb +3 -3
  29. data/lib/tuile/component/combo_box.rb +12 -3
  30. data/lib/tuile/component/confirm_window.rb +442 -0
  31. data/lib/tuile/component/has_content.rb +22 -9
  32. data/lib/tuile/component/has_value.rb +1 -1
  33. data/lib/tuile/component/info_window.rb +64 -16
  34. data/lib/tuile/component/layout.rb +0 -10
  35. data/lib/tuile/component/list.rb +22 -0
  36. data/lib/tuile/component/list_dropdown.rb +102 -11
  37. data/lib/tuile/component/log_text_view.rb +71 -0
  38. data/lib/tuile/component/log_window.rb +13 -48
  39. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  40. data/lib/tuile/component/menu_bar.rb +582 -0
  41. data/lib/tuile/component/notification.rb +24 -39
  42. data/lib/tuile/component/overlay.rb +192 -0
  43. data/lib/tuile/component/picker_window.rb +0 -5
  44. data/lib/tuile/component/popup.rb +61 -123
  45. data/lib/tuile/component/progress_bar.rb +1 -1
  46. data/lib/tuile/component/select.rb +17 -7
  47. data/lib/tuile/component/slot.rb +54 -0
  48. data/lib/tuile/component/tab_sheet.rb +231 -0
  49. data/lib/tuile/component/tabs.rb +528 -0
  50. data/lib/tuile/component/text_area.rb +5 -4
  51. data/lib/tuile/component/text_field.rb +23 -6
  52. data/lib/tuile/component/text_view.rb +8 -5
  53. data/lib/tuile/component/window.rb +22 -46
  54. data/lib/tuile/component.rb +186 -31
  55. data/lib/tuile/event_queue.rb +45 -1
  56. data/lib/tuile/fake_screen.rb +40 -2
  57. data/lib/tuile/keys.rb +72 -0
  58. data/lib/tuile/screen.rb +212 -113
  59. data/lib/tuile/screen_pane.rb +125 -41
  60. data/lib/tuile/styled_string.rb +80 -7
  61. data/lib/tuile/terminal_background.rb +74 -16
  62. data/lib/tuile/version.rb +1 -1
  63. data/sig/tuile.rbs +2527 -358
  64. metadata +13 -3
  65. data/mise.toml +0 -2
@@ -104,6 +104,12 @@ Recorded here so the open questions below stay narrow.
104
104
  stop. See open question on backwards entry.
105
105
  - **Mouse is untouched.** Popups are untouched — the bubble is already scoped
106
106
  to the topmost modal popup.
107
+ - **{Tuile::Component::Tabs} already left the vertical axis free for this.**
108
+ The strip claims Left/Right and *declines* Up/Down specifically so that this
109
+ feature can move focus out of it vertically while Left/Right keep switching
110
+ tabs inside it (`D_tabs`). It composes for nothing: the strip declines, the
111
+ key bubbles, the navigating ancestor moves. That is also the shape to copy
112
+ for any future one-axis widget — claim one axis, leave the other.
107
113
 
108
114
  ## The honest argument against
109
115
 
@@ -180,6 +186,16 @@ exactly virtui's shape, so the answer matters more there than in a form.
180
186
  (`combo_box.rb:181`) but declines Up. So Up would jump out of a closed combo
181
187
  while Down opens it. Browsers eat both. Deliberate choice or accident to fix?
182
188
 
189
+ **Q12 — Down out of a `Tabs` strip: to the pane, or past the whole
190
+ `TabSheet`?** The strip is a child of the sheet, not of the navigating layout,
191
+ so "walk direct children" sees the *sheet* holding focus and would move to the
192
+ sheet's next sibling — skipping the pane the user is looking at. Entering the
193
+ pane is almost certainly what a user means by Down here. Options: let a
194
+ `TabSheet` claim Down when focus is on its strip (a `handle_key` on the sheet,
195
+ no framework change, but a second place that binds an arrow); or have the
196
+ navigating walk descend into a child that holds focus deeper than its first
197
+ tab stop. Interacts with Q1's placement question.
198
+
183
199
  **Q9 — Naming.** "Form" is the wrong word for the behavior — it's *focus
184
200
  navigation*. `navigation` / `arrow_nav` / `key_navigation` /
185
201
  `focus_navigation`? Whatever it is, it must not imply validation or submit,
@@ -199,7 +215,7 @@ Also: any public signature change means `rake sig` in the same commit.
199
215
 
200
216
  If built: the user-facing half goes to book ch5 (the key/Enter tables live
201
217
  there), the invariants half to AGENTS.md's key-dispatch section, and the
202
- choice-plus-rejected-roads half to `DECISIONS.md` as `D-arrow-navigation` —
218
+ choice-plus-rejected-roads half to `DECISIONS.md` as `D_arrow_navigation` —
203
219
  which must record the `Layout::Form` rejection and the Vaadin FormGroup
204
220
  precedent behind it, since that's the reasoning most likely to be
205
221
  re-litigated. Then retire this file.
@@ -0,0 +1,24 @@
1
+ # Modal backdrop — dim the content under a popup, or cast a shadow
2
+
3
+ **Status:** seed, 2026-08-31. Deliberately not brainstormed yet; spun off from
4
+ the `ConfirmWindow` design (`D_confirm_window`'s sizing paragraph).
5
+
6
+ **The problem.** A modal `Popup` floats over the tiled content with no visual
7
+ separation beyond its own border: the content underneath is neither dimmed nor
8
+ shadowed. A small popup — a `ConfirmWindow` measuring a one-line "Overwrite?" —
9
+ can sit in the middle of a busy screen and simply not be noticed.
10
+
11
+ **The two candidate treatments** (every GUI stack ships at least one):
12
+
13
+ - **Dim/tint** the non-popup cells under the topmost modal.
14
+ - **A drop shadow** — a one-cell dark offset under/right of the popup box.
15
+
16
+ **Hooks that exist today, for whoever picks this up:** `Screen#repaint`
17
+ already partitions tiled vs. popup subtrees and repaints popups on top, so a
18
+ dim pass has a natural slot between the two. Terminal cells are opaque
19
+ (`D_bg_inherit`), so "dim" means restyling cells, not compositing — and
20
+ `Color` has no darken/blend operation yet, which a dim factor would need.
21
+
22
+ **Open when picked up:** flush-time transform in `Buffer` vs. repaint-time
23
+ style override in components; does a shadow belong to `Overlay` or only
24
+ `Popup`; interaction with themes and with the terminal-default (unset) bg.
@@ -9,14 +9,14 @@ and it belongs here, not in a durable doc, because it goes stale as we
9
9
  build.
10
10
 
11
11
  Batch 1 ("field components only") is **done** — every idea filed under it has
12
- graduated: `checkbox` (`DECISIONS.md` `D-boolean-fields`) and `checkbox-group`
13
- (`D-checkbox-group`), both built 2026-07-30; `radio-group` (`D-radio-group`),
14
- built 2026-07-31; `progress-bar` (`D-color-slots`, book ch7 "Reporting
15
- progress") and `password-field` (`D-integer-field`'s taxonomy, book ch7
12
+ graduated: `checkbox` (`DECISIONS.md` `D_boolean_fields`) and `checkbox-group`
13
+ (`D_checkbox_group`), both built 2026-07-30; `radio-group` (`D_radio_group`),
14
+ built 2026-07-31; `progress-bar` (`D_color_slots`, book ch7 "Reporting
15
+ progress") and `password-field` (`D_integer_field`'s taxonomy, book ch7
16
16
  "Editing text"), both built 2026-08-02.
17
17
 
18
18
  The **box layouts** that headed the gating list below are done too
19
- (`D-box-layouts`, 2026-08-07) — `Layout::Vertical` / `::Horizontal`, plus the
19
+ (`D_box_layouts`, 2026-08-07) — `Layout::Vertical` / `::Horizontal`, plus the
20
20
  sampler ported onto them.
21
21
 
22
22
  ## What Tuile already has
@@ -26,7 +26,7 @@ Integer Field, Combo Box (1:1), Dialog ({Tuile::Component::Popup} plus
26
26
  `Window`/`InfoWindow`/`PickerWindow`), Themable Mixin
27
27
  ({Tuile::Theme}/{Tuile::ThemeDef}). **List Box** is half-there:
28
28
  {Tuile::Component::List} takes typed items and a renderer since 2026-08-14
29
- (`D-list-items`), but has no multi-select — {Tuile::Component::CheckboxGroup}
29
+ (`D_list_items`), but has no multi-select — {Tuile::Component::CheckboxGroup}
30
30
  is the nearest thing.
31
31
 
32
32
  Tuile also has pieces Vaadin doesn't name as components — `ListDropdown`,
@@ -39,21 +39,20 @@ That leaves ~46 gaps.
39
39
 
40
40
  | Component | Builds on | Note |
41
41
  |---|---|---|
42
- | ~~Box layouts (H/V)~~ | `Layout` | **built** 2026-08-07 (`D-box-layouts`, book ch3); `Vertical`/`Horizontal` over `Box`, additive sugar on top of `Absolute` — no foundation change |
43
- | ~~Checkbox~~ | `HasValue` | **built** 2026-07-30 (`D-boolean-fields`); tri-state still deferred |
44
- | ~~Radio Group~~ | `List` + `HasValue` | **built** 2026-07-31 (`D-radio-group`); composes a `List`, cursor roams and Space selects |
45
- | ~~Checkbox Group~~ | `List` + `HasValue` | **built** 2026-07-30 (`D-checkbox-group`); composes a `List`, frozen `Set` value |
46
- | ~~Select~~ | `ListDropdown` + `HasValue` | **built** 2026-08-12 (`D-select`, book ch7); a *second driver* of `ListDropdown`, not "ComboBox − filter" — it paints its own one-row face and needs no read-only axis. Claims no printable but Space |
47
- | ~~Password Field~~ | `TextField` | **built** 2026-08-02 (`D-integer-field`'s taxonomy — subclass, since a password's value *is* its text; mask default in `D-ambiguous-width`); a `display_text` seam, one mask glyph per character |
48
- | ~~Number Field~~ | `IntegerField` twin | **built** 2026-08-07 as `FloatField` (`D-float-field`) and `BigDecimalField` (`D-bigdecimal-field`, on Tuile's first optional dep); each named for its Ruby value type, deliberate copies of `IntegerField` |
49
- | ~~Progress Bar~~ | `draw_line` + `EventQueue#tick_fps` | **built** 2026-08-02 (`D-progress-bar`, book ch7); a `value` that stays out of `HasValue`, ticker synced from `attached? && indeterminate?` |
50
- | ~~Notification~~ | `Popup` + `Ticker` | **built** 2026-08-17 (`D-notification`, book ch7); one non-modal top-right box, N messages, one 3 s ticker retiring the oldest. Corner anchor is its own `reposition` override, so `Popup` was untouched — and the `Popover` extraction still waits for a second *kind* of anchoring |
51
- | Confirm Dialog | `Popup`+`Window`+`Button` | fold `PickerWindow` in |
42
+ | ~~Box layouts (H/V)~~ | `Layout` | **built** 2026-08-07 (`D_box_layouts`, book ch3); `Vertical`/`Horizontal` over `Box`, additive sugar on top of `Absolute` — no foundation change |
43
+ | ~~Checkbox~~ | `HasValue` | **built** 2026-07-30 (`D_boolean_fields`); tri-state still deferred |
44
+ | ~~Radio Group~~ | `List` + `HasValue` | **built** 2026-07-31 (`D_radio_group`); composes a `List`, cursor roams and Space selects |
45
+ | ~~Checkbox Group~~ | `List` + `HasValue` | **built** 2026-07-30 (`D_checkbox_group`); composes a `List`, frozen `Set` value |
46
+ | ~~Select~~ | `ListDropdown` + `HasValue` | **built** 2026-08-12 (`D_select`, book ch7); a *second driver* of `ListDropdown`, not "ComboBox − filter" — it paints its own one-row face and needs no read-only axis. Claims no printable but Space |
47
+ | ~~Password Field~~ | `TextField` | **built** 2026-08-02 (`D_integer_field`'s taxonomy — subclass, since a password's value *is* its text; mask default in `D_ambiguous_width`); a `display_text` seam, one mask glyph per character |
48
+ | ~~Number Field~~ | `IntegerField` twin | **built** 2026-08-07 as `FloatField` (`D_float_field`) and `BigDecimalField` (`D_bigdecimal_field`, on Tuile's first optional dep); each named for its Ruby value type, deliberate copies of `IntegerField` |
49
+ | ~~Progress Bar~~ | `draw_line` + `EventQueue#tick_fps` | **built** 2026-08-02 (`D_progress_bar`, book ch7); a `value` that stays out of `HasValue`, ticker synced from `attached? && indeterminate?` |
50
+ | ~~Notification~~ | `Popup` + `Ticker` | **built** 2026-08-17 (`D_notification`, book ch7); one non-modal top-right box, N messages, one 3 s ticker retiring the oldest. Corner anchor is its own `reposition` override, so `Popup` was untouched — and the `Popover` extraction still waits for a second *kind* of anchoring |
51
+ | ~~Confirm Dialog~~ | `Popup`+`Window`+`Button` | **built** 2026-08-31 as `ConfirmWindow` (`D_confirm_window`, book ch7); the component is the builder — `#button` plus the `alert`/`confirm`/`yes_no` factories — every button dismisses, MenuBar-shaped mnemonics with `q`/`g`/`G` reserved. The fold-`PickerWindow`-in idea is **rejected**: the two disagree on every semantic that matters (cursor, default, ESC, close-on-pick) and share only API shape |
52
52
  | Details → Accordion | `HasContent` | Details is the atom, Accordion the group |
53
- | Tabs → Tabsheet | `HasValue` (index) + `HasContent` | strip, then strip + content swap |
54
- | Popover | `ListDropdown#anchor_to` (extracted 2026-08-12) | generalize the anchored non-modal overlay: `anchor_to` moves down to it and `ListDropdown` inherits it. Build it when the second *kind* of anchoring appears (a point; a right edge that flips) — not the second caller of the same kind. Gates the next two |
55
- | Menu Bar | `ListDropdown::Menu` + Popover | |
56
- | Context Menu | same | `:right` button already parses |
53
+ | ~~Tabs → TabSheet~~ | plain `Component` + the tree API | **built** 2026-08-23 (`D_tabs`, book ch7); a strip (one tab stop, Left/Right, immediate activation) plus a sheet whose `children` are `[strip, pane]`. Neither is `HasValue` — a tab selection is view state — and neither is `HasContent`; hiding a pane means *detaching* it, since Tuile has no visibility flag; the strip owns mutable `Tabs::Tab` handles rather than the `items`/`item_label` shell. Hidden/disabled/closeable tabs, lazy panes and a scrolling strip are deferred, each additive (`D_tabs`) |
54
+ | Popover | `ListDropdown#anchor_to` (extracted 2026-08-12) | generalize the anchored non-modal overlay: `anchor_to` moves down to it and `ListDropdown` inherits it. Build it when the second *kind* of anchoring appears (a point; a right edge that flips) — not the second caller of the same kind. Nothing gates on it today: Menu Bar shipped without it |
55
+ | ~~Menu Bar~~ | `ListDropdown` (+ `anchor_beside`) | **built** 2026-08-24 (`D_menu_bar`, book ch7), mnemonics the same day. Turned out *not* to need the Popover extraction: a focused strip drives a cascade of non-modal `ListDropdown`s the way `Select` drives one, so the additions were a side-anchor method, a highlighted-row rect and a cursor pass-through. Unlimited submenu depth; checkable/disabled items and global-shortcut activation deferred indefinitely |
57
56
  | Slider | `draw_line` | arrows/PgUp; 25.2 also has a two-thumb *range* variant |
58
57
  | Breadcrumbs | `Label`/`StyledString` | clickable path segments |
59
58
  | Markdown | `TextView` + `StyledString` | Markdown subset → styled text; high value on a TTY |
@@ -71,7 +70,7 @@ That leaves ~46 gaps.
71
70
  | Virtual List | a lazy data-provider strategy on `List` |
72
71
  | Side Nav | hierarchical collapsible list (the sampler's nav is the prototype) |
73
72
  | App Layout | shell: title bar + drawer + content slot |
74
- | Custom Field | formalize the composed typed-field pattern — or *reject* it as a shared base (COP: inherit to *be*, not to share); `D-integer-field` already sketches the taxonomy |
73
+ | Custom Field | formalize the composed typed-field pattern — or *reject* it as a shared base (COP: inherit to *be*, not to share); `D_integer_field` already sketches the taxonomy |
75
74
  | Message Input / Message List / Login | nothing — pure assemblies, good example fodder |
76
75
  | Upload | reinterpret as a file-chooser dialog (`file_commander` has the ingredients) |
77
76
  | Icon | a glyph / Nerd-Font constants module |
@@ -92,7 +91,7 @@ That leaves ~46 gaps.
92
91
  These are prerequisites, not components, and each deserves its own idea
93
92
  file when its cluster comes up:
94
93
 
95
- 1. ~~**Box layouts** (H/V)~~ — **done** 2026-08-07 (`D-box-layouts`). Turned
94
+ 1. ~~**Box layouts** (H/V)~~ — **done** 2026-08-07 (`D_box_layouts`). Turned
96
95
  out *not* to be structural: a `Box` is an `Absolute` subclass with a `rect=`
97
96
  override, so it unblocked the form-shaped cluster without touching the
98
97
  foundation. A future Grid should reuse its `Fixed`/`Percent`/`Expand`
@@ -100,24 +99,26 @@ file when its cluster comes up:
100
99
  2. **Field label + helper text seam** → Form Layout. Note this is what Form
101
100
  Layout is actually blocked on — the layout half now exists.
102
101
  3. **Validation seam** → Email Field, forms generally.
103
- 4. **Anchored Popover extraction** → Menu Bar, Context Menu, pickers,
104
- Tooltip.
102
+ 4. **Anchored Popover extraction** → pickers, Tooltip. **No longer gates Menu
103
+ Bar** — `D_menu_bar` argues the side-anchor is a sibling method on
104
+ `ListDropdown`, since both callers still wrap a `List`; the extraction's
105
+ trigger is now the first non-`List` content that wants anchoring.
105
106
  5. **Mouse motion/drag** (modes 1002/1006, release events) → Split
106
107
  divider, Slider drag, scrollbar drag.
107
108
  6. **Typed items + data provider on `List`** → List Box, Grid, Virtual
108
- List. **Half done** 2026-08-14 (`D-list-items`): `List` takes `items` +
109
+ List. **Half done** 2026-08-14 (`D_list_items`): `List` takes `items` +
109
110
  a `renderer` and renders only the visible rows, and the five composers
110
111
  are folded onto it. The remaining half is *sourcing* items lazily (a
111
112
  data provider behind `items`), which lazy rendering was chosen to keep
112
113
  reachable without a redesign.
113
114
 
114
115
  Vaadin's `Binder` is the natural companion for the forms cluster but is
115
- not a component; `D-has-value` already parks the forms-layer questions
116
+ not a component; `D_has_value` already parks the forms-layer questions
116
117
  (converters, read-only, required indicator).
117
118
 
118
119
  ## ~~Cross-cutting open question: component color slots vs. theme tokens~~
119
120
 
120
- **Settled 2026-08-01 as `DECISIONS.md` `D-color-slots`** — the slot, defaulting
121
+ **Settled 2026-08-01 as `DECISIONS.md` `D_color_slots`** — the slot, defaulting
121
122
  to `nil`. Slider and Badge are bound by it when they land; Badge's promotion
122
123
  trigger (a *second* built-in needing the same semantic color) is written up
123
124
  there, so neither has to re-argue it.
data/lib/tuile/ansi.rb CHANGED
@@ -12,6 +12,16 @@ module Tuile
12
12
  # @return [String]
13
13
  RESET = "\e[0m"
14
14
 
15
+ # The bell (`BEL`, `\a`, 0x07) — the "that keystroke went nowhere" signal.
16
+ # Ring it with {Screen#beep} rather than printing it: the bell is terminal
17
+ # IO, which is {Screen}'s job.
18
+ #
19
+ # What the user gets is the *terminal's* business — an audible beep, a
20
+ # visual flash, or nothing at all — and Tuile keeps no preference of its
21
+ # own about that.
22
+ # @return [String]
23
+ BEL = "\a"
24
+
15
25
  # Begin Synchronized Update (DEC private mode 2026, "Synchronized
16
26
  # Output"). The terminal stops refreshing its display and buffers every
17
27
  # subsequent write until {SYNC_END}, then composites the whole batch
data/lib/tuile/buffer.rb CHANGED
@@ -116,7 +116,16 @@ module Tuile
116
116
  def self.display_width(grapheme) = WIDTH_CACHE[grapheme]
117
117
 
118
118
  # @param size [Size] grid dimensions in columns × rows.
119
- def initialize(size)
119
+ # @param color_depth [Symbol] what the terminal can show — one of
120
+ # {ColorDepth::DEPTHS}; {#flush} degrades every emitted color to it.
121
+ # Validated here rather than at paint time: a bad value would otherwise
122
+ # surface as an exception mid-frame, far from the mistake.
123
+ # @raise [ArgumentError] when `color_depth` is not a known depth.
124
+ def initialize(size, color_depth: :truecolor)
125
+ raise ArgumentError, "invalid color depth: #{color_depth.inspect}" unless
126
+ ColorDepth::DEPTHS.include?(color_depth)
127
+
128
+ @color_depth = color_depth
120
129
  allocate_grid(size)
121
130
  # A fresh buffer never matches the terminal yet — the screen holds
122
131
  # whatever was there at startup — so it begins fully dirty and the first
@@ -130,6 +139,12 @@ module Tuile
130
139
  # @return [Integer]
131
140
  attr_reader :width, :height
132
141
 
142
+ # What the terminal can show ({ColorDepth::DEPTHS}). Cells hold whatever
143
+ # color a component painted — {#region_ansi} and friends report that,
144
+ # unchanged — and only {#flush} degrades it on the way to the wire.
145
+ # @return [Symbol]
146
+ attr_reader :color_depth
147
+
133
148
  # @param x [Integer] column.
134
149
  # @param y [Integer] row.
135
150
  # @return [Cell, nil] the live cell at `(x, y)` (do not mutate — paint via
@@ -395,8 +410,9 @@ module Tuile
395
410
  out << TTY::Cursor.move_to(x, y)
396
411
  run_open = true
397
412
  end
398
- out << style.sgr_to(c.style) << c.grapheme
399
- style = c.style
413
+ shown = quantized_style(c.style)
414
+ out << style.sgr_to(shown) << c.grapheme
415
+ style = shown
400
416
  end
401
417
  else
402
418
  run_open = false
@@ -406,6 +422,38 @@ module Tuile
406
422
  style
407
423
  end
408
424
 
425
+ # `style` as {#color_depth} can actually show it, each color through
426
+ # {Color#quantize}. Applied *before* the {StyledString::Style#sgr_to}
427
+ # diff, so two RGBs that quantize onto the same cell emit nothing at all
428
+ # rather than a redundant SGR.
429
+ #
430
+ # The one-slot memo is load-bearing, not a micro-optimization: this runs
431
+ # per dirty *cell*, while a painted run shares one frozen
432
+ # {StyledString::Style} instance, so remembering just the last answer
433
+ # collapses the work onto actual style transitions. Without it a
434
+ # full-screen repaint of RGB-styled content measured 51 ms against 15 ms
435
+ # at `:truecolor` — a keyed cache is still the wrong answer
436
+ # (`D_color_depth`), but paying the arithmetic 8000 times for one span
437
+ # was too.
438
+ #
439
+ # @param style [StyledString::Style]
440
+ # @return [StyledString::Style] `style` itself whenever nothing needed
441
+ # degrading — {Color#quantize}'s identity contract, extended.
442
+ def quantized_style(style)
443
+ return style if @color_depth == :truecolor
444
+ return @quantized_style if style.equal?(@quantized_source)
445
+
446
+ fg = style.fg&.quantize(@color_depth)
447
+ bg = style.bg&.quantize(@color_depth)
448
+ @quantized_source = style
449
+ @quantized_style =
450
+ if fg.equal?(style.fg) && bg.equal?(style.bg)
451
+ style
452
+ else
453
+ style.merge(fg: fg, bg: bg)
454
+ end
455
+ end
456
+
409
457
  # @param rect [Rect]
410
458
  # @return [Array<Array<Cell>>] cells within `rect`, row-major, clamped to
411
459
  # the grid (out-of-bounds positions yield a blank cell).
data/lib/tuile/color.rb CHANGED
@@ -144,6 +144,47 @@ module Tuile
144
144
  end
145
145
  end
146
146
 
147
+ # This color as the nearest one `depth` can actually show — the
148
+ # degradation {Buffer#flush} applies to every color on its way to the wire:
149
+ #
150
+ # Color.rgb(100, 100, 100).quantize(:palette256) # => Color.palette(241)
151
+ # Color.rgb(255, 0, 0).quantize(:ansi16) # => Color::BRIGHT_RED
152
+ # Color.rgb(255, 0, 0).quantize(:truecolor) # => itself, unchanged
153
+ #
154
+ # Returns **the same instance** whenever `depth` shows this color as-is —
155
+ # every named color at every depth, a palette index anywhere but
156
+ # `:ansi16`, RGB at `:truecolor` — so `color.quantize(depth).equal?(color)`
157
+ # *is* the "needs no translating" predicate, and the common path
158
+ # allocates nothing.
159
+ #
160
+ # == Implementation details
161
+ #
162
+ # RGB picks whichever is nearer in squared-RGB distance: the 6×6×6 cube
163
+ # (16..231, its per-channel nearest levels being the nearest cell outright
164
+ # — the axes are independent) or the 24-step grey ramp (232..255, whose
165
+ # nearest step is the one nearest the channel mean).
166
+ #
167
+ # Under `:ansi16` a color goes *direct* to the nearest of the 16, never
168
+ # via the 256-palette — two steps would compound the rounding — and the
169
+ # result is a *named* color, which keeps respecting the user's terminal
170
+ # scheme. Matching is against xterm's default RGBs for the 16, which that
171
+ # scheme may itself redefine: the one mapping here that can be honestly
172
+ # wrong.
173
+ #
174
+ # @param depth [Symbol] one of {ColorDepth::DEPTHS}.
175
+ # @return [Color]
176
+ # @raise [ArgumentError] when `depth` is not a known depth.
177
+ def quantize(depth)
178
+ case depth
179
+ when :truecolor then self
180
+ when :palette256
181
+ @value.is_a?(Array) ? PALETTE_COLORS[nearest_palette(@value)] : self
182
+ when :ansi16
183
+ @value.is_a?(Symbol) ? self : ANSI16_COLORS[nearest_ansi16(rgb_triple)]
184
+ else raise ArgumentError, "invalid color depth: #{depth.inspect}"
185
+ end
186
+ end
187
+
147
188
  # Full SGR escape sequence for this color (e.g. `"\e[31m"`). Useful for
148
189
  # `print`-style direct emission; for composing with other attributes use
149
190
  # {#sgr_codes} instead.
@@ -171,10 +212,112 @@ module Tuile
171
212
  "#<#{self.class.name} #{@value.inspect}>"
172
213
  end
173
214
 
215
+ private
216
+
217
+ # This color's RGB — the palette cell's own coordinates when the value is
218
+ # an index. Only ever asked of a non-Symbol value; a named color has no
219
+ # RGB of its own, since the terminal's scheme decides what it looks like.
220
+ # @return [Array<Integer>] red, green and blue, each 0..255.
221
+ def rgb_triple
222
+ return @value if @value.is_a?(Array)
223
+ return ANSI16_RGB[@value] if @value < 16
224
+ return [8 + (10 * (@value - 232))] * 3 if @value >= 232
225
+
226
+ cube = @value - 16
227
+ [CUBE_LEVELS[cube / 36], CUBE_LEVELS[(cube / 6) % 6], CUBE_LEVELS[cube % 6]]
228
+ end
229
+
230
+ # Written flat — destructured rather than splatted, `x * x` rather than
231
+ # `x**2`, no distance helper — because it runs per style transition in
232
+ # {Buffer#flush}, and the tidy shape measures ~2x slower (see
233
+ # `benchmark/quantize.rb`).
234
+ #
235
+ # @param rgb [Array<Integer>] red, green and blue, each 0..255.
236
+ # @return [Integer] palette index, 16..255 — the nearer of this color's
237
+ # cube cell and its grey-ramp step. A tie goes to the cube, which spans
238
+ # the whole space where the ramp only covers the diagonal.
239
+ def nearest_palette(rgb)
240
+ red, green, blue = rgb
241
+ ri = CUBE_INDEX[red]
242
+ gi = CUBE_INDEX[green]
243
+ bi = CUBE_INDEX[blue]
244
+ dr = CUBE_LEVELS[ri] - red
245
+ dg = CUBE_LEVELS[gi] - green
246
+ db = CUBE_LEVELS[bi] - blue
247
+ cube = (dr * dr) + (dg * dg) + (db * db)
248
+ # The grey minimizing the distance sits at the channel mean, so the best
249
+ # ramp step is the one nearest it; floor division rounds it half-up.
250
+ step = ((((red + green + blue) / 3) - 3) / 10).clamp(0, 23)
251
+ level = 8 + (10 * step)
252
+ gr = level - red
253
+ gg = level - green
254
+ gb = level - blue
255
+ grey = (gr * gr) + (gg * gg) + (gb * gb)
256
+ cube <= grey ? 16 + (36 * ri) + (6 * gi) + bi : 232 + step
257
+ end
258
+
259
+ # @param rgb [Array<Integer>] red, green and blue, each 0..255.
260
+ # @return [Integer] index into {COLOR_SYMBOLS} of the nearest of the 16.
261
+ def nearest_ansi16(rgb)
262
+ red, green, blue = rgb
263
+ best = 0
264
+ best_distance = nil
265
+ index = 0
266
+ while index < 16
267
+ candidate = ANSI16_RGB[index]
268
+ dr = candidate[0] - red
269
+ dg = candidate[1] - green
270
+ db = candidate[2] - blue
271
+ distance = (dr * dr) + (dg * dg) + (db * db)
272
+ if best_distance.nil? || distance < best_distance
273
+ best = index
274
+ best_distance = distance
275
+ end
276
+ index += 1
277
+ end
278
+ best
279
+ end
280
+
174
281
  COLOR_SYMBOLS.each do |sym|
175
282
  const_set(sym.upcase, new(sym))
176
283
  end
177
284
 
285
+ # The channel values the 6×6×6 cube (palette 16..231) samples.
286
+ # @return [Array<Integer>]
287
+ CUBE_LEVELS = [0, 95, 135, 175, 215, 255].freeze
288
+ private_constant :CUBE_LEVELS
289
+
290
+ # Channel value 0..255 → index into {CUBE_LEVELS} of the nearest level,
291
+ # so quantizing a channel is one array read rather than six compares.
292
+ # @return [Array<Integer>]
293
+ CUBE_INDEX = Array.new(256) { |c| (0...6).min_by { |i| (CUBE_LEVELS[i] - c).abs } }.freeze
294
+ private_constant :CUBE_INDEX
295
+
296
+ # xterm's default RGB for each of the 16 named colors, in {COLOR_SYMBOLS}
297
+ # order — what `:ansi16` quantization matches against. A terminal scheme
298
+ # may redefine these; see {#quantize}.
299
+ # @return [Array<Array<Integer>>]
300
+ ANSI16_RGB = [
301
+ [0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
302
+ [0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192],
303
+ [128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0],
304
+ [0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255]
305
+ ].freeze
306
+ private_constant :ANSI16_RGB
307
+
308
+ # Every named color, in {COLOR_SYMBOLS} order — the shared instances
309
+ # `:ansi16` quantization returns.
310
+ # @return [Array<Color>]
311
+ ANSI16_COLORS = COLOR_SYMBOLS.map { |sym| const_get(sym.upcase) }.freeze
312
+ private_constant :ANSI16_COLORS
313
+
314
+ # Every palette cell 0..255 as a {Color}, so quantizing to the palette
315
+ # allocates nothing and lands on a shared instance. Distinct from the
316
+ # {PALETTE_NAMES} constants, which cover only the *named* cells.
317
+ # @return [Array<Color>]
318
+ PALETTE_COLORS = Array.new(256) { |index| new(index) }.freeze
319
+ private_constant :PALETTE_COLORS
320
+
178
321
  # Names for the 256-color palette indices 16..255, from the standard
179
322
  # xterm chart (<https://www.ditig.com/256-colors-cheat-sheet>). A constant
180
323
  # per entry is pre-defined, an exact palette cell — no quantization:
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # How many colors the terminal on the other end can actually show — the
5
+ # depth {Color#quantize} degrades a color to:
6
+ #
7
+ # ColorDepth.detect # => :truecolor
8
+ # ColorDepth.detect(env: { "TERM" => "xterm-256color" }) # => :palette256
9
+ # ColorDepth.detect(env: {}) # => :ansi16
10
+ #
11
+ # Env-only: no terminal round-trip, so unlike {TerminalBackground.detect}
12
+ # there is no stdin timing to respect, and the answer cannot go stale
13
+ # mid-session the way a background color can.
14
+ #
15
+ # Terminals lie in both directions — `COLORTERM` frequently doesn't survive
16
+ # ssh (it isn't in the default `SendEnv` set) or tmux — so {OVERRIDE_ENV}
17
+ # beats every other signal, the escape hatch for a terminal detected wrong.
18
+ # Misdetection otherwise lands *conservatively*: a truecolor tmux
19
+ # advertising only `tmux-256color` reads as `:palette256`, which renders
20
+ # coarser but never mangled.
21
+ #
22
+ # == Implementation details
23
+ #
24
+ # Terminfo is deliberately not consulted — its `RGB` boolean and
25
+ # `colors#0x1000000` would mean shelling out to `tput`/`infocmp` at every
26
+ # startup, and the env ladder plus the override already covers the real
27
+ # terminal matrix.
28
+ module ColorDepth
29
+ # The depths, most capable first: 24-bit RGB, the 256-color palette, and
30
+ # the 16 named ANSI colors.
31
+ # @return [Array<Symbol>]
32
+ DEPTHS = %i[truecolor palette256 ansi16].freeze
33
+
34
+ # Environment variable that overrides detection outright; holds one of
35
+ # {DEPTHS}. Empty counts as unset.
36
+ # @return [String]
37
+ OVERRIDE_ENV = "TUILE_COLOR_DEPTH"
38
+
39
+ # `COLORTERM` values that promise 24-bit color.
40
+ # @return [Array<String>]
41
+ TRUECOLOR_COLORTERM = %w[truecolor 24bit].freeze
42
+
43
+ class << self
44
+ # The terminal's color depth, from {OVERRIDE_ENV}, else `COLORTERM`,
45
+ # else `TERM` (a `-direct` entry means 24-bit, a `256color` one the
46
+ # palette), else the 16-color floor.
47
+ #
48
+ # @param env [Hash{String => String}] environment to read; defaults to
49
+ # `ENV` (which duck-types the `[]` lookup).
50
+ # @return [Symbol] one of {DEPTHS}.
51
+ # @raise [ArgumentError] when {OVERRIDE_ENV} holds an unknown value. It
52
+ # is only ever set deliberately, so a typo in it is worth failing at
53
+ # startup over — ignoring it silently means a whole session of
54
+ # debugging the wrong colors.
55
+ def detect(env: ENV)
56
+ override = env[OVERRIDE_ENV].to_s
57
+ return parse_override(override) unless override.empty?
58
+
59
+ term = env["TERM"].to_s
60
+ return :truecolor if TRUECOLOR_COLORTERM.include?(env["COLORTERM"].to_s.downcase) ||
61
+ term.include?("-direct")
62
+ return :palette256 if term.include?("256color")
63
+
64
+ :ansi16
65
+ end
66
+
67
+ private
68
+
69
+ # @param value [String] the raw {OVERRIDE_ENV} value.
70
+ # @return [Symbol]
71
+ def parse_override(value)
72
+ depth = value.strip.downcase.to_sym
73
+ return depth if DEPTHS.include?(depth)
74
+
75
+ raise ArgumentError,
76
+ "invalid #{OVERRIDE_ENV}: #{value.inspect} (expected one of #{DEPTHS.join(", ")})"
77
+ end
78
+ end
79
+ end
80
+ end
@@ -42,6 +42,8 @@ module Tuile
42
42
  #
43
43
  # - {#preprocess_text} — input filter (e.g. {TextField} truncates to
44
44
  # fit `rect.width - 1`).
45
+ # - {#preprocess_paste} — the same for {#handle_paste}, which lands a
46
+ # whole clipboard at the caret in one mutation.
45
47
  # - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
46
48
  # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
47
49
  # keep the caret visible).
@@ -155,8 +157,42 @@ module Tuile
155
157
  handle_text_input_key(key)
156
158
  end
157
159
 
160
+ # Inserts pasted text at the caret as **one** mutation, so {#on_change}
161
+ # fires once for the whole paste rather than once per character.
162
+ # {#preprocess_paste} filters it first.
163
+ # @param text [String]
164
+ # @return [Boolean] always true — a field consumes every paste, an empty
165
+ # one included.
166
+ def handle_paste(text)
167
+ insert_text(preprocess_paste(text))
168
+ true
169
+ end
170
+
158
171
  protected
159
172
 
173
+ # Input filter for {#handle_paste}, the paste-side counterpart of
174
+ # {#preprocess_text}. Strips the C0 control characters a text buffer
175
+ # cannot hold — a raw `\e` or `\t` reaching {Buffer} would move the real
176
+ # terminal cursor mid-frame — keeping `\n`, and turning a tab into a
177
+ # single space so pasted code keeps its word gaps. {TextField} narrows it
178
+ # further; an app wanting tab *expansion* overrides {#handle_paste}.
179
+ # @param text [String]
180
+ # @return [String]
181
+ def preprocess_paste(text) = text.tr("\t", " ").gsub(/[\x00-\x09\x0b-\x1f\x7f]/, "")
182
+
183
+ # Inserts `str` at the caret, leaving the caret behind it. The bulk
184
+ # counterpart of a subclass's per-key insert.
185
+ # @param str [String]
186
+ # @return [Boolean] true if the text changed.
187
+ def insert_text(str)
188
+ return false if str.empty?
189
+
190
+ new_text = @text.dup.insert(@caret, str)
191
+ @caret += str.length
192
+ self.text = new_text
193
+ true
194
+ end
195
+
160
196
  # Renders `text` on the field's background well, looked up from the
161
197
  # current {Screen#theme} at paint time: {Theme#active_bg_color} when this
162
198
  # input is on the active (focus) chain, {Theme#input_bg_color} otherwise —
@@ -55,8 +55,8 @@ module Tuile
55
55
  # It still *focuses*: {Component#handle_mouse}'s click-to-focus is ungated
56
56
  # by geometry. Same rule as {Checkbox#extent}, which documents the two
57
57
  # traps behind it.
58
- # @return [Rect]
59
- def extent = Rect.new(rect.left, rect.top, [caption.display_width + 4, rect.width].min, 1)
58
+ # @return [Size]
59
+ def extent = Size.new([caption.display_width + 4, rect.width].min, 1)
60
60
 
61
61
  # Fires {#on_click} on a left click within {#extent}; `super` runs first, so
62
62
  # a click anywhere in {#rect} still focuses.
@@ -64,7 +64,7 @@ module Tuile
64
64
  # @return [void]
65
65
  def handle_mouse(event)
66
66
  super
67
- return unless event.button == :left && extent.contains?(event.point)
67
+ return unless event.button == :left && extent_rect.contains?(event.point)
68
68
 
69
69
  @on_click&.call
70
70
  end
@@ -95,8 +95,8 @@ module Tuile
95
95
  # The extent ignores {Component#bg_color}: an inherited tint paints the dead
96
96
  # tail, but a hit test that silently widened with a background would be a
97
97
  # mode switch invisible in the code and untestable by inspection.
98
- # @return [Rect]
99
- def extent = Rect.new(rect.left, rect.top, [caption.display_width + 4, rect.width].min, 1)
98
+ # @return [Size]
99
+ def extent = Size.new([caption.display_width + 4, rect.width].min, 1)
100
100
 
101
101
  # Toggles on Space or Enter. Every other key is left unhandled so it bubbles
102
102
  # to an ancestor.
@@ -115,7 +115,7 @@ module Tuile
115
115
  # @return [void]
116
116
  def handle_mouse(event)
117
117
  super
118
- return unless event.button == :left && extent.contains?(event.point)
118
+ return unless event.button == :left && extent_rect.contains?(event.point)
119
119
 
120
120
  toggle
121
121
  end