tuile 0.8.0 → 0.10.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 +47 -0
- data/DECISIONS.md +1961 -0
- data/README.md +82 -48
- data/book/01-first-app.md +186 -0
- data/book/02-repaint.md +177 -0
- data/book/03-layout.md +379 -0
- data/book/04-event-loop.md +295 -0
- data/book/05-focus.md +219 -0
- data/book/06-theming.md +302 -0
- data/book/07-components.md +585 -0
- data/book/08-testing.md +199 -0
- data/book/09-styled-text.md +132 -0
- data/book/README.md +85 -0
- data/examples/hello_world.rb +1 -2
- data/examples/sampler.rb +435 -20
- data/ideas/new-components.md +109 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +113 -43
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/button.rb +25 -29
- data/lib/tuile/component/checkbox.rb +133 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +281 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/info_window.rb +4 -2
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +20 -25
- data/lib/tuile/component/layout.rb +3 -26
- data/lib/tuile/component/list.rb +8 -33
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/log_window.rb +0 -14
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +70 -79
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -137
- data/lib/tuile/component/window.rb +88 -121
- data/lib/tuile/component.rb +246 -142
- data/lib/tuile/event_queue.rb +39 -21
- data/lib/tuile/fake_event_queue.rb +32 -7
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +42 -0
- data/lib/tuile/screen.rb +210 -109
- data/lib/tuile/screen_pane.rb +56 -44
- data/lib/tuile/styled_string.rb +112 -83
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2291 -890
- metadata +28 -9
- data/ideas/back-buffer.md +0 -217
- data/lib/tuile/sizing.rb +0 -59
data/examples/sampler.rb
CHANGED
|
@@ -32,6 +32,30 @@ module SamplerExample
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# A {Panel} that runs {#on_tick} on every frame while it is on screen. The
|
|
36
|
+
# ticker is started on attach and cancelled on detach, so selecting another
|
|
37
|
+
# demo — which detaches this pane — cannot leave one firing at the old pane
|
|
38
|
+
# forever. Owning a mounted-lifetime resource this way is the whole point of
|
|
39
|
+
# the attach hooks.
|
|
40
|
+
class TickingPanel < Panel
|
|
41
|
+
def initialize(fps, &layout_block)
|
|
42
|
+
super(&layout_block)
|
|
43
|
+
@fps = fps
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Proc, nil] called with no arguments on each frame.
|
|
47
|
+
attr_writer :on_tick
|
|
48
|
+
|
|
49
|
+
def on_attached
|
|
50
|
+
@ticker = screen.event_queue.tick_fps(@fps) { @on_tick&.call }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def on_detached
|
|
54
|
+
@ticker&.cancel
|
|
55
|
+
@ticker = nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
35
59
|
# Top-level sampler component. Splits the screen into a left entry list
|
|
36
60
|
# and a right demo pane; each `load_entry` rebuilds the demo from
|
|
37
61
|
# scratch so it always starts in a clean state.
|
|
@@ -67,10 +91,18 @@ module SamplerExample
|
|
|
67
91
|
["Label", :build_label],
|
|
68
92
|
["TextField", :build_text_field],
|
|
69
93
|
["TextArea", :build_text_area],
|
|
94
|
+
["ComboBox", :build_combo_box],
|
|
95
|
+
["IntegerField", :build_integer_field],
|
|
96
|
+
["PasswordField", :build_password_field],
|
|
70
97
|
["Slash menu", :build_slash_demo],
|
|
71
98
|
["TextView", :build_text_view],
|
|
72
99
|
["Button", :build_buttons],
|
|
100
|
+
["Checkbox", :build_checkboxes],
|
|
101
|
+
["CheckboxGroup", :build_checkbox_group],
|
|
102
|
+
["RadioGroup", :build_radio_group],
|
|
73
103
|
["List", :build_list],
|
|
104
|
+
["ProgressBar", :build_progress_bar],
|
|
105
|
+
["Background", :build_background],
|
|
74
106
|
["Layout", :build_layout],
|
|
75
107
|
["Popup", :build_popup_launcher],
|
|
76
108
|
["InfoWindow", :build_info_launcher],
|
|
@@ -141,6 +173,73 @@ module SamplerExample
|
|
|
141
173
|
end
|
|
142
174
|
end
|
|
143
175
|
|
|
176
|
+
# ComboBox: its value is the selected item, not the typed text — the status
|
|
177
|
+
# line echoes it as it commits. The dropdown closes itself on blur, so no
|
|
178
|
+
# overlay bookkeeping is needed here (unlike the slash demo below).
|
|
179
|
+
def build_combo_box
|
|
180
|
+
prompt = Tuile::Component::Label.new
|
|
181
|
+
prompt.text = "Tab here, then type to filter. ↑↓ move the highlight, Enter accepts, ESC dismisses.\n" \
|
|
182
|
+
"The dropdown floats above or below the field and tints itself apart from the content."
|
|
183
|
+
items = %w[Ruby Python JavaScript TypeScript Rust Go Elixir Crystal Haskell Kotlin Swift Zig]
|
|
184
|
+
combo = Tuile::Component::ComboBox.new(items: items)
|
|
185
|
+
status = Tuile::Component::Label.new.tap { _1.text = "(nothing selected)" }
|
|
186
|
+
combo.on_value_change = ->(value) { status.text = "Selected: #{value}" }
|
|
187
|
+
panel(prompt, combo, status) do |r|
|
|
188
|
+
inner = inner_rect(r)
|
|
189
|
+
prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
|
|
190
|
+
combo.rect = Tuile::Rect.new(inner.left, inner.top + 5, [inner.width, 30].min, 1)
|
|
191
|
+
status.rect = Tuile::Rect.new(inner.left, inner.top + 7, inner.width, 1)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# IntegerField: its value is a typed Integer (or nil), parsed from the
|
|
196
|
+
# digits you type — the status line echoes it. Up/Down step it like a
|
|
197
|
+
# spinner. Only the value seam shows on its face; the digit filtering and
|
|
198
|
+
# parsing are internal.
|
|
199
|
+
def build_integer_field
|
|
200
|
+
prompt = Tuile::Component::Label.new
|
|
201
|
+
prompt.text = "Tab here, then type digits (and a leading -). Non-digits are ignored.\n" \
|
|
202
|
+
"Up/Down step the value by one; an empty field counts as 0."
|
|
203
|
+
field = Tuile::Component::IntegerField.new
|
|
204
|
+
status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
|
|
205
|
+
field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
|
|
206
|
+
panel(prompt, field, status) do |r|
|
|
207
|
+
inner = inner_rect(r)
|
|
208
|
+
prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
|
|
209
|
+
field.rect = Tuile::Rect.new(inner.left, inner.top + 4, [inner.width, 20].min, 1)
|
|
210
|
+
status.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# PasswordField: a TextField that paints a mask instead of its text. The
|
|
215
|
+
# plaintext stays in `value` throughout — the status line proves it by
|
|
216
|
+
# reporting the length — and the "Show password" box flips `revealed`,
|
|
217
|
+
# since a TTY field has no room for an in-field reveal button.
|
|
218
|
+
def build_password_field
|
|
219
|
+
prompt = Tuile::Component::Label.new
|
|
220
|
+
prompt.text = "Tab through the two fields and type. The password paints one * per character,\n" \
|
|
221
|
+
"whatever you type — try a CJK passphrase: the caret still tracks the mask.\n" \
|
|
222
|
+
"Ctrl+Left/Right jump to the ends while masked, so the caret can't give away\n" \
|
|
223
|
+
"where the spaces are; they resume word jumping once revealed."
|
|
224
|
+
user = Tuile::Component::TextField.new
|
|
225
|
+
password = Tuile::Component::PasswordField.new
|
|
226
|
+
reveal = Tuile::Component::Checkbox.new("Show password")
|
|
227
|
+
reveal.on_value_change = ->(on) { password.revealed = on }
|
|
228
|
+
status = Tuile::Component::Label.new
|
|
229
|
+
refresh = -> { status.text = "user: #{user.text.inspect} password: #{password.value.length} chars" }
|
|
230
|
+
refresh.call
|
|
231
|
+
[user, password].each { _1.on_change = ->(_) { refresh.call } }
|
|
232
|
+
panel(prompt, user, password, reveal, status) do |r|
|
|
233
|
+
inner = inner_rect(r)
|
|
234
|
+
width = [inner.width, 30].min
|
|
235
|
+
prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 4)
|
|
236
|
+
user.rect = Tuile::Rect.new(inner.left, inner.top + 6, width, 1)
|
|
237
|
+
password.rect = Tuile::Rect.new(inner.left, inner.top + 8, width, 1)
|
|
238
|
+
reveal.rect = Tuile::Rect.new(inner.left, inner.top + 10, inner.width, 1)
|
|
239
|
+
status.rect = Tuile::Rect.new(inner.left, inner.top + 12, inner.width, 1)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
144
243
|
# Slash commands the demo offers; the menu filters these by what's typed.
|
|
145
244
|
SLASH_COMMANDS = %w[/help /list /open /save /clear /quit].freeze
|
|
146
245
|
|
|
@@ -249,13 +348,211 @@ module SamplerExample
|
|
|
249
348
|
panel(label, ok, cancel, result) do |r|
|
|
250
349
|
inner = inner_rect(r)
|
|
251
350
|
label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
|
|
252
|
-
ok.rect = Tuile::Rect.new(inner.left, inner.top + 4, ok
|
|
253
|
-
cancel.rect = Tuile::Rect.new(inner.left + ok
|
|
254
|
-
cancel
|
|
351
|
+
ok.rect = Tuile::Rect.new(inner.left, inner.top + 4, button_width(ok), 1)
|
|
352
|
+
cancel.rect = Tuile::Rect.new(inner.left + button_width(ok) + 2, inner.top + 4,
|
|
353
|
+
button_width(cancel), 1)
|
|
255
354
|
result.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
|
|
256
355
|
end
|
|
257
356
|
end
|
|
258
357
|
|
|
358
|
+
CHECKBOX_OPTIONS = ["Enable syslog forwarding", "Rotate logs daily", "Compress archives",
|
|
359
|
+
"Email on failure", "Verbose output"].freeze
|
|
360
|
+
|
|
361
|
+
# Checkbox: Space (or a click on the label) toggles. Each box is handed the
|
|
362
|
+
# full column width, which is what makes the extent visible — the highlight
|
|
363
|
+
# and the click target stop at the end of the caption, not at the rect's.
|
|
364
|
+
def build_checkboxes
|
|
365
|
+
prompt = Tuile::Component::Label.new
|
|
366
|
+
prompt.text = "Tab here, then Space to toggle; a left-click on a label toggles too.\n" \
|
|
367
|
+
"Each box spans the whole column, but only the caption highlights —\n" \
|
|
368
|
+
"clicking the empty space to its right just moves focus."
|
|
369
|
+
status = Tuile::Component::Label.new
|
|
370
|
+
boxes = CHECKBOX_OPTIONS.map { Tuile::Component::Checkbox.new(_1) }
|
|
371
|
+
refresh = lambda do
|
|
372
|
+
on = boxes.select(&:checked?).map { _1.caption.to_s }
|
|
373
|
+
status.text = "checked: #{on.empty? ? "(none)" : on.join(", ")}"
|
|
374
|
+
end
|
|
375
|
+
refresh.call
|
|
376
|
+
boxes.each { _1.on_value_change = ->(_) { refresh.call } }
|
|
377
|
+
panel(prompt, *boxes, status) do |r|
|
|
378
|
+
inner = inner_rect(r)
|
|
379
|
+
prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
|
|
380
|
+
boxes.each_with_index do |box, i|
|
|
381
|
+
box.rect = Tuile::Rect.new(inner.left, inner.top + 5 + i, inner.width, 1)
|
|
382
|
+
end
|
|
383
|
+
status.rect = Tuile::Rect.new(inner.left, inner.top + 6 + boxes.size, inner.width, 1)
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# One filterable log level: the item type a CheckboxGroup holds. Its `value`
|
|
388
|
+
# is a Set of *these*, never of the labels shown on the rows.
|
|
389
|
+
LogLevel = Data.define(:label, :tag, :color)
|
|
390
|
+
|
|
391
|
+
LOG_LEVELS = [
|
|
392
|
+
LogLevel.new("Debug", "DEBUG", :cyan),
|
|
393
|
+
LogLevel.new("Info", "INFO", :green),
|
|
394
|
+
LogLevel.new("Warnings", "WARN", :yellow),
|
|
395
|
+
LogLevel.new("Errors", "ERROR", :red)
|
|
396
|
+
].freeze
|
|
397
|
+
|
|
398
|
+
# `[tag, message]` pairs; the tag names the LogLevel that owns the line.
|
|
399
|
+
SAMPLE_LOG = [
|
|
400
|
+
["DEBUG", "config loaded from /etc/tuile.conf"],
|
|
401
|
+
["INFO", "listening on 0.0.0.0:8080"],
|
|
402
|
+
["DEBUG", "cache warm: 128 entries"],
|
|
403
|
+
["WARN", "TLS certificate expires in 6 days"],
|
|
404
|
+
["INFO", "GET /health 200 (1.2ms)"],
|
|
405
|
+
["DEBUG", "pool checkout: 3/16 busy"],
|
|
406
|
+
["ERROR", "upstream timeout after 5000ms"],
|
|
407
|
+
["INFO", "GET /index 200 (18ms)"],
|
|
408
|
+
["WARN", "slow query: 1.8s SELECT * FROM tiles"],
|
|
409
|
+
["DEBUG", "gc pause 4ms"],
|
|
410
|
+
["ERROR", "connection reset by peer (retrying)"],
|
|
411
|
+
["INFO", "POST /tiles 201 (32ms)"],
|
|
412
|
+
["DEBUG", "pool checkout: 11/16 busy"],
|
|
413
|
+
["WARN", "queue depth 240, above the 200 mark"],
|
|
414
|
+
["INFO", "GET /tiles/42 200 (7ms)"],
|
|
415
|
+
["ERROR", "failed to write /var/log/tuile.log: no space left"],
|
|
416
|
+
["DEBUG", "flush wrote 96 cells"],
|
|
417
|
+
["INFO", "shutdown signal received"]
|
|
418
|
+
].freeze
|
|
419
|
+
|
|
420
|
+
# CheckboxGroup filtering an adjacent log. Its value is a Set of the selected
|
|
421
|
+
# *items* — LogLevel objects, not their labels — so the filter below is plain
|
|
422
|
+
# set membership, no lookup table. Rows come from `item_label`, which may
|
|
423
|
+
# return styled text (these colors are inherent to the data, not theme
|
|
424
|
+
# accents, so they need no on_theme_changed hook).
|
|
425
|
+
def build_checkbox_group
|
|
426
|
+
prompt = Tuile::Component::Label.new
|
|
427
|
+
# Kept under 48 columns a line, so an 80-column terminal shows it whole.
|
|
428
|
+
prompt.text = "Tab here. ↑↓ moves the cursor, Space toggles.\n" \
|
|
429
|
+
"Enter or a click anywhere on a row toggles too —\n" \
|
|
430
|
+
"in a list, the whole row is the target.\n" \
|
|
431
|
+
"The log redraws from the value on every toggle."
|
|
432
|
+
levels_by_tag = LOG_LEVELS.to_h { [_1.tag, _1] }
|
|
433
|
+
entries = SAMPLE_LOG.map { |tag, message| [levels_by_tag.fetch(tag), message] }
|
|
434
|
+
|
|
435
|
+
group = Tuile::Component::CheckboxGroup.new(items: LOG_LEVELS, value: LOG_LEVELS.last(2))
|
|
436
|
+
group.item_label = ->(level) { Rainbow(level.label).color(level.color) }
|
|
437
|
+
|
|
438
|
+
log = Tuile::Component::List.new
|
|
439
|
+
log.cursor = Tuile::Component::List::Cursor.new
|
|
440
|
+
log.scrollbar_visibility = :visible
|
|
441
|
+
status = Tuile::Component::Label.new
|
|
442
|
+
|
|
443
|
+
refresh = lambda do
|
|
444
|
+
selected = group.value
|
|
445
|
+
log.lines = entries.select { |level, _| selected.include?(level) }
|
|
446
|
+
.map { |level, message| "#{Rainbow(level.tag.ljust(5)).color(level.color)} #{message}" }
|
|
447
|
+
# The Set iterates in *toggle* order, so intersect with items to report
|
|
448
|
+
# it in the order the rows are shown — the documented idiom.
|
|
449
|
+
shown = (LOG_LEVELS & selected.to_a).map(&:label)
|
|
450
|
+
status.text = "value: {#{shown.join(", ")}} — #{log.lines.size} of #{entries.size} lines"
|
|
451
|
+
end
|
|
452
|
+
refresh.call
|
|
453
|
+
group.on_value_change = ->(_set) { refresh.call }
|
|
454
|
+
|
|
455
|
+
panel(prompt, group, log, status) do |r|
|
|
456
|
+
inner = inner_rect(r)
|
|
457
|
+
prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 4)
|
|
458
|
+
# Status above the body, so it stays next to the group however tall the
|
|
459
|
+
# pane gets; the log takes whatever height is left.
|
|
460
|
+
status.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
|
|
461
|
+
top = inner.top + 8
|
|
462
|
+
body_height = [inner.height - 9, LOG_LEVELS.size].max
|
|
463
|
+
group_width = [16, inner.width / 3].min
|
|
464
|
+
group.rect = Tuile::Rect.new(inner.left, top, group_width, LOG_LEVELS.size)
|
|
465
|
+
log.rect = Tuile::Rect.new(inner.left + group_width + 2, top,
|
|
466
|
+
[inner.width - group_width - 2, 4].max, body_height)
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# One sort order: the item type a RadioGroup holds. Its `value` is one of
|
|
471
|
+
# *these*, and the chosen object carries the behavior — so re-sorting is
|
|
472
|
+
# `value.sorter.call(files)`, never a lookup from a label back to a
|
|
473
|
+
# comparator.
|
|
474
|
+
SortOrder = Data.define(:label, :sorter)
|
|
475
|
+
|
|
476
|
+
SORT_ORDERS = [
|
|
477
|
+
SortOrder.new("Name A-Z", ->(files) { files.sort_by(&:name) }),
|
|
478
|
+
SortOrder.new("Name Z-A", ->(files) { files.sort_by(&:name).reverse }),
|
|
479
|
+
SortOrder.new("Biggest", ->(files) { files.sort_by { -_1.size } }),
|
|
480
|
+
SortOrder.new("Newest", ->(files) { files.sort_by(&:date).reverse })
|
|
481
|
+
].freeze
|
|
482
|
+
|
|
483
|
+
SampleFile = Data.define(:name, :size, :date)
|
|
484
|
+
|
|
485
|
+
# Names stay under 14 columns and sizes round to distinct k values, so the
|
|
486
|
+
# rows fit the pane at 80 columns and every sort order reorders visibly.
|
|
487
|
+
SAMPLE_FILES = [
|
|
488
|
+
SampleFile.new("AGENTS.md", 31_402, "2026-07-30"),
|
|
489
|
+
SampleFile.new("CHANGELOG.md", 4118, "2026-07-05"),
|
|
490
|
+
SampleFile.new("DECISIONS.md", 48_990, "2026-07-31"),
|
|
491
|
+
SampleFile.new("Gemfile", 312, "2026-06-18"),
|
|
492
|
+
SampleFile.new("README.md", 9674, "2026-07-12"),
|
|
493
|
+
SampleFile.new("Rakefile", 2118, "2026-06-18"),
|
|
494
|
+
SampleFile.new("list.rb", 21_006, "2026-07-24"),
|
|
495
|
+
SampleFile.new("sampler.rb", 22_180, "2026-07-31"),
|
|
496
|
+
SampleFile.new("screen.rb", 18_442, "2026-07-28"),
|
|
497
|
+
SampleFile.new("text_view.rb", 14_338, "2026-07-19"),
|
|
498
|
+
SampleFile.new("theme.rb", 6512, "2026-07-23"),
|
|
499
|
+
SampleFile.new("tuile.gemspec", 1284, "2026-06-20")
|
|
500
|
+
].freeze
|
|
501
|
+
|
|
502
|
+
# RadioGroup driving an adjacent file list. Two things worth watching: the
|
|
503
|
+
# value is the selected *item* (a SortOrder carrying its own comparator),
|
|
504
|
+
# and the cursor is chrome — arrows move it without touching the value, so
|
|
505
|
+
# the status line's two halves drift apart until you press Space.
|
|
506
|
+
def build_radio_group
|
|
507
|
+
prompt = Tuile::Component::Label.new
|
|
508
|
+
# Kept under 48 columns a line, so an 80-column terminal shows it whole.
|
|
509
|
+
prompt.text = "Tab here. ↑↓ move the cursor only.\n" \
|
|
510
|
+
"Space, Enter or a click selects — and only\n" \
|
|
511
|
+
"then does the list re-sort. Picking another\n" \
|
|
512
|
+
"clears the previous; there is no deselect."
|
|
513
|
+
|
|
514
|
+
group = Tuile::Component::RadioGroup.new(items: SORT_ORDERS, value: SORT_ORDERS.first)
|
|
515
|
+
group.item_label = :label.to_proc
|
|
516
|
+
|
|
517
|
+
files = Tuile::Component::List.new
|
|
518
|
+
files.cursor = Tuile::Component::List::Cursor.new
|
|
519
|
+
files.scrollbar_visibility = :visible
|
|
520
|
+
status = Tuile::Component::Label.new
|
|
521
|
+
short_size = ->(bytes) { bytes < 1024 ? bytes.to_s : "#{(bytes / 1024.0).round}k" }
|
|
522
|
+
|
|
523
|
+
update_status = lambda do
|
|
524
|
+
under_cursor = SORT_ORDERS[group.content.cursor.position]
|
|
525
|
+
status.text = "value: #{group.value.label} — cursor: #{under_cursor&.label}"
|
|
526
|
+
end
|
|
527
|
+
resort = lambda do
|
|
528
|
+
files.lines = group.value.sorter.call(SAMPLE_FILES).map do |file|
|
|
529
|
+
"#{file.name.ljust(13)} #{short_size.call(file.size).rjust(4)} #{file.date}"
|
|
530
|
+
end
|
|
531
|
+
update_status.call
|
|
532
|
+
end
|
|
533
|
+
resort.call
|
|
534
|
+
group.on_value_change = ->(_order) { resort.call }
|
|
535
|
+
# `content` is the composed List, which is where the cursor lives.
|
|
536
|
+
# Watching it is what makes the chrome/value split visible above.
|
|
537
|
+
group.content.on_cursor_changed = ->(_idx, _line) { update_status.call }
|
|
538
|
+
|
|
539
|
+
panel(prompt, group, files, status) do |r|
|
|
540
|
+
inner = inner_rect(r)
|
|
541
|
+
prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 4)
|
|
542
|
+
# Status above the body, so it stays next to the group however tall the
|
|
543
|
+
# pane gets; the file list takes whatever height is left.
|
|
544
|
+
status.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
|
|
545
|
+
top = inner.top + 8
|
|
546
|
+
body_height = [inner.height - 9, SORT_ORDERS.size].max
|
|
547
|
+
# List pads a column either side of a row, so a label needs
|
|
548
|
+
# `width - 2`; the file rows lose one more to their scrollbar.
|
|
549
|
+
group_width = [14, inner.width / 3].min
|
|
550
|
+
group.rect = Tuile::Rect.new(inner.left, top, group_width, SORT_ORDERS.size)
|
|
551
|
+
files.rect = Tuile::Rect.new(inner.left + group_width + 2, top,
|
|
552
|
+
[inner.width - group_width - 2, 4].max, body_height)
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
259
556
|
def build_list
|
|
260
557
|
list = Tuile::Component::List.new
|
|
261
558
|
list.cursor = Tuile::Component::List::Cursor.new
|
|
@@ -264,6 +561,119 @@ module SamplerExample
|
|
|
264
561
|
list
|
|
265
562
|
end
|
|
266
563
|
|
|
564
|
+
# Files the fake job in the ProgressBar demo pretends to process.
|
|
565
|
+
PROGRESS_TOTAL = 50
|
|
566
|
+
|
|
567
|
+
# Frames per second of the demo's fake job — its own pace, unrelated to
|
|
568
|
+
# {Tuile::Component::ProgressBar::INDETERMINATE_FPS}, which paces only the
|
|
569
|
+
# animation the bar runs for itself.
|
|
570
|
+
PROGRESS_FPS = 8
|
|
571
|
+
|
|
572
|
+
# Two bars: a determinate one whose value a pane-owned ticker walks up and
|
|
573
|
+
# wraps around, and an indeterminate one that animates itself. Neither
|
|
574
|
+
# paints text — the lines beneath them are sibling Labels fed from
|
|
575
|
+
# {Tuile::Component::ProgressBar#percent}, which is what lets the app word
|
|
576
|
+
# the progress ("42% — 21/50 files") instead of taking whatever the widget
|
|
577
|
+
# would have formatted.
|
|
578
|
+
def build_progress_bar
|
|
579
|
+
prompt = Tuile::Component::Label.new
|
|
580
|
+
prompt.text = "A ProgressBar paints no text of its own —\n" \
|
|
581
|
+
"the line below it is a sibling Label fed\n" \
|
|
582
|
+
"from bar.percent. A ticker owned by this\n" \
|
|
583
|
+
"pane advances the value while it's on screen."
|
|
584
|
+
|
|
585
|
+
bar = Tuile::Component::ProgressBar.new(range: 0..PROGRESS_TOTAL)
|
|
586
|
+
bar.bar_color = Tuile::Color::GREEN
|
|
587
|
+
status = Tuile::Component::Label.new
|
|
588
|
+
|
|
589
|
+
spinner = Tuile::Component::ProgressBar.new(indeterminate: true)
|
|
590
|
+
spinner_caption = Tuile::Component::Label.new
|
|
591
|
+
spinner_caption.text = "Indeterminate: no total yet, so the bar owns\n" \
|
|
592
|
+
"its own animation — no ticker in the app."
|
|
593
|
+
|
|
594
|
+
done = 0
|
|
595
|
+
refresh = -> { status.text = "#{bar.percent}% — #{done}/#{PROGRESS_TOTAL} files" }
|
|
596
|
+
refresh.call
|
|
597
|
+
|
|
598
|
+
pane = TickingPanel.new(PROGRESS_FPS) do |r|
|
|
599
|
+
inner = inner_rect(r)
|
|
600
|
+
prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 4)
|
|
601
|
+
bar.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
|
|
602
|
+
status.rect = Tuile::Rect.new(inner.left, inner.top + 7, inner.width, 1)
|
|
603
|
+
spinner.rect = Tuile::Rect.new(inner.left, inner.top + 9, inner.width, 1)
|
|
604
|
+
spinner_caption.rect = Tuile::Rect.new(inner.left, inner.top + 10, inner.width, 2)
|
|
605
|
+
end
|
|
606
|
+
pane.add([prompt, bar, status, spinner, spinner_caption])
|
|
607
|
+
pane.on_tick = lambda do
|
|
608
|
+
done = done < PROGRESS_TOTAL ? done + 1 : 0
|
|
609
|
+
bar.value = done
|
|
610
|
+
refresh.call
|
|
611
|
+
end
|
|
612
|
+
pane
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# One background the Background demo offers: a display label and the value
|
|
616
|
+
# handed to {Component#bg_color=} — a live {Tuile::Theme::Ref}, a hard-coded
|
|
617
|
+
# {Tuile::Color}, or nil (terminal default).
|
|
618
|
+
BgChoice = Data.define(:label, :color)
|
|
619
|
+
|
|
620
|
+
# The palette the Background combo filters over: theme refs first (they
|
|
621
|
+
# re-resolve on a light/dark flip, so they track the scheme), then a spread
|
|
622
|
+
# of hard-coded ANSI / 256-palette / RGB colors that stay put across flips.
|
|
623
|
+
BG_CHOICES = [
|
|
624
|
+
BgChoice.new("None (terminal default)", nil),
|
|
625
|
+
BgChoice.new("Theme: input well", Tuile::Theme.ref(:input_bg_color)),
|
|
626
|
+
BgChoice.new("Theme: active", Tuile::Theme.ref(:active_bg_color)),
|
|
627
|
+
BgChoice.new("Theme: active border", Tuile::Theme.ref(:active_border_color)),
|
|
628
|
+
BgChoice.new("ANSI blue", Tuile::Color::BLUE),
|
|
629
|
+
BgChoice.new("ANSI magenta", Tuile::Color::MAGENTA),
|
|
630
|
+
BgChoice.new("ANSI bright black", Tuile::Color::BRIGHT_BLACK),
|
|
631
|
+
BgChoice.new("Palette 236 (charcoal)", Tuile::Color.palette(236)),
|
|
632
|
+
BgChoice.new("Palette 22 (deep green)", Tuile::Color.palette(22)),
|
|
633
|
+
BgChoice.new("Deep purple (RGB)", Tuile::Color.rgb(48, 25, 82)),
|
|
634
|
+
BgChoice.new("Midnight teal (RGB)", Tuile::Color.rgb(10, 40, 45)),
|
|
635
|
+
BgChoice.new("Hot pink (RGB)", Tuile::Color.rgb(120, 20, 70))
|
|
636
|
+
].freeze
|
|
637
|
+
|
|
638
|
+
def build_background
|
|
639
|
+
intro = Tuile::Component::Label.new
|
|
640
|
+
intro.text = "bg_color tints a component and every descendant that doesn't set its own.\n" \
|
|
641
|
+
"Pick one below — this label and the list inherit it; input widgets keep their own well.\n" \
|
|
642
|
+
"Theme refs track light/dark flips; hard-coded colors stay put."
|
|
643
|
+
|
|
644
|
+
list = Tuile::Component::List.new
|
|
645
|
+
list.cursor = Tuile::Component::List::Cursor.new
|
|
646
|
+
list.lines = (1..12).map { |i| "List row #{i}" }
|
|
647
|
+
field = Tuile::Component::TextField.new
|
|
648
|
+
field.text = "TextField keeps its own background"
|
|
649
|
+
|
|
650
|
+
# A borderless sub-box holding the list + field; it inherits the tint too.
|
|
651
|
+
box = panel(list, field) do |r|
|
|
652
|
+
list_h = [r.height - 2, 1].max
|
|
653
|
+
list.rect = Tuile::Rect.new(r.left, r.top, r.width, list_h)
|
|
654
|
+
field.rect = Tuile::Rect.new(r.left, r.top + list_h + 1, r.width, 1)
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# A ComboBox over BG_CHOICES swaps the whole panel's bg_color on commit, so
|
|
658
|
+
# the tint flows down to every descendant without its own background — the
|
|
659
|
+
# label and the list — while the input widgets (the combo, the field) keep
|
|
660
|
+
# their own well. Theme::Ref picks re-resolve on a scheme flip with no hook;
|
|
661
|
+
# the hard-coded Colors are fixed by design, so no on_theme_changed here.
|
|
662
|
+
outer = nil
|
|
663
|
+
combo = Tuile::Component::ComboBox.new(items: BG_CHOICES)
|
|
664
|
+
combo.item_label = :label.to_proc
|
|
665
|
+
combo.on_value_change = ->(choice) { outer.bg_color = choice.color }
|
|
666
|
+
|
|
667
|
+
outer = panel(intro, combo, box) do |r|
|
|
668
|
+
inner = inner_rect(r)
|
|
669
|
+
intro.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
|
|
670
|
+
combo.rect = Tuile::Rect.new(inner.left, inner.top + 5, [inner.width, 40].min, 1)
|
|
671
|
+
box.rect = Tuile::Rect.new(inner.left, inner.top + 7, inner.width, [inner.height - 8, 2].max)
|
|
672
|
+
end
|
|
673
|
+
combo.value = BG_CHOICES.first # show "None" as the resting selection
|
|
674
|
+
outer
|
|
675
|
+
end
|
|
676
|
+
|
|
267
677
|
def build_layout
|
|
268
678
|
left = Tuile::Component::Window.new("Left")
|
|
269
679
|
left.content = Tuile::Component::Label.new.tap { _1.text = "Nested left window." }
|
|
@@ -319,11 +729,9 @@ module SamplerExample
|
|
|
319
729
|
|
|
320
730
|
def build_log_window
|
|
321
731
|
log = Tuile::Component::LogWindow.new("Log")
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
"Used with Logger::IO it captures arbitrary log output."
|
|
326
|
-
])
|
|
732
|
+
["LogWindow is a Window wrapping an auto-scrolling TextView.",
|
|
733
|
+
"Lines are appended via #log (safe from any thread).",
|
|
734
|
+
"Used with Logger::IO it captures arbitrary log output."].each { |line| log.log(line) }
|
|
327
735
|
log
|
|
328
736
|
end
|
|
329
737
|
|
|
@@ -339,9 +747,9 @@ module SamplerExample
|
|
|
339
747
|
panel(label, a, b, field) do |r|
|
|
340
748
|
inner = inner_rect(r)
|
|
341
749
|
label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
|
|
342
|
-
a.rect = Tuile::Rect.new(inner.left, inner.top + 4, a
|
|
343
|
-
b.rect = Tuile::Rect.new(inner.left + a
|
|
344
|
-
b
|
|
750
|
+
a.rect = Tuile::Rect.new(inner.left, inner.top + 4, button_width(a), 1)
|
|
751
|
+
b.rect = Tuile::Rect.new(inner.left + button_width(a) + 2, inner.top + 4,
|
|
752
|
+
button_width(b), 1)
|
|
345
753
|
field.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
|
|
346
754
|
end
|
|
347
755
|
end
|
|
@@ -361,7 +769,7 @@ module SamplerExample
|
|
|
361
769
|
panel(label, button) do |r|
|
|
362
770
|
inner = inner_rect(r)
|
|
363
771
|
label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
|
|
364
|
-
button.rect = Tuile::Rect.new(inner.left, inner.top + 5, button
|
|
772
|
+
button.rect = Tuile::Rect.new(inner.left, inner.top + 5, button_width(button), 1)
|
|
365
773
|
end
|
|
366
774
|
end
|
|
367
775
|
|
|
@@ -410,6 +818,9 @@ module SamplerExample
|
|
|
410
818
|
overlay.rect = Tuile::Rect.new(left, top, size.width, size.height)
|
|
411
819
|
end
|
|
412
820
|
|
|
821
|
+
# A button's natural width — enough to show "[ caption ]".
|
|
822
|
+
def button_width(button) = button.caption.display_width + 4
|
|
823
|
+
|
|
413
824
|
# Carves a 2-column padding out of the panel rect so the demo content
|
|
414
825
|
# doesn't run flush to the window border.
|
|
415
826
|
def inner_rect(rect)
|
|
@@ -419,12 +830,16 @@ module SamplerExample
|
|
|
419
830
|
end
|
|
420
831
|
end
|
|
421
832
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
screen.
|
|
428
|
-
|
|
429
|
-
|
|
833
|
+
# Guard the runner so specs can `require` this file to unit-test the Sampler
|
|
834
|
+
# component tree without spinning up the real event loop.
|
|
835
|
+
if $PROGRAM_NAME == __FILE__
|
|
836
|
+
screen = Tuile::Screen.new
|
|
837
|
+
sampler = SamplerExample::Sampler.new
|
|
838
|
+
screen.content = sampler
|
|
839
|
+
sampler.entry_list.focus
|
|
840
|
+
begin
|
|
841
|
+
screen.run_event_loop
|
|
842
|
+
ensure
|
|
843
|
+
screen.close
|
|
844
|
+
end
|
|
430
845
|
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Components Vaadin has and Tuile doesn't — the survey
|
|
2
|
+
|
|
3
|
+
**Status:** survey done 2026-07-25 against Vaadin **25.2** (54 free/OSS
|
|
4
|
+
components, via the Vaadin docs MCP). This file is the roadmap; each
|
|
5
|
+
component we actually decide to build gets its own `ideas/<name>.md`.
|
|
6
|
+
Retire this file once the interesting part of the list is either built or
|
|
7
|
+
explicitly rejected — the tiering below is the only nugget worth keeping,
|
|
8
|
+
and it belongs here, not in a durable doc, because it goes stale as we
|
|
9
|
+
build.
|
|
10
|
+
|
|
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
|
|
16
|
+
"Editing text"), both built 2026-08-02.
|
|
17
|
+
|
|
18
|
+
## What Tuile already has
|
|
19
|
+
|
|
20
|
+
Seven of the 54 have a counterpart: Button, Text Field, Text Area,
|
|
21
|
+
Integer Field, Combo Box (1:1), Dialog ({Tuile::Component::Popup} plus
|
|
22
|
+
`Window`/`InfoWindow`/`PickerWindow`), Themable Mixin
|
|
23
|
+
({Tuile::Theme}/{Tuile::ThemeDef}). **List Box** is half-there:
|
|
24
|
+
{Tuile::Component::List} is line-based, with no typed items and no
|
|
25
|
+
multi-select.
|
|
26
|
+
|
|
27
|
+
Tuile also has pieces Vaadin doesn't name as components — `ListDropdown`,
|
|
28
|
+
`TextView`, `LogWindow`, `VerticalScrollBar` — so the gap is not
|
|
29
|
+
symmetric.
|
|
30
|
+
|
|
31
|
+
That leaves ~46 gaps.
|
|
32
|
+
|
|
33
|
+
## Tier 1 — reachable from what exists (S–M each)
|
|
34
|
+
|
|
35
|
+
| Component | Builds on | Note |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| Box layouts (H/V) | `Layout` | Tuile has only `Layout::Absolute`. Biggest structural win; unblocks half of this table |
|
|
38
|
+
| ~~Checkbox~~ | `HasValue` | **built** 2026-07-30 (`D-boolean-fields`); tri-state still deferred |
|
|
39
|
+
| ~~Radio Group~~ | `List` + `HasValue` | **built** 2026-07-31 (`D-radio-group`); composes a `List`, cursor roams and Space selects |
|
|
40
|
+
| ~~Checkbox Group~~ | `List` + `HasValue` | **built** 2026-07-30 (`D-checkbox-group`); composes a `List`, frozen `Set` value |
|
|
41
|
+
| Select | `ComboBox` − filter | ComboBox with a read-only field; near-free. Deferred once already in `D-combobox` (wants the parked read-only axis) |
|
|
42
|
+
| Password Field | `TextField` | masked repaint only |
|
|
43
|
+
| Number Field | `IntegerField` twin | same composed-field shape, `Float` |
|
|
44
|
+
| Progress Bar | `draw_line` + `EventQueue#tick_fps` | ticker for the indeterminate mode |
|
|
45
|
+
| Notification | `Popup` + `Ticker` | needs corner-anchored (non-centered) popup placement |
|
|
46
|
+
| Confirm Dialog | `Popup`+`Window`+`Button` | fold `PickerWindow` in |
|
|
47
|
+
| Details → Accordion | `HasContent` | Details is the atom, Accordion the group |
|
|
48
|
+
| Tabs → Tabsheet | `HasValue` (index) + `HasContent` | strip, then strip + content swap |
|
|
49
|
+
| Popover | extract `ComboBox#anchor` + `ListDropdown` geometry | generalize the anchored non-modal overlay; gates the next two |
|
|
50
|
+
| Menu Bar | `ListDropdown::Menu` + Popover | |
|
|
51
|
+
| Context Menu | same | `:right` button already parses |
|
|
52
|
+
| Slider | `draw_line` | arrows/PgUp; 25.2 also has a two-thumb *range* variant |
|
|
53
|
+
| Breadcrumbs | `Label`/`StyledString` | clickable path segments |
|
|
54
|
+
| Markdown | `TextView` + `StyledString` | Markdown subset → styled text; high value on a TTY |
|
|
55
|
+
|
|
56
|
+
## Tier 2 — worth doing, each blocked on a new seam
|
|
57
|
+
|
|
58
|
+
| Component | Blocked on |
|
|
59
|
+
|---|---|
|
|
60
|
+
| **Grid** (the flagship gap) | column model + renderer strategies + typed items + horizontal scroll (L) |
|
|
61
|
+
| Form Layout | a field label/helper seam (Vaadin's `HasLabel`) — Tuile fields carry no caption |
|
|
62
|
+
| Email Field | a validation seam (`HasValidation`: invalid state + error line) |
|
|
63
|
+
| Date / Time / DateTime Picker | calendar-grid popup over Popover (L) |
|
|
64
|
+
| Multi Select Combo Box | Checkbox Group + ComboBox |
|
|
65
|
+
| Split Layout → Master Detail Layout | mouse **motion/drag**: Tuile runs X10 mode 1000 (press only, no release, no motion) |
|
|
66
|
+
| Virtual List | a lazy data-provider strategy on `List` |
|
|
67
|
+
| Side Nav | hierarchical collapsible list (the sampler's nav is the prototype) |
|
|
68
|
+
| App Layout | shell: title bar + drawer + content slot |
|
|
69
|
+
| 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 |
|
|
70
|
+
| Message Input / Message List / Login | nothing — pure assemblies, good example fodder |
|
|
71
|
+
| Upload | reinterpret as a file-chooser dialog (`file_commander` has the ingredients) |
|
|
72
|
+
| Icon | a glyph / Nerd-Font constants module |
|
|
73
|
+
|
|
74
|
+
## Tier 3 — design tension or marginal
|
|
75
|
+
|
|
76
|
+
- **Scroller** — scrolling *arbitrary* content needs clipping/viewport
|
|
77
|
+
machinery and pushes against the top-down layout invariant (it wants to
|
|
78
|
+
measure content). Best kept as a documented road-not-taken.
|
|
79
|
+
- **Tooltip** — competes with Tuile's status-bar `keyboard_hint` idiom.
|
|
80
|
+
- **Card** — overlaps `Window` almost entirely.
|
|
81
|
+
- **Avatar / Avatar Group** — initials in a box; little value on a TTY.
|
|
82
|
+
- **Not applicable:** Field Highlighter (collaboration), Themable Mixin
|
|
83
|
+
(covered by `Theme`).
|
|
84
|
+
|
|
85
|
+
## Infrastructure that gates clusters
|
|
86
|
+
|
|
87
|
+
These are prerequisites, not components, and each deserves its own idea
|
|
88
|
+
file when its cluster comes up:
|
|
89
|
+
|
|
90
|
+
1. **Box layouts** (H/V) — everything form-shaped wants them.
|
|
91
|
+
2. **Field label + helper text seam** → Form Layout.
|
|
92
|
+
3. **Validation seam** → Email Field, forms generally.
|
|
93
|
+
4. **Anchored Popover extraction** → Menu Bar, Context Menu, pickers,
|
|
94
|
+
Tooltip.
|
|
95
|
+
5. **Mouse motion/drag** (modes 1002/1006, release events) → Split
|
|
96
|
+
divider, Slider drag, scrollbar drag.
|
|
97
|
+
6. **Typed items + data provider on `List`** → List Box, Grid, Virtual
|
|
98
|
+
List.
|
|
99
|
+
|
|
100
|
+
Vaadin's `Binder` is the natural companion for the forms cluster but is
|
|
101
|
+
not a component; `D-has-value` already parks the forms-layer questions
|
|
102
|
+
(converters, read-only, required indicator).
|
|
103
|
+
|
|
104
|
+
## ~~Cross-cutting open question: component color slots vs. theme tokens~~
|
|
105
|
+
|
|
106
|
+
**Settled 2026-08-01 as `DECISIONS.md` `D-color-slots`** — the slot, defaulting
|
|
107
|
+
to `nil`. Slider and Badge are bound by it when they land; Badge's promotion
|
|
108
|
+
trigger (a *second* built-in needing the same semantic color) is written up
|
|
109
|
+
there, so neither has to re-argue it.
|