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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +81 -37
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +1095 -195
- data/README.md +19 -19
- data/TERMINOLOGY.md +6 -5
- data/book/03-layout.md +17 -10
- data/book/05-focus.md +4 -1
- data/book/06-theming.md +98 -0
- data/book/07-components.md +169 -19
- data/book/08-testing.md +16 -0
- data/book/09-styled-text.md +3 -3
- data/examples/file_commander.rb +1 -1
- data/examples/sampler.rb +143 -43
- data/ideas/arrow-key-navigation.md +2 -2
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +24 -24
- 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/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +9 -2
- 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_dropdown.rb +18 -10
- 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 +3 -3
- data/lib/tuile/component/menu_bar.rb +5 -5
- data/lib/tuile/component/notification.rb +16 -34
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/popup.rb +59 -187
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +14 -6
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +0 -11
- data/lib/tuile/component/tabs.rb +5 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +149 -19
- data/lib/tuile/event_queue.rb +21 -1
- data/lib/tuile/fake_screen.rb +26 -2
- data/lib/tuile/keys.rb +7 -0
- data/lib/tuile/screen.rb +120 -38
- data/lib/tuile/screen_pane.rb +37 -35
- data/lib/tuile/styled_string.rb +40 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1157 -368
- 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
|
-
# (`
|
|
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
|
|
495
|
-
#
|
|
496
|
-
#
|
|
497
|
-
#
|
|
498
|
-
#
|
|
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 = "
|
|
502
|
-
"(try \"/\" or \"/s\"). The menu floats
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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::
|
|
532
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
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
|
|
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
|
-
#
|
|
1319
|
-
#
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
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 (`
|
|
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 `
|
|
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.
|
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,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 (`
|
|
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 → TabSheet~~ | plain `Component` + the tree API | **built** 2026-08-23 (`
|
|
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 (`
|
|
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); `
|
|
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 (`
|
|
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** — `
|
|
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 (`
|
|
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; `
|
|
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` `
|
|
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
|
-
|
|
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).
|