tuile 0.13.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +1095 -195
  5. data/README.md +19 -19
  6. data/TERMINOLOGY.md +6 -5
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +4 -1
  9. data/book/06-theming.md +98 -0
  10. data/book/07-components.md +169 -19
  11. data/book/08-testing.md +16 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/examples/file_commander.rb +1 -1
  14. data/examples/sampler.rb +143 -43
  15. data/ideas/arrow-key-navigation.md +2 -2
  16. data/ideas/modal-backdrop.md +24 -0
  17. data/ideas/new-components.md +24 -24
  18. data/lib/tuile/buffer.rb +51 -3
  19. data/lib/tuile/color.rb +143 -0
  20. data/lib/tuile/color_depth.rb +80 -0
  21. data/lib/tuile/component/button.rb +3 -3
  22. data/lib/tuile/component/checkbox.rb +3 -3
  23. data/lib/tuile/component/combo_box.rb +9 -2
  24. data/lib/tuile/component/confirm_window.rb +442 -0
  25. data/lib/tuile/component/has_content.rb +22 -9
  26. data/lib/tuile/component/has_value.rb +1 -1
  27. data/lib/tuile/component/info_window.rb +64 -16
  28. data/lib/tuile/component/layout.rb +0 -10
  29. data/lib/tuile/component/list_dropdown.rb +18 -10
  30. data/lib/tuile/component/log_text_view.rb +71 -0
  31. data/lib/tuile/component/log_window.rb +13 -48
  32. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  33. data/lib/tuile/component/menu_bar.rb +5 -5
  34. data/lib/tuile/component/notification.rb +16 -34
  35. data/lib/tuile/component/overlay.rb +192 -0
  36. data/lib/tuile/component/popup.rb +59 -187
  37. data/lib/tuile/component/progress_bar.rb +1 -1
  38. data/lib/tuile/component/select.rb +14 -6
  39. data/lib/tuile/component/slot.rb +54 -0
  40. data/lib/tuile/component/tab_sheet.rb +0 -11
  41. data/lib/tuile/component/tabs.rb +5 -5
  42. data/lib/tuile/component/window.rb +22 -46
  43. data/lib/tuile/component.rb +149 -19
  44. data/lib/tuile/event_queue.rb +21 -1
  45. data/lib/tuile/fake_screen.rb +26 -2
  46. data/lib/tuile/keys.rb +7 -0
  47. data/lib/tuile/screen.rb +120 -38
  48. data/lib/tuile/screen_pane.rb +37 -35
  49. data/lib/tuile/styled_string.rb +40 -7
  50. data/lib/tuile/terminal_background.rb +74 -16
  51. data/lib/tuile/version.rb +1 -1
  52. data/sig/tuile.rbs +1157 -368
  53. metadata +8 -1
data/examples/sampler.rb CHANGED
@@ -134,7 +134,7 @@ module SamplerExample
134
134
  attr_reader :demo_window, :menu_bar, :jump_box
135
135
 
136
136
  # The bottom row. Tuile draws no status bar and reserves no row
137
- # (`D-status-bar`) — this one is the sampler's own, kept current by
137
+ # (`D_status_bar`) — this one is the sampler's own, kept current by
138
138
  # {Tuile::Screen#on_focus_changed=}. Naming the focused component makes Tab
139
139
  # traversal visible as you walk a pane, which no per-pane label shows.
140
140
  # @return [void]
