tuile 0.13.0 → 0.15.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 +150 -37
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +4266 -226
- data/README.md +44 -24
- data/TERMINOLOGY.md +22 -7
- data/book/03-layout.md +17 -10
- data/book/05-focus.md +67 -3
- data/book/06-theming.md +153 -7
- data/book/07-components.md +643 -67
- data/book/08-testing.md +94 -0
- data/book/09-styled-text.md +3 -3
- data/book/10-locale.md +216 -0
- data/book/README.md +14 -5
- data/examples/file_commander.rb +1 -1
- data/examples/sampler.rb +402 -62
- data/ideas/arrow-key-navigation.md +2 -2
- data/ideas/binder.md +177 -0
- data/ideas/composite-field.md +77 -0
- data/ideas/focus-accent.md +116 -0
- data/ideas/form-layout.md +151 -0
- data/ideas/hover/probe.rb +241 -0
- data/ideas/hover/probe_spec.rb +82 -0
- data/ideas/hover.md +909 -0
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +49 -29
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/abstract_string_field.rb +106 -58
- data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/checkbox_group.rb +36 -20
- data/lib/tuile/component/combo_box.rb +68 -33
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/date_field.rb +322 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +43 -11
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +28 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +6 -38
- data/lib/tuile/component/layout/box.rb +87 -19
- data/lib/tuile/component/layout.rb +13 -13
- data/lib/tuile/component/list.rb +11 -6
- data/lib/tuile/component/list_dropdown.rb +22 -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 +209 -0
- data/lib/tuile/component/popup.rb +59 -187
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +39 -22
- data/lib/tuile/component/select.rb +26 -10
- 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/text_area.rb +14 -8
- data/lib/tuile/component/text_field.rb +42 -15
- data/lib/tuile/component/text_view.rb +25 -8
- data/lib/tuile/component/time_field.rb +454 -0
- data/lib/tuile/component/window.rb +48 -59
- data/lib/tuile/component.rb +580 -54
- data/lib/tuile/event_queue.rb +21 -1
- data/lib/tuile/fake_screen.rb +37 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/keys.rb +7 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/screen.rb +251 -55
- data/lib/tuile/screen_pane.rb +50 -44
- data/lib/tuile/styled_string.rb +40 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +100 -10
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +4545 -770
- metadata +25 -1
data/examples/sampler.rb
CHANGED
|
@@ -109,6 +109,37 @@ module SamplerExample
|
|
|
109
109
|
end
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
+
# A {Tuile::Component::TextArea} that steers a {Tuile::Component::ListDropdown}
|
|
113
|
+
# while it is open: movement keys move the highlight, ENTER accepts and ESC
|
|
114
|
+
# dismisses, and everything else — printables, editing, the ENTER that inserts
|
|
115
|
+
# a newline with no menu up — stays the TextArea's own.
|
|
116
|
+
#
|
|
117
|
+
# Subclassing *is* the seam for this. A component receives keys through
|
|
118
|
+
# `handle_key`, so one that wants different keys overrides it (here its
|
|
119
|
+
# `handle_text_input_key` hook) and calls `super` for the rest, which composes
|
|
120
|
+
# and stacks. None of this is baked into TextArea.
|
|
121
|
+
class SlashCommandTextArea < Tuile::Component::TextArea
|
|
122
|
+
# @param overlay [Tuile::Component::ListDropdown] the menu to steer.
|
|
123
|
+
def initialize(overlay)
|
|
124
|
+
super()
|
|
125
|
+
@overlay = overlay
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
protected
|
|
129
|
+
|
|
130
|
+
def handle_text_input_key(key)
|
|
131
|
+
return super unless @overlay.open?
|
|
132
|
+
return true if @overlay.move(key) # Up/Down/PgUp/PgDn/^U/^D
|
|
133
|
+
|
|
134
|
+
case key
|
|
135
|
+
when Tuile::Keys::ENTER then @overlay.choose
|
|
136
|
+
when Tuile::Keys::ESC then @overlay.close
|
|
137
|
+
else return super
|
|
138
|
+
end
|
|
139
|
+
true
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
112
143
|
# Top-level sampler component: a shell row across the top — a
|
|
113
144
|
# {Tuile::Component::MenuBar} of the demos, grouped, and a
|
|
114
145
|
# {Tuile::Component::ComboBox} jump box at its right end — over one demo
|
|
@@ -134,7 +165,7 @@ module SamplerExample
|
|
|
134
165
|
attr_reader :demo_window, :menu_bar, :jump_box
|
|
135
166
|
|
|
136
167
|
# The bottom row. Tuile draws no status bar and reserves no row
|
|
137
|
-
# (`
|
|
168
|
+
# (`D_status_bar`) — this one is the sampler's own, kept current by
|
|
138
169
|
# {Tuile::Screen#on_focus_changed=}. Naming the focused component makes Tab
|
|
139
170
|
# traversal visible as you walk a pane, which no per-pane label shows.
|
|
140
171
|
# @return [void]
|
|
@@ -199,7 +230,11 @@ module SamplerExample
|
|
|
199
230
|
Menu.new("Typed", "y", [
|
|
200
231
|
Entry.new("IntegerField", :build_integer_field, "i"),
|
|
201
232
|
Entry.new("FloatField", :build_float_field, "f"),
|
|
202
|
-
Entry.new("BigDecimalField", :build_big_decimal_field, "b")
|
|
233
|
+
Entry.new("BigDecimalField", :build_big_decimal_field, "b"),
|
|
234
|
+
Entry.new("DateField", :build_date_field, "d"),
|
|
235
|
+
Entry.new("TimeField", :build_time_field, "t"),
|
|
236
|
+
Entry.new("Bad input", :build_bad_input, "a"),
|
|
237
|
+
Entry.new("Validation", :build_validation, "v")
|
|
203
238
|
]),
|
|
204
239
|
Menu.new("Choose", "c", [
|
|
205
240
|
Entry.new("Checkbox", :build_checkboxes, "c"),
|
|
@@ -216,6 +251,7 @@ module SamplerExample
|
|
|
216
251
|
Menu.new("Overlay", "o", [
|
|
217
252
|
Entry.new("Popup", :build_popup_launcher, "p"),
|
|
218
253
|
Entry.new("Notification", :build_notification_launcher, "n"),
|
|
254
|
+
Entry.new("ConfirmWindow", :build_confirm_launcher, "c"),
|
|
219
255
|
Entry.new("InfoWindow", :build_info_launcher, "i"),
|
|
220
256
|
Entry.new("PickerWindow", :build_picker_launcher, "k"),
|
|
221
257
|
Entry.new("LogWindow", :build_log_window, "l")
|
|
@@ -226,6 +262,7 @@ module SamplerExample
|
|
|
226
262
|
Entry.new("Narrow strips", :build_narrow_strips, "n"),
|
|
227
263
|
Entry.new("Layout", :build_layout, "l"),
|
|
228
264
|
Entry.new("Background", :build_background, "b"),
|
|
265
|
+
Entry.new("Visibility", :build_visibility, "v"),
|
|
229
266
|
Entry.new("Focus & Tab", :build_focus_demo, "f")
|
|
230
267
|
])
|
|
231
268
|
].freeze
|
|
@@ -308,10 +345,13 @@ module SamplerExample
|
|
|
308
345
|
|
|
309
346
|
def build_text_field
|
|
310
347
|
prompt = Tuile::Component::Label.new
|
|
311
|
-
prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work
|
|
348
|
+
prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work.\n" \
|
|
349
|
+
"While it is empty it shows a placeholder — a hint in ink faint " \
|
|
350
|
+
"enough to miss, which is the point."
|
|
312
351
|
field = Tuile::Component::TextField.new
|
|
352
|
+
field.placeholder = "dd.mm.yyyy"
|
|
313
353
|
form do |f|
|
|
314
|
-
f.add(prompt, Fixed[
|
|
354
|
+
f.add(prompt, Fixed[2])
|
|
315
355
|
f.add(field, Fixed[1])
|
|
316
356
|
end
|
|
317
357
|
end
|
|
@@ -407,6 +447,8 @@ module SamplerExample
|
|
|
407
447
|
prompt.text = "Tab here, then type digits (and a leading -). Non-digits are ignored.\n" \
|
|
408
448
|
"Up/Down step the value by one; an empty field counts as 0."
|
|
409
449
|
field = Tuile::Component::IntegerField.new
|
|
450
|
+
# Set on the composed field, not on the TextField inside it.
|
|
451
|
+
field.placeholder = "1-65535"
|
|
410
452
|
status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
|
|
411
453
|
field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
|
|
412
454
|
form do |f|
|
|
@@ -453,6 +495,185 @@ module SamplerExample
|
|
|
453
495
|
end
|
|
454
496
|
end
|
|
455
497
|
|
|
498
|
+
# DateField: several formats in, one format out. Type a date in any of the
|
|
499
|
+
# three spellings this pane accepts and Tab away — the field rewrites it in
|
|
500
|
+
# the *first* one, which is how the user sees that it understood them. Both
|
|
501
|
+
# halves of the settling rule are visible here too: nothing reddens while a
|
|
502
|
+
# date is being typed, and a buffer that never parsed reddens on the way
|
|
503
|
+
# out. The echo row is deliberately driven by `on_value_change` alone, so
|
|
504
|
+
# garbage leaves it stale: bad_input? is a *pull*, and the button is the "a
|
|
505
|
+
# form asks once, at the click" gesture that consults it (it also gives Tab
|
|
506
|
+
# somewhere to go, which is what makes both behaviours visible at all).
|
|
507
|
+
def build_date_field
|
|
508
|
+
prompt = Tuile::Component::Label.new
|
|
509
|
+
prompt.text = "Tab here, then type 4.9.2026 — or 09/04/2026, or 2026-09-04.\n" \
|
|
510
|
+
"Tab away or press Enter and a buffer that parses is rewritten as yyyy-mm-dd;\n" \
|
|
511
|
+
"one that doesn't is left as you typed it, and *then* the well goes red.\n" \
|
|
512
|
+
"Up/Down step a day, and an empty field steps to today."
|
|
513
|
+
field = Tuile::Component::DateField.new
|
|
514
|
+
field.formats = ["%Y-%m-%d", "%d.%m.%Y", "%m/%d/%Y"]
|
|
515
|
+
status = Tuile::Component::Label.new
|
|
516
|
+
# to_s, not inspect: Date#inspect spells out the Julian day and the
|
|
517
|
+
# calendar reform, which is noise next to the one fact this row is for.
|
|
518
|
+
report = -> { status.text = "value: #{field.value&.to_s || "nil"} bad_input?: #{field.bad_input?}" }
|
|
519
|
+
report.call
|
|
520
|
+
field.on_value_change = ->(_value) { report.call }
|
|
521
|
+
ask = Tuile::Component::Button.new("Ask again") { report.call }
|
|
522
|
+
form do |f|
|
|
523
|
+
f.add(prompt, Fixed[4])
|
|
524
|
+
f.add(field, Fixed[1], cross: Fixed[20])
|
|
525
|
+
f.add(status, Fixed[1])
|
|
526
|
+
f.add(ask, Fixed[1], cross: Fixed[button_width(ask)])
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# TimeField: one knob for two things, and the reason it is one. Both fields
|
|
531
|
+
# below sit under a *Finnish* spelling that this pane installs on the
|
|
532
|
+
# screen, because the whole point is invisible under ISO: switching
|
|
533
|
+
# precision must not cost you the locale's separator. So the left field
|
|
534
|
+
# shows 13.45 and the right 13.45.00 — a dot either way, which a per-field
|
|
535
|
+
# format override could not have managed.
|
|
536
|
+
def build_time_field
|
|
537
|
+
# Assigned here rather than detected, so the pane demonstrates the same
|
|
538
|
+
# thing on an American box as on a Finnish one.
|
|
539
|
+
Tuile::Screen.instance.locale = Tuile::Locale::ISO.with(time_formats: ["%H.%M.%S", "%H:%M:%S"])
|
|
540
|
+
prompt = Tuile::Component::Label.new
|
|
541
|
+
prompt.text = "The session spells times the Finnish way (13.45). Tab into either field.\n" \
|
|
542
|
+
"Up/Down step a minute on the left, a second on the right (PageUp/PageDown an hour in both) —\n" \
|
|
543
|
+
"one knob, because precision is a property of the format the buffer holds.\n" \
|
|
544
|
+
"Note the dot survives the switch: that is what a per-field format would cost."
|
|
545
|
+
minutes = time_field_with(step: 60)
|
|
546
|
+
seconds = time_field_with(step: 1)
|
|
547
|
+
status = Tuile::Component::Label.new
|
|
548
|
+
report = lambda do
|
|
549
|
+
status.text = "minutes: #{minutes.value&.strftime("%H:%M:%S") || "nil"} (#{minutes.formats.first}) " \
|
|
550
|
+
"seconds: #{seconds.value&.strftime("%H:%M:%S") || "nil"} (#{seconds.formats.first})"
|
|
551
|
+
end
|
|
552
|
+
report.call
|
|
553
|
+
[minutes, seconds].each { _1.on_value_change = ->(_value) { report.call } }
|
|
554
|
+
form do |f|
|
|
555
|
+
f.add(prompt, Fixed[4])
|
|
556
|
+
f.add(labelled("Minute stride", minutes, field_width: 12), Fixed[1], cross: Fixed[30])
|
|
557
|
+
f.add(labelled("Second stride", seconds, field_width: 12), Fixed[1], cross: Fixed[30])
|
|
558
|
+
f.add(status, Fixed[1])
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# @param step [Integer]
|
|
563
|
+
# @return [Tuile::Component::TimeField]
|
|
564
|
+
def time_field_with(step:)
|
|
565
|
+
Tuile::Component::TimeField.new.tap do |field|
|
|
566
|
+
field.step = step
|
|
567
|
+
field.set_to(13, 45)
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# HasBadInput: the one fact on_value_change cannot carry. Type a lone "-"
|
|
572
|
+
# and watch the echo row stay silent — the value was nil before and is nil
|
|
573
|
+
# after, so there is no diff to report — while Save, which asks bad_input?
|
|
574
|
+
# instead of empty?, refuses and names the field.
|
|
575
|
+
def build_bad_input
|
|
576
|
+
prompt = Tuile::Component::Label.new
|
|
577
|
+
prompt.text = "Type a lone '-' into Amount (or '1e' into Rate), then press Save.\n" \
|
|
578
|
+
"Both read value: nil and empty?: true, exactly like an untouched field —\n" \
|
|
579
|
+
"which is why a form must ask bad_input? before it saves a nil over your work."
|
|
580
|
+
# The one pane that tags its widgets with `id`, so the sampler's own spec
|
|
581
|
+
# can drive them through Tuile::Testing.get by name.
|
|
582
|
+
amount = Tuile::Component::IntegerField.new.tap { _1.id = :amount }
|
|
583
|
+
rate = Tuile::Component::FloatField.new.tap { _1.id = :rate }
|
|
584
|
+
echo = Tuile::Component::Label.new.tap { _1.text = "on_value_change: (nothing yet)" }
|
|
585
|
+
amount.on_value_change = ->(v) { echo.text = "on_value_change: amount = #{v.inspect}" }
|
|
586
|
+
rate.on_value_change = ->(v) { echo.text = "on_value_change: rate = #{v.inspect}" }
|
|
587
|
+
save = Tuile::Component::Button.new("Save") { save_form("Amount" => amount, "Rate" => rate) }
|
|
588
|
+
save.id = :save
|
|
589
|
+
rows = group do |g|
|
|
590
|
+
g.add(labelled("Amount", amount), Fixed[1])
|
|
591
|
+
g.add(labelled("Rate", rate), Fixed[1])
|
|
592
|
+
end
|
|
593
|
+
form do |f|
|
|
594
|
+
f.add(prompt, Fixed[3])
|
|
595
|
+
f.add(rows, Fixed[2])
|
|
596
|
+
f.add(echo, Fixed[1])
|
|
597
|
+
f.add(save, Fixed[1], cross: Fixed[button_width(save)])
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# The login form of `D_has_validation`: the click writes a verdict onto
|
|
602
|
+
# each field and the field paints itself red, while *this* pane owns the
|
|
603
|
+
# cells the messages go in — one Label per field, refilled from
|
|
604
|
+
# `on_error_message_change`. Note the handler sets *or clears* on every
|
|
605
|
+
# pass, which is the whole writer discipline; and that no field computes
|
|
606
|
+
# anything, so nothing here fights the fields' own `bad_input?`.
|
|
607
|
+
def build_validation
|
|
608
|
+
prompt = Tuile::Component::Label.new
|
|
609
|
+
prompt.text = "Press Log in empty, then with a 2-letter username: the message lands in\n" \
|
|
610
|
+
"this pane's cells (via a listener) and the field's well turns red.\n" \
|
|
611
|
+
"Tab between them — an invalid field still shows which one has focus."
|
|
612
|
+
username = Tuile::Component::TextField.new
|
|
613
|
+
password = Tuile::Component::PasswordField.new
|
|
614
|
+
fields = { "Username" => username, "Password" => password }
|
|
615
|
+
rows = group do |g|
|
|
616
|
+
fields.each { |caption, field| g.add(validated_row(caption, field), Fixed[1]) }
|
|
617
|
+
end
|
|
618
|
+
login = Tuile::Component::Button.new("Log in") { validate_login(fields) }
|
|
619
|
+
form do |f|
|
|
620
|
+
f.add(prompt, Fixed[3])
|
|
621
|
+
f.add(rows, Fixed[2])
|
|
622
|
+
f.add(login, Fixed[1], cross: Fixed[button_width(login)])
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# @param caption [String]
|
|
627
|
+
# @param field [Tuile::Component] a field carrying {Tuile::Component::HasValidation}.
|
|
628
|
+
# @return [Tuile::Component] a row of caption, field, and the error Label
|
|
629
|
+
# the field's listener keeps current.
|
|
630
|
+
def validated_row(caption, field)
|
|
631
|
+
error = Tuile::Component::Label.new
|
|
632
|
+
field.on_error_message_change = ->(msg) { error.text = msg || Tuile::StyledString::EMPTY }
|
|
633
|
+
row do |r|
|
|
634
|
+
r.add(Tuile::Component::Label.new(caption), Fixed[14])
|
|
635
|
+
r.add(field, Fixed[22])
|
|
636
|
+
r.add(error, Expand[1])
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# Two rules, so the pane shows both halves: "required" has nothing to tint,
|
|
641
|
+
# "too short" does.
|
|
642
|
+
# @param fields [Hash{String => Tuile::Component}] caption => field.
|
|
643
|
+
def validate_login(fields)
|
|
644
|
+
fields.each { |caption, field| field.error_message = login_problem(caption, field) }
|
|
645
|
+
return if fields.each_value.any?(&:error_message)
|
|
646
|
+
|
|
647
|
+
Tuile::Component::ConfirmWindow.alert("Logged in", "Welcome, #{fields["Username"].value}.")
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# @param caption [String]
|
|
651
|
+
# @param field [Tuile::Component]
|
|
652
|
+
# @return [String, nil] the verdict, or `nil` — the *clear* half of
|
|
653
|
+
# set-or-clear-on-every-pass, without which a fixed field stays red.
|
|
654
|
+
def login_problem(caption, field)
|
|
655
|
+
return "#{caption} is required" if field.empty?
|
|
656
|
+
return "at least 3 characters" if field.value.length < 3
|
|
657
|
+
|
|
658
|
+
nil
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# The Save gate of `D_bad_input`: asked once, at the click, so the
|
|
662
|
+
# continuously-true fact ("2" is a bad date on the way to "2026") is only
|
|
663
|
+
# ever read in a settled state.
|
|
664
|
+
# @param fields [Hash{String => Tuile::Component}] caption => field.
|
|
665
|
+
def save_form(fields)
|
|
666
|
+
bad = fields.filter_map do |caption, field|
|
|
667
|
+
"#{caption}: #{field.bad_input_message}" if field.respond_to?(:bad_input?) && field.bad_input?
|
|
668
|
+
end
|
|
669
|
+
if bad.empty?
|
|
670
|
+
values = fields.map { |caption, field| "#{caption}: #{field.value.inspect}" }
|
|
671
|
+
Tuile::Component::ConfirmWindow.alert("Saved", values.join("\n"))
|
|
672
|
+
else
|
|
673
|
+
Tuile::Component::ConfirmWindow.alert("Cannot save", "#{bad.size} problem(s):\n#{bad.join("\n")}")
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
|
|
456
677
|
# @param value [BigDecimal, nil]
|
|
457
678
|
# @return [String] the value tripled exactly, next to the same sum in Float.
|
|
458
679
|
def triple_report(value)
|
|
@@ -491,52 +712,36 @@ module SamplerExample
|
|
|
491
712
|
# Slash commands the demo offers; the menu filters these by what's typed.
|
|
492
713
|
SLASH_COMMANDS = %w[/help /list /open /save /clear /quit].freeze
|
|
493
714
|
|
|
494
|
-
# A
|
|
495
|
-
#
|
|
496
|
-
#
|
|
497
|
-
#
|
|
498
|
-
#
|
|
715
|
+
# A ListDropdown driven from a TextArea — the same shape {ComboBox} and
|
|
716
|
+
# {Select} use, but wired by app code onto a field that knows nothing about
|
|
717
|
+
# it. Focus (and the caret) stays in the TextArea the whole time: an
|
|
718
|
+
# `on_change` listener refills the menu, and {SlashCommandTextArea} hands
|
|
719
|
+
# movement keys to `#move` and Enter to `#choose` while it is open.
|
|
499
720
|
def build_slash_demo
|
|
500
721
|
prompt = Tuile::Component::Label.new
|
|
501
|
-
prompt.text = "
|
|
502
|
-
"(try \"/\" or \"/s\"). The menu floats
|
|
722
|
+
prompt.text = "A ListDropdown driven from a TextArea. Type a slash command\n" \
|
|
723
|
+
"(try \"/\" or \"/s\"). The menu floats over the field without taking\n" \
|
|
503
724
|
"focus: Down/Up move the selection, Enter accepts, ESC dismisses, and\n" \
|
|
504
725
|
"ordinary typing keeps editing the field and refilters the menu."
|
|
505
|
-
|
|
506
|
-
|
|
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)
|
|
726
|
+
overlay = Tuile::Component::ListDropdown.new
|
|
512
727
|
@slash_overlay = overlay
|
|
728
|
+
area = SlashCommandTextArea.new(overlay)
|
|
513
729
|
|
|
514
730
|
refill = lambda do
|
|
515
731
|
matches = slash_matches(area)
|
|
516
732
|
if matches.empty?
|
|
517
733
|
overlay.close if overlay.open?
|
|
518
734
|
else
|
|
735
|
+
overlay.items = matches
|
|
519
736
|
overlay.open unless overlay.open?
|
|
520
|
-
|
|
521
|
-
|
|
737
|
+
# Width is the driver's call, never the dropdown's: measure the
|
|
738
|
+
# commands rather than inherit the full-width TextArea's columns.
|
|
739
|
+
overlay.anchor_to(area.rect, rows: matches.size, width: slash_menu_width(matches))
|
|
522
740
|
end
|
|
523
741
|
end
|
|
524
742
|
|
|
525
743
|
area.on_change = ->(_text) { refill.call }
|
|
526
|
-
|
|
527
|
-
area.on_key = lambda do |key|
|
|
528
|
-
next false unless overlay.open?
|
|
529
|
-
|
|
530
|
-
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
|
|
533
|
-
when Tuile::Keys::ESC
|
|
534
|
-
overlay.close
|
|
535
|
-
true
|
|
536
|
-
else
|
|
537
|
-
false
|
|
538
|
-
end
|
|
539
|
-
end
|
|
744
|
+
overlay.on_item_chosen = ->(_idx, item) { accept_slash_command(area, item.to_s) }
|
|
540
745
|
|
|
541
746
|
form do |f|
|
|
542
747
|
f.add(prompt, Fixed[4])
|
|
@@ -667,6 +872,42 @@ module SamplerExample
|
|
|
667
872
|
end
|
|
668
873
|
end
|
|
669
874
|
|
|
875
|
+
# `visible=` on a conditional form: the fields a checkbox above them
|
|
876
|
+
# governs. The rows close up completely when they go — a hidden child costs
|
|
877
|
+
# neither its slot nor the box's `spacing` gap, which is what separates it
|
|
878
|
+
# from a `Fixed[0]` collapse — and come back with their constraints and
|
|
879
|
+
# their typed text intact, since nothing was ever detached.
|
|
880
|
+
def build_visibility
|
|
881
|
+
prompt = Tuile::Component::Label.new
|
|
882
|
+
prompt.text = "Tick 'Business customer' to reveal two more fields.\n" \
|
|
883
|
+
"The rows close up with no double gap, Tab skips what is hidden,\n" \
|
|
884
|
+
"and text typed into a field survives being hidden and shown."
|
|
885
|
+
status = Tuile::Component::Label.new
|
|
886
|
+
company = labelled("Company", Tuile::Component::TextField.new)
|
|
887
|
+
vat = labelled("VAT id", Tuile::Component::TextField.new)
|
|
888
|
+
conditional = [company, vat]
|
|
889
|
+
business = Tuile::Component::Checkbox.new("Business customer")
|
|
890
|
+
apply = lambda do
|
|
891
|
+
conditional.each { _1.visible = business.checked? }
|
|
892
|
+
status.text = "visible fields: #{business.checked? ? 4 : 2}"
|
|
893
|
+
end
|
|
894
|
+
business.on_value_change = ->(_) { apply.call }
|
|
895
|
+
# The rows sit flush; the form keeps its blank row around the block.
|
|
896
|
+
rows = group do |g|
|
|
897
|
+
g.add(labelled("Name", Tuile::Component::TextField.new), Fixed[1])
|
|
898
|
+
g.add(labelled("Email", Tuile::Component::TextField.new), Fixed[1])
|
|
899
|
+
g.add(company, Fixed[1])
|
|
900
|
+
g.add(vat, Fixed[1])
|
|
901
|
+
end
|
|
902
|
+
apply.call
|
|
903
|
+
form do |f|
|
|
904
|
+
f.add(prompt, Fixed[3])
|
|
905
|
+
f.add(business, Fixed[1])
|
|
906
|
+
f.add(rows, Fixed[4])
|
|
907
|
+
f.add(status, Fixed[1])
|
|
908
|
+
end
|
|
909
|
+
end
|
|
910
|
+
|
|
670
911
|
# One filterable log level: the item type a CheckboxGroup holds. Its `value`
|
|
671
912
|
# is a Set of *these*, never of the labels shown on the rows.
|
|
672
913
|
LogLevel = Data.define(:label, :tag, :color)
|
|
@@ -807,7 +1048,7 @@ module SamplerExample
|
|
|
807
1048
|
short_size = ->(bytes) { bytes < 1024 ? bytes.to_s : "#{(bytes / 1024.0).round}k" }
|
|
808
1049
|
|
|
809
1050
|
update_status = lambda do
|
|
810
|
-
under_cursor = SORT_ORDERS[group.
|
|
1051
|
+
under_cursor = SORT_ORDERS[group.list.cursor.position]
|
|
811
1052
|
status.text = "value: #{group.value.label} — cursor: #{under_cursor&.label}"
|
|
812
1053
|
end
|
|
813
1054
|
resort = lambda do
|
|
@@ -818,9 +1059,9 @@ module SamplerExample
|
|
|
818
1059
|
end
|
|
819
1060
|
resort.call
|
|
820
1061
|
group.on_value_change = ->(_order) { resort.call }
|
|
821
|
-
# `
|
|
1062
|
+
# `list` is the composed List, which is where the cursor lives.
|
|
822
1063
|
# Watching it is what makes the chrome/value split visible above.
|
|
823
|
-
group.
|
|
1064
|
+
group.list.on_cursor_changed = ->(_idx, _line) { update_status.call }
|
|
824
1065
|
|
|
825
1066
|
# Side-by-side body on a rect-callback {Panel}, as in the CheckboxGroup
|
|
826
1067
|
# demo — the sidebar width is a capped proportion, not a constraint.
|
|
@@ -928,6 +1169,21 @@ module SamplerExample
|
|
|
928
1169
|
BgChoice.new("Hot pink (RGB)", Tuile::Color.rgb(120, 20, 70))
|
|
929
1170
|
].freeze
|
|
930
1171
|
|
|
1172
|
+
# The one choice that can't be a constant: the terminal's *own* background
|
|
1173
|
+
# stepped +10 per channel — the borderless-pane tint, which only sits right
|
|
1174
|
+
# when it's derived from the real background. Nil on a terminal that
|
|
1175
|
+
# reported none, which is the branch most users will actually see.
|
|
1176
|
+
def terminal_tint_choice
|
|
1177
|
+
bg = Tuile::Screen.instance.background_color
|
|
1178
|
+
return BgChoice.new("Terminal background — none reported", nil) if bg.nil?
|
|
1179
|
+
|
|
1180
|
+
BgChoice.new("Terminal background +10 (derived)",
|
|
1181
|
+
Tuile::Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }))
|
|
1182
|
+
end
|
|
1183
|
+
|
|
1184
|
+
# @param derived [BgChoice] the live terminal-derived tint, offered second.
|
|
1185
|
+
def bg_choices(derived) = [BG_CHOICES.first, derived, *BG_CHOICES[1..]]
|
|
1186
|
+
|
|
931
1187
|
def build_background
|
|
932
1188
|
intro = Tuile::Component::Label.new
|
|
933
1189
|
intro.text = "bg_color tints a component and every descendant that doesn't set its own.\n" \
|
|
@@ -951,7 +1207,8 @@ module SamplerExample
|
|
|
951
1207
|
# their own well. Theme::Ref picks re-resolve on a scheme flip with no hook;
|
|
952
1208
|
# the hard-coded Colors are fixed by design, so no on_theme_changed here.
|
|
953
1209
|
outer = nil
|
|
954
|
-
|
|
1210
|
+
derived = terminal_tint_choice
|
|
1211
|
+
combo = Tuile::Component::ComboBox.new(items: bg_choices(derived))
|
|
955
1212
|
combo.item_label = :label.to_proc
|
|
956
1213
|
combo.on_value_change = ->(choice) { outer.bg_color = choice.color }
|
|
957
1214
|
|
|
@@ -960,6 +1217,17 @@ module SamplerExample
|
|
|
960
1217
|
f.add(combo, Fixed[1], cross: Fixed[40])
|
|
961
1218
|
f.add(box, Expand[1])
|
|
962
1219
|
end
|
|
1220
|
+
# The derived tint is the one pick whose *color* moves under it: a flip
|
|
1221
|
+
# re-probes the terminal, so rebuild the choice and re-apply it if it is
|
|
1222
|
+
# the current one. Expect it to correct itself a frame late — the flip
|
|
1223
|
+
# report carries no RGB, so this hook runs once on the old background and
|
|
1224
|
+
# again when the re-probe answers.
|
|
1225
|
+
outer.on_theme_changed = lambda do
|
|
1226
|
+
was_derived = combo.value.equal?(derived)
|
|
1227
|
+
derived = terminal_tint_choice
|
|
1228
|
+
combo.items = bg_choices(derived)
|
|
1229
|
+
combo.value = derived if was_derived
|
|
1230
|
+
end
|
|
963
1231
|
combo.value = BG_CHOICES.first # show "None" as the resting selection
|
|
964
1232
|
outer
|
|
965
1233
|
end
|
|
@@ -1166,6 +1434,61 @@ module SamplerExample
|
|
|
1166
1434
|
end
|
|
1167
1435
|
end
|
|
1168
1436
|
|
|
1437
|
+
# The three factories, the layer-1 builder (3-way), and a message long
|
|
1438
|
+
# enough to scroll. The status row makes the one-dismissal-channel contract
|
|
1439
|
+
# visible: every route out of a dialog lands in exactly one callback.
|
|
1440
|
+
def build_confirm_launcher
|
|
1441
|
+
label = Tuile::Component::Label.new
|
|
1442
|
+
label.text = "ConfirmWindow asks a question with a row of buttons, in a popup sized to\n" \
|
|
1443
|
+
"its content (capped at half the screen). Every button closes the dialog;\n" \
|
|
1444
|
+
"ESC, q or an outside click dismiss it instead. An underlined letter presses\n" \
|
|
1445
|
+
"its button from anywhere; Up/Down scroll a long message meanwhile."
|
|
1446
|
+
status = Tuile::Component::Label.new("Outcome: none yet")
|
|
1447
|
+
report = ->(outcome) { status.text = "Outcome: #{outcome}" }
|
|
1448
|
+
buttons = [
|
|
1449
|
+
Tuile::Component::Button.new("Confirm") do
|
|
1450
|
+
Tuile::Component::ConfirmWindow.confirm(
|
|
1451
|
+
"Delete Report Q4?", "This cannot be undone.",
|
|
1452
|
+
confirm: "Delete", on_dismiss: -> { report.call("kept the report") }
|
|
1453
|
+
) { report.call("deleted the report") }
|
|
1454
|
+
end,
|
|
1455
|
+
Tuile::Component::Button.new("Yes/No") do
|
|
1456
|
+
Tuile::Component::ConfirmWindow.yes_no(
|
|
1457
|
+
"Overwrite draft.txt?", "The file already exists.",
|
|
1458
|
+
on_dismiss: -> { report.call("kept draft.txt") }
|
|
1459
|
+
) { report.call("overwrote draft.txt") }
|
|
1460
|
+
end,
|
|
1461
|
+
Tuile::Component::Button.new("Alert") do
|
|
1462
|
+
Tuile::Component::ConfirmWindow.alert("Export failed", "Contact support@example.com.")
|
|
1463
|
+
end,
|
|
1464
|
+
Tuile::Component::Button.new("3-way") do
|
|
1465
|
+
dialog = Tuile::Component::ConfirmWindow.new("Unsaved changes")
|
|
1466
|
+
dialog.message = "Save your changes before leaving?"
|
|
1467
|
+
dialog.button("Save") { report.call("saved") }
|
|
1468
|
+
dialog.button("Discard") { report.call("discarded") }
|
|
1469
|
+
dialog.button("Cancel")
|
|
1470
|
+
dialog.on_dismiss = -> { report.call("stayed put") }
|
|
1471
|
+
dialog.open
|
|
1472
|
+
end,
|
|
1473
|
+
Tuile::Component::Button.new("Long") do
|
|
1474
|
+
dialog = Tuile::Component::ConfirmWindow.new("Terms of Service")
|
|
1475
|
+
dialog.message = (1..40).map { "#{_1}. Clause #{_1} of the agreement, spelled out in full." }.join("\n")
|
|
1476
|
+
dialog.button("Accept") { report.call("accepted the terms") }
|
|
1477
|
+
dialog.button("Decline") { report.call("declined the terms") }
|
|
1478
|
+
dialog.on_dismiss = -> { report.call("left the terms unanswered") }
|
|
1479
|
+
dialog.open
|
|
1480
|
+
end
|
|
1481
|
+
]
|
|
1482
|
+
strip = row do |r|
|
|
1483
|
+
buttons.each { |b| r.add(b, Fixed[button_width(b)]) }
|
|
1484
|
+
end
|
|
1485
|
+
form do |f|
|
|
1486
|
+
f.add(label, Fixed[4])
|
|
1487
|
+
f.add(strip, Fixed[1])
|
|
1488
|
+
f.add(status, Fixed[1])
|
|
1489
|
+
end
|
|
1490
|
+
end
|
|
1491
|
+
|
|
1169
1492
|
def build_popup_launcher
|
|
1170
1493
|
launcher(
|
|
1171
1494
|
"Popup is a modal overlay wrapping any Component.\n" \
|
|
@@ -1179,17 +1502,39 @@ module SamplerExample
|
|
|
1179
1502
|
end
|
|
1180
1503
|
|
|
1181
1504
|
def build_info_launcher
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1505
|
+
label = Tuile::Component::Label.new
|
|
1506
|
+
label.text = "InfoWindow is a Window with a read-only body: prose (message=) wraps in\n" \
|
|
1507
|
+
"a TextView, rows (lines=) stay one per row in a List, truncating. The\n" \
|
|
1508
|
+
"constructor picks the presentation by the body's type."
|
|
1509
|
+
buttons = [
|
|
1510
|
+
Tuile::Component::Button.new("Prose") do
|
|
1511
|
+
Tuile::Component::InfoWindow.open(
|
|
1512
|
+
"About",
|
|
1513
|
+
"InfoWindow renders a String as wrapping prose: this sentence is long " \
|
|
1514
|
+
"enough to wrap to the popup's width, and it scrolls when it outgrows " \
|
|
1515
|
+
"the box. Press ESC or q to close."
|
|
1516
|
+
)
|
|
1517
|
+
end,
|
|
1518
|
+
Tuile::Component::Button.new("Rows") do
|
|
1519
|
+
Tuile::Component::InfoWindow.open(
|
|
1520
|
+
"Files",
|
|
1521
|
+
["drwxr-xr-x src/",
|
|
1522
|
+
"drwxr-xr-x spec/",
|
|
1523
|
+
"-rw-r--r-- README.md 4.1k",
|
|
1524
|
+
"-rw-r--r-- Rakefile 812",
|
|
1525
|
+
"",
|
|
1526
|
+
"Rows never wrap: a long row like this one is truncated at the popup's edge, keeping columns aligned.",
|
|
1527
|
+
"",
|
|
1528
|
+
"Press ESC or q to close."]
|
|
1529
|
+
)
|
|
1530
|
+
end
|
|
1531
|
+
]
|
|
1532
|
+
strip = row do |r|
|
|
1533
|
+
buttons.each { |b| r.add(b, Fixed[button_width(b)]) }
|
|
1534
|
+
end
|
|
1535
|
+
form do |f|
|
|
1536
|
+
f.add(label, Fixed[3])
|
|
1537
|
+
f.add(strip, Fixed[1])
|
|
1193
1538
|
end
|
|
1194
1539
|
end
|
|
1195
1540
|
|
|
@@ -1207,7 +1552,7 @@ module SamplerExample
|
|
|
1207
1552
|
|
|
1208
1553
|
def build_log_window
|
|
1209
1554
|
log = Tuile::Component::LogWindow.new("Log")
|
|
1210
|
-
["LogWindow is a Window
|
|
1555
|
+
["LogWindow is a Window framing an auto-scrolling LogTextView.",
|
|
1211
1556
|
"Lines are appended via #log (safe from any thread).",
|
|
1212
1557
|
"Used with Logger::IO it captures arbitrary log output."].each { |line| log.log(line) }
|
|
1213
1558
|
log
|
|
@@ -1315,18 +1660,13 @@ module SamplerExample
|
|
|
1315
1660
|
area.caret = start + command.length + 1
|
|
1316
1661
|
end
|
|
1317
1662
|
|
|
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)
|
|
1663
|
+
# The slash menu's width: the widest command plus List's two row gutters,
|
|
1664
|
+
# clamped to the screen. ListDropdown places itself but never measures — the
|
|
1665
|
+
# width policy stays with the driver, exactly as it does for Select.
|
|
1666
|
+
# @return [Integer]
|
|
1667
|
+
def slash_menu_width(matches)
|
|
1668
|
+
widest = matches.map { Tuile::StyledString.plain(_1).display_width }.max || 0
|
|
1669
|
+
[widest + 2, Tuile::Screen.instance.size.width].min
|
|
1330
1670
|
end
|
|
1331
1671
|
|
|
1332
1672
|
# 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.
|