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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -27
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +2342 -158
- data/README.md +151 -505
- data/TERMINOLOGY.md +15 -5
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +28 -20
- data/book/05-focus.md +137 -19
- data/book/06-theming.md +103 -2
- data/book/07-components.md +567 -27
- data/book/08-testing.md +34 -4
- data/book/09-styled-text.md +3 -3
- data/book/README.md +7 -5
- data/examples/file_commander.rb +23 -17
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +527 -108
- data/ideas/arrow-key-navigation.md +17 -1
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +28 -27
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +12 -3
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/has_content.rb +22 -9
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/layout.rb +0 -10
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +102 -11
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +24 -39
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +61 -123
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +17 -7
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +231 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area.rb +5 -4
- data/lib/tuile/component/text_field.rb +23 -6
- data/lib/tuile/component/text_view.rb +8 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +186 -31
- data/lib/tuile/event_queue.rb +45 -1
- data/lib/tuile/fake_screen.rb +40 -2
- data/lib/tuile/keys.rb +72 -0
- data/lib/tuile/screen.rb +212 -113
- data/lib/tuile/screen_pane.rb +125 -41
- data/lib/tuile/styled_string.rb +80 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2527 -358
- metadata +13 -3
- 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 `
|
|
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.
|
data/ideas/new-components.md
CHANGED
|
@@ -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` `
|
|
13
|
-
(`
|
|
14
|
-
built 2026-07-31; `progress-bar` (`
|
|
15
|
-
progress") and `password-field` (`
|
|
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
|
-
(`
|
|
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
|
-
(`
|
|
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 (`
|
|
43
|
-
| ~~Checkbox~~ | `HasValue` | **built** 2026-07-30 (`
|
|
44
|
-
| ~~Radio Group~~ | `List` + `HasValue` | **built** 2026-07-31 (`
|
|
45
|
-
| ~~Checkbox Group~~ | `List` + `HasValue` | **built** 2026-07-30 (`
|
|
46
|
-
| ~~Select~~ | `ListDropdown` + `HasValue` | **built** 2026-08-12 (`
|
|
47
|
-
| ~~Password Field~~ | `TextField` | **built** 2026-08-02 (`
|
|
48
|
-
| ~~Number Field~~ | `IntegerField` twin | **built** 2026-08-07 as `FloatField` (`
|
|
49
|
-
| ~~Progress Bar~~ | `draw_line` + `EventQueue#tick_fps` | **built** 2026-08-02 (`
|
|
50
|
-
| ~~Notification~~ | `Popup` + `Ticker` | **built** 2026-08-17 (`
|
|
51
|
-
| Confirm Dialog | `Popup`+`Window`+`Button` |
|
|
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 →
|
|
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.
|
|
55
|
-
| Menu Bar | `ListDropdown
|
|
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); `
|
|
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 (`
|
|
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** →
|
|
104
|
-
|
|
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 (`
|
|
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; `
|
|
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` `
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
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 [
|
|
59
|
-
def extent =
|
|
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 &&
|
|
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 [
|
|
99
|
-
def extent =
|
|
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 &&
|
|
118
|
+
return unless event.button == :left && extent_rect.contains?(event.point)
|
|
119
119
|
|
|
120
120
|
toggle
|
|
121
121
|
end
|