@@ -216,6 +216,7 @@ module SamplerExample
216
216
  Menu.new("Overlay", "o", [
217
217
  Entry.new("Popup", :build_popup_launcher, "p"),
218
218
  Entry.new("Notification", :build_notification_launcher, "n"),
219
+ Entry.new("ConfirmWindow", :build_confirm_launcher, "c"),
219
220
  Entry.new("InfoWindow", :build_info_launcher, "i"),
220
221
  Entry.new("PickerWindow", :build_picker_launcher, "k"),
221
222
  Entry.new("LogWindow", :build_log_window, "l")
@@ -491,24 +492,21 @@ module SamplerExample
491
492
  # Slash commands the demo offers; the menu filters these by what's typed.
492
493
  SLASH_COMMANDS = %w[/help /list /open /save /clear /quit].freeze
493
494
 
494
- # A non-modal Popup used as an autocomplete menu. Focus (and the caret)
495
- # stays in the TextArea the whole time: an `on_change` listener refills the
496
- # menu, an `on_key` interceptor forwards Up/Down/Enter/ESC to it while it's
497
- # open, and the menu floats above the field, anchored to the caret. None of
498
- # this is baked into TextArea — it's all assembled here from stock hooks.
495
+ # A ListDropdown driven from a TextArea — the same shape {ComboBox} and
496
+ # {Select} use, but wired by app code onto a field that knows nothing about
497
+ # it. Focus (and the caret) stays in the TextArea the whole time: an
498
+ # `on_change` listener refills the menu, and an `on_key` interceptor hands
499
+ # movement keys to `#move` and Enter to `#choose` while it is open. None of
500
+ # this is baked into TextArea.
499
501
  def build_slash_demo
500
502
  prompt = Tuile::Component::Label.new
501
- prompt.text = "Non-modal Popup as an autocomplete menu. Type a slash command\n" \
502
- "(try \"/\" or \"/s\"). The menu floats above the field without taking\n" \
503
+ prompt.text = "A ListDropdown driven from a TextArea. Type a slash command\n" \
504
+ "(try \"/\" or \"/s\"). The menu floats over the field without taking\n" \
503
505
  "focus: Down/Up move the selection, Enter accepts, ESC dismisses, and\n" \
504
506
  "ordinary typing keeps editing the field and refilters the menu."
505
507
  area = Tuile::Component::TextArea.new
506
508
 
507
- list = Tuile::Component::List.new
508
- list.cursor = Tuile::Component::List::Cursor.new
509
- list.show_cursor_when_inactive = true # highlight the selection though focus stays in the field
510
- window = Tuile::Component::Window.new("Commands").tap { _1.content = list }
511
- overlay = Tuile::Component::Popup.new(content: window, modal: false)
509
+ overlay = Tuile::Component::ListDropdown.new
512
510
  @slash_overlay = overlay
513
511
 
514
512
  refill = lambda do
@@ -516,20 +514,23 @@ module SamplerExample
516
514
  if matches.empty?
517
515
  overlay.close if overlay.open?
518
516
  else
517
+ overlay.items = matches
519
518
  overlay.open unless overlay.open?
520
- list.lines = matches
521
- anchor_overlay(overlay, area)
519
+ # Width is the driver's call, never the dropdown's: measure the
520
+ # commands rather than inherit the full-width TextArea's columns.
521
+ overlay.anchor_to(area.rect, rows: matches.size, width: slash_menu_width(matches))
522
522
  end
523
523
  end
524
524
 
525
525
  area.on_change = ->(_text) { refill.call }
526
- list.on_item_chosen = ->(_idx, line) { accept_slash_command(area, line.to_s) }
526
+ overlay.on_item_chosen = ->(_idx, item) { accept_slash_command(area, item.to_s) }
527
527
  area.on_key = lambda do |key|
528
528
  next false unless overlay.open?
529
+ next true if overlay.move(key) # Up/Down/PgUp/PgDn/^U/^D
529
530
 
530
531
  case key
531
- when Tuile::Keys::UP_ARROW, Tuile::Keys::DOWN_ARROW, Tuile::Keys::ENTER
532
- list.handle_key(key) # works though the list is unfocused — dispatch gates on focus, not the list
532
+ when Tuile::Keys::ENTER
533
+ overlay.choose
533
534
  when Tuile::Keys::ESC
534
535
  overlay.close
535
536
  true
@@ -928,6 +929,21 @@ module SamplerExample
928
929
  BgChoice.new("Hot pink (RGB)", Tuile::Color.rgb(120, 20, 70))
929
930
  ].freeze
930
931
 
932
+ # The one choice that can't be a constant: the terminal's *own* background
933
+ # stepped +10 per channel — the borderless-pane tint, which only sits right
934
+ # when it's derived from the real background. Nil on a terminal that
935
+ # reported none, which is the branch most users will actually see.
936
+ def terminal_tint_choice
937
+ bg = Tuile::Screen.instance.background_color
938
+ return BgChoice.new("Terminal background — none reported", nil) if bg.nil?
939
+
940
+ BgChoice.new("Terminal background +10 (derived)",
941
+ Tuile::Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }))
942
+ end
943
+
944
+ # @param derived [BgChoice] the live terminal-derived tint, offered second.
945
+ def bg_choices(derived) = [BG_CHOICES.first, derived, *BG_CHOICES[1..]]
946
+
931
947
  def build_background
932
948
  intro = Tuile::Component::Label.new
933
949
  intro.text = "bg_color tints a component and every descendant that doesn't set its own.\n" \
@@ -951,7 +967,8 @@ module SamplerExample
951
967
  # their own well. Theme::Ref picks re-resolve on a scheme flip with no hook;
952
968
  # the hard-coded Colors are fixed by design, so no on_theme_changed here.
953
969
  outer = nil
954
- combo = Tuile::Component::ComboBox.new(items: BG_CHOICES)
970
+ derived = terminal_tint_choice
971
+ combo = Tuile::Component::ComboBox.new(items: bg_choices(derived))
955
972
  combo.item_label = :label.to_proc
956
973
  combo.on_value_change = ->(choice) { outer.bg_color = choice.color }
957
974
 
@@ -960,6 +977,17 @@ module SamplerExample
960
977
  f.add(combo, Fixed[1], cross: Fixed[40])
961
978
  f.add(box, Expand[1])
962
979
  end
980
+ # The derived tint is the one pick whose *color* moves under it: a flip
981
+ # re-probes the terminal, so rebuild the choice and re-apply it if it is
982
+ # the current one. Expect it to correct itself a frame late — the flip
983
+ # report carries no RGB, so this hook runs once on the old background and
984
+ # again when the re-probe answers.
985
+ outer.on_theme_changed = lambda do
986
+ was_derived = combo.value.equal?(derived)
987
+ derived = terminal_tint_choice
988
+ combo.items = bg_choices(derived)
989
+ combo.value = derived if was_derived
990
+ end
963
991
  combo.value = BG_CHOICES.first # show "None" as the resting selection
964
992
  outer
965
993
  end
@@ -1166,6 +1194,61 @@ module SamplerExample
1166
1194
  end
1167
1195
  end
1168
1196
 
1197
+ # The three factories, the layer-1 builder (3-way), and a message long
1198
+ # enough to scroll. The status row makes the one-dismissal-channel contract
1199
+ # visible: every route out of a dialog lands in exactly one callback.
1200
+ def build_confirm_launcher
1201
+ label = Tuile::Component::Label.new
1202
+ label.text = "ConfirmWindow asks a question with a row of buttons, in a popup sized to\n" \
1203
+ "its content (capped at half the screen). Every button closes the dialog;\n" \
1204
+ "ESC, q or an outside click dismiss it instead. An underlined letter presses\n" \
1205
+ "its button from anywhere; Up/Down scroll a long message meanwhile."
1206
+ status = Tuile::Component::Label.new("Outcome: none yet")
1207
+ report = ->(outcome) { status.text = "Outcome: #{outcome}" }
1208
+ buttons = [
1209
+ Tuile::Component::Button.new("Confirm") do
1210
+ Tuile::Component::ConfirmWindow.confirm(
1211
+ "Delete Report Q4?", "This cannot be undone.",
1212
+ confirm: "Delete", on_dismiss: -> { report.call("kept the report") }
1213
+ ) { report.call("deleted the report") }
1214
+ end,
1215
+ Tuile::Component::Button.new("Yes/No") do
1216
+ Tuile::Component::ConfirmWindow.yes_no(
1217
+ "Overwrite draft.txt?", "The file already exists.",
1218
+ on_dismiss: -> { report.call("kept draft.txt") }
1219
+ ) { report.call("overwrote draft.txt") }
1220
+ end,
1221
+ Tuile::Component::Button.new("Alert") do
1222
+ Tuile::Component::ConfirmWindow.alert("Export failed", "Contact support@example.com.")
1223
+ end,
1224
+ Tuile::Component::Button.new("3-way") do
1225
+ dialog = Tuile::Component::ConfirmWindow.new("Unsaved changes")
1226
+ dialog.message = "Save your changes before leaving?"
1227
+ dialog.button("Save") { report.call("saved") }
1228
+ dialog.button("Discard") { report.call("discarded") }
1229
+ dialog.button("Cancel")
1230
+ dialog.on_dismiss = -> { report.call("stayed put") }
1231
+ dialog.open
1232
+ end,
1233
+ Tuile::Component::Button.new("Long") do
1234
+ dialog = Tuile::Component::ConfirmWindow.new("Terms of Service")
1235
+ dialog.message = (1..40).map { "#{_1}. Clause #{_1} of the agreement, spelled out in full." }.join("\n")
1236
+ dialog.button("Accept") { report.call("accepted the terms") }
1237
+ dialog.button("Decline") { report.call("declined the terms") }
1238
+ dialog.on_dismiss = -> { report.call("left the terms unanswered") }
1239
+ dialog.open
1240
+ end
1241
+ ]
1242
+ strip = row do |r|
1243
+ buttons.each { |b| r.add(b, Fixed[button_width(b)]) }
1244
+ end
1245
+ form do |f|
1246
+ f.add(label, Fixed[4])
1247
+ f.add(strip, Fixed[1])
1248
+ f.add(status, Fixed[1])
1249
+ end
1250
+ end
1251
+
1169
1252
  def build_popup_launcher
1170
1253
  launcher(
1171
1254
  "Popup is a modal overlay wrapping any Component.\n" \
@@ -1179,17 +1262,39 @@ module SamplerExample
1179
1262
  end
1180
1263
 
1181
1264
  def build_info_launcher
1182
- launcher(
1183
- "InfoWindow is a Window of read-only text lines, openable as a popup.",
1184
- "Open InfoWindow"
1185
- ) do
1186
- Tuile::Component::InfoWindow.open(
1187
- "Hello",
1188
- ["InfoWindow displays static text",
1189
- "inside a popup.",
1190
- "",
1191
- "Press ESC or q to close."]
1192
- )
1265
+ label = Tuile::Component::Label.new
1266
+ label.text = "InfoWindow is a Window with a read-only body: prose (message=) wraps in\n" \
1267
+ "a TextView, rows (lines=) stay one per row in a List, truncating. The\n" \
1268
+ "constructor picks the presentation by the body's type."
1269
+ buttons = [
1270
+ Tuile::Component::Button.new("Prose") do
1271
+ Tuile::Component::InfoWindow.open(
1272
+ "About",
1273
+ "InfoWindow renders a String as wrapping prose: this sentence is long " \
1274
+ "enough to wrap to the popup's width, and it scrolls when it outgrows " \
1275
+ "the box. Press ESC or q to close."
1276
+ )
1277
+ end,
1278
+ Tuile::Component::Button.new("Rows") do
1279
+ Tuile::Component::InfoWindow.open(
1280
+ "Files",
1281
+ ["drwxr-xr-x src/",
1282
+ "drwxr-xr-x spec/",
1283
+ "-rw-r--r-- README.md 4.1k",
1284
+ "-rw-r--r-- Rakefile 812",
1285
+ "",
1286
+ "Rows never wrap: a long row like this one is truncated at the popup's edge, keeping columns aligned.",
1287
+ "",
1288
+ "Press ESC or q to close."]
1289
+ )
1290
+ end
1291
+ ]
1292
+ strip = row do |r|
1293
+ buttons.each { |b| r.add(b, Fixed[button_width(b)]) }
1294
+ end
1295
+ form do |f|
1296
+ f.add(label, Fixed[3])
1297
+ f.add(strip, Fixed[1])
1193
1298
  end
1194
1299
  end
1195
1300
 
@@ -1207,7 +1312,7 @@ module SamplerExample
1207
1312
 
1208
1313
  def build_log_window
1209
1314
  log = Tuile::Component::LogWindow.new("Log")
1210
- ["LogWindow is a Window wrapping an auto-scrolling TextView.",
1315
+ ["LogWindow is a Window framing an auto-scrolling LogTextView.",
1211
1316
  "Lines are appended via #log (safe from any thread).",
1212
1317
  "Used with Logger::IO it captures arbitrary log output."].each { |line| log.log(line) }
1213
1318
  log
@@ -1315,18 +1420,13 @@ module SamplerExample
1315
1420
  area.caret = start + command.length + 1
1316
1421
  end
1317
1422
 
1318
- # Positions the overlay just below the caret, flipping above when there's
1319
- # no room beneath, and clamps it to the screen.
1320
- def anchor_overlay(overlay, area)
1321
- caret = area.cursor_position
1322
- return if caret.nil?
1323
-
1324
- screen_size = Tuile::Screen.instance.size
1325
- size = overlay.rect
1326
- top = caret.y + 1
1327
- top = [caret.y - size.height, 0].max if top + size.height > screen_size.height - 1
1328
- left = caret.x.clamp(0, [screen_size.width - size.width, 0].max)
1329
- overlay.rect = Tuile::Rect.new(left, top, size.width, size.height)
1423
+ # The slash menu's width: the widest command plus List's two row gutters,
1424
+ # clamped to the screen. ListDropdown places itself but never measures — the
1425
+ # width policy stays with the driver, exactly as it does for Select.
1426
+ # @return [Integer]
1427
+ def slash_menu_width(matches)
1428
+ widest = matches.map { Tuile::StyledString.plain(_1).display_width }.max || 0
1429
+ [widest + 2, Tuile::Screen.instance.size.width].min
1330
1430
  end
1331
1431
 
1332
1432
  # A button's natural width — enough to show "[ caption ]".
@@ -107,7 +107,7 @@ Recorded here so the open questions below stay narrow.
107
107
  - **{Tuile::Component::Tabs} already left the vertical axis free for this.**
108
108
  The strip claims Left/Right and *declines* Up/Down specifically so that this
109
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
110
+ tabs inside it (`D_tabs`). It composes for nothing: the strip declines, the
111
111
  key bubbles, the navigating ancestor moves. That is also the shape to copy
112
112
  for any future one-axis widget — claim one axis, leave the other.
113
113
 
@@ -215,7 +215,7 @@ Also: any public signature change means `rake sig` in the same commit.
215
215
 
216
216
  If built: the user-facing half goes to book ch5 (the key/Enter tables live
217
217
  there), the invariants half to AGENTS.md's key-dispatch section, and the
218
- 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` —
219
219
  which must record the `Layout::Form` rejection and the Vaadin FormGroup
220
220
  precedent behind it, since that's the reasoning most likely to be
221
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,20 +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~~ | 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`) |
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
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 |
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 |
56
56
  | Slider | `draw_line` | arrows/PgUp; 25.2 also has a two-thumb *range* variant |
57
57
  | Breadcrumbs | `Label`/`StyledString` | clickable path segments |
58
58
  | Markdown | `TextView` + `StyledString` | Markdown subset → styled text; high value on a TTY |
@@ -70,7 +70,7 @@ That leaves ~46 gaps.
70
70
  | Virtual List | a lazy data-provider strategy on `List` |
71
71
  | Side Nav | hierarchical collapsible list (the sampler's nav is the prototype) |
72
72
  | App Layout | shell: title bar + drawer + content slot |
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 |
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 |
74
74
  | Message Input / Message List / Login | nothing — pure assemblies, good example fodder |
75
75
  | Upload | reinterpret as a file-chooser dialog (`file_commander` has the ingredients) |
76
76
  | Icon | a glyph / Nerd-Font constants module |
@@ -91,7 +91,7 @@ That leaves ~46 gaps.
91
91
  These are prerequisites, not components, and each deserves its own idea
92
92
  file when its cluster comes up:
93
93
 
94
- 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
95
95
  out *not* to be structural: a `Box` is an `Absolute` subclass with a `rect=`
96
96
  override, so it unblocked the form-shaped cluster without touching the
97
97
  foundation. A future Grid should reuse its `Fixed`/`Percent`/`Expand`
@@ -100,25 +100,25 @@ file when its cluster comes up:
100
100
  Layout is actually blocked on — the layout half now exists.
101
101
  3. **Validation seam** → Email Field, forms generally.
102
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
103
+ Bar** — `D_menu_bar` argues the side-anchor is a sibling method on
104
104
  `ListDropdown`, since both callers still wrap a `List`; the extraction's
105
105
  trigger is now the first non-`List` content that wants anchoring.
106
106
  5. **Mouse motion/drag** (modes 1002/1006, release events) → Split
107
107
  divider, Slider drag, scrollbar drag.
108
108
  6. **Typed items + data provider on `List`** → List Box, Grid, Virtual
109
- 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` +
110
110
  a `renderer` and renders only the visible rows, and the five composers
111
111
  are folded onto it. The remaining half is *sourcing* items lazily (a
112
112
  data provider behind `items`), which lazy rendering was chosen to keep
113
113
  reachable without a redesign.
114
114
 
115
115
  Vaadin's `Binder` is the natural companion for the forms cluster but is
116
- 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
117
117
  (converters, read-only, required indicator).
118
118
 
119
119
  ## ~~Cross-cutting open question: component color slots vs. theme tokens~~
120
120
 
121
- **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
122
122
  to `nil`. Slider and Badge are bound by it when they land; Badge's promotion
123
123
  trigger (a *second* built-in needing the same semantic color) is written up
124
124
  there, so neither has to re-argue it.
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).