tuile 0.14.0 → 0.16.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 +159 -49
- data/README.md +53 -17
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +152 -22
- data/book/06-theming.md +105 -25
- data/book/07-components.md +531 -50
- data/book/08-testing.md +100 -20
- data/book/10-locale.md +216 -0
- data/book/README.md +19 -9
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +654 -40
- data/lib/tuile/component/abstract_string_field.rb +114 -68
- data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +38 -21
- data/lib/tuile/component/combo_box.rb +102 -59
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +347 -0
- data/lib/tuile/component/date_time_field.rb +275 -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 +32 -13
- 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/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +7 -39
- data/lib/tuile/component/layout/box.rb +90 -19
- data/lib/tuile/component/layout.rb +15 -5
- data/lib/tuile/component/list.rb +53 -38
- data/lib/tuile/component/list_dropdown.rb +7 -3
- data/lib/tuile/component/menu_bar/cascade.rb +5 -5
- data/lib/tuile/component/menu_bar.rb +18 -18
- data/lib/tuile/component/notification.rb +32 -18
- data/lib/tuile/component/overlay.rb +26 -8
- data/lib/tuile/component/picker_window.rb +27 -8
- data/lib/tuile/component/popup.rb +2 -2
- data/lib/tuile/component/progress_bar.rb +10 -4
- data/lib/tuile/component/radio_group.rb +41 -23
- data/lib/tuile/component/select.rb +23 -16
- data/lib/tuile/component/slot.rb +3 -3
- data/lib/tuile/component/tab_sheet.rb +6 -6
- data/lib/tuile/component/tabs.rb +11 -11
- data/lib/tuile/component/text_area.rb +26 -18
- data/lib/tuile/component/text_field.rb +55 -26
- data/lib/tuile/component/text_view.rb +40 -19
- data/lib/tuile/component/time_field.rb +479 -0
- data/lib/tuile/component/window.rb +26 -13
- data/lib/tuile/component.rb +635 -131
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +95 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +219 -68
- data/lib/tuile/screen_pane.rb +51 -42
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +110 -32
- 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 +4595 -825
- metadata +14 -9
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -5422
- data/TERMINOLOGY.md +0 -71
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -124
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
data/lib/tuile/component/list.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Tuile
|
|
|
6
6
|
#
|
|
7
7
|
# list = Component::List.new
|
|
8
8
|
# list.items = people
|
|
9
|
-
# list.renderer = ->(p) { StyledString.plain(p.name) + screen.theme.
|
|
9
|
+
# list.renderer = ->(p) { StyledString.plain(p.name) + screen.theme.fg(:muted, " #{p.email}") }
|
|
10
10
|
# list.cursor = List::Cursor.new # a bare list has none
|
|
11
11
|
# list.on_item_chosen = ->(index, person) { open(person) }
|
|
12
12
|
#
|
|
@@ -249,7 +249,7 @@ module Tuile
|
|
|
249
249
|
|
|
250
250
|
# @param key [String] a key.
|
|
251
251
|
# @return [Boolean] true if the key was handled.
|
|
252
|
-
def handle_key(key)
|
|
252
|
+
def handle_key?(key)
|
|
253
253
|
if key == Keys::PAGE_UP
|
|
254
254
|
move_scroll_top_row_by(-viewport_rows)
|
|
255
255
|
true
|
|
@@ -259,7 +259,7 @@ module Tuile
|
|
|
259
259
|
elsif key == Keys::ENTER && cursor_on_item?
|
|
260
260
|
fire_item_chosen
|
|
261
261
|
true
|
|
262
|
-
elsif @cursor.handle_key(key, @items.size, viewport_rows)
|
|
262
|
+
elsif @cursor.handle_key?(key, @items.size, viewport_rows)
|
|
263
263
|
move_viewport_to_cursor
|
|
264
264
|
notify_cursor_changed
|
|
265
265
|
invalidate
|
|
@@ -316,25 +316,35 @@ module Tuile
|
|
|
316
316
|
true
|
|
317
317
|
end
|
|
318
318
|
|
|
319
|
-
#
|
|
320
|
-
#
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
elsif event.button == :scroll_up
|
|
326
|
-
move_scroll_top_row_by(-4)
|
|
327
|
-
else
|
|
328
|
-
return unless rect.contains?(event.point)
|
|
319
|
+
# Moves the cursor to the pressed row and fires {#on_item_chosen}; what
|
|
320
|
+
# each {Cursor} does with a press is its own.
|
|
321
|
+
# @param event [Mouse::DownEvent]
|
|
322
|
+
# @return [Boolean]
|
|
323
|
+
def handle_mouse_down?(event)
|
|
324
|
+
return false unless event.button == :left
|
|
329
325
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
end
|
|
336
|
-
fire_item_chosen if event.button == :left && item_index >= 0 && item_index < @items.size && cursor_on_item?
|
|
326
|
+
item_index = event.y - rect.top + scroll_top_row
|
|
327
|
+
if @cursor.handle_mouse_down?(item_index, event, @items.size)
|
|
328
|
+
move_viewport_to_cursor
|
|
329
|
+
notify_cursor_changed
|
|
330
|
+
invalidate
|
|
337
331
|
end
|
|
332
|
+
fire_item_chosen if item_index >= 0 && item_index < @items.size && cursor_on_item?
|
|
333
|
+
true
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Scrolls four rows a notch, and declines — so the notch bubbles to an
|
|
337
|
+
# ancestor scroller — once this list is at that end of its items.
|
|
338
|
+
# @param event [Mouse::ScrollEvent]
|
|
339
|
+
# @return [Boolean]
|
|
340
|
+
def handle_mouse_scroll?(event)
|
|
341
|
+
before = scroll_top_row
|
|
342
|
+
case event.direction
|
|
343
|
+
when :down then move_scroll_top_row_by(4)
|
|
344
|
+
when :up then move_scroll_top_row_by(-4)
|
|
345
|
+
else return false
|
|
346
|
+
end
|
|
347
|
+
scroll_top_row != before
|
|
338
348
|
end
|
|
339
349
|
|
|
340
350
|
# Paints the visible items into {#rect}, rendering the ones not already
|
|
@@ -379,15 +389,15 @@ module Tuile
|
|
|
379
389
|
# @param _item_count [Integer]
|
|
380
390
|
# @param _viewport_rows [Integer]
|
|
381
391
|
# @return [Boolean]
|
|
382
|
-
def handle_key(_key, _item_count, _viewport_rows)
|
|
392
|
+
def handle_key?(_key, _item_count, _viewport_rows)
|
|
383
393
|
false
|
|
384
394
|
end
|
|
385
395
|
|
|
386
396
|
# @param _item_index [Integer]
|
|
387
|
-
# @param _event [
|
|
397
|
+
# @param _event [Mouse::DownEvent]
|
|
388
398
|
# @param _item_count [Integer]
|
|
389
399
|
# @return [Boolean]
|
|
390
|
-
def
|
|
400
|
+
def handle_mouse_down?(_item_index, _event, _item_count)
|
|
391
401
|
false
|
|
392
402
|
end
|
|
393
403
|
|
|
@@ -422,7 +432,7 @@ module Tuile
|
|
|
422
432
|
# @param item_count [Integer] number of items in the list.
|
|
423
433
|
# @param viewport_rows [Integer] number of visible rows.
|
|
424
434
|
# @return [Boolean] true if the cursor moved.
|
|
425
|
-
def handle_key(key, item_count, viewport_rows)
|
|
435
|
+
def handle_key?(key, item_count, viewport_rows)
|
|
426
436
|
case key
|
|
427
437
|
when *Keys::DOWN_ARROWS
|
|
428
438
|
go_down_by(1, item_count)
|
|
@@ -441,11 +451,11 @@ module Tuile
|
|
|
441
451
|
end
|
|
442
452
|
end
|
|
443
453
|
|
|
444
|
-
# @param item_index [Integer] the item
|
|
445
|
-
# @param event [
|
|
454
|
+
# @param item_index [Integer] the item pressed on.
|
|
455
|
+
# @param event [Mouse::DownEvent] the event.
|
|
446
456
|
# @param item_count [Integer] number of items in the list.
|
|
447
|
-
# @return [Boolean] true if the
|
|
448
|
-
def
|
|
457
|
+
# @return [Boolean] true if the cursor moved.
|
|
458
|
+
def handle_mouse_down?(item_index, event, item_count)
|
|
449
459
|
if event.button == :left
|
|
450
460
|
go(item_index.clamp(nil, item_count - 1))
|
|
451
461
|
else
|
|
@@ -507,10 +517,10 @@ module Tuile
|
|
|
507
517
|
end
|
|
508
518
|
|
|
509
519
|
# @param item_index [Integer]
|
|
510
|
-
# @param event [
|
|
520
|
+
# @param event [Mouse::DownEvent]
|
|
511
521
|
# @param _item_count [Integer]
|
|
512
522
|
# @return [Boolean]
|
|
513
|
-
def
|
|
523
|
+
def handle_mouse_down?(item_index, event, _item_count)
|
|
514
524
|
if event.button == :left
|
|
515
525
|
prev_pos = @positions.reverse_each.find { _1 <= item_index }
|
|
516
526
|
return go_to_first if prev_pos.nil?
|
|
@@ -564,14 +574,14 @@ module Tuile
|
|
|
564
574
|
protected
|
|
565
575
|
|
|
566
576
|
# Drops the rendered-row cache when the wrap width changes. The wrap
|
|
567
|
-
# width depends on {#rect}`.width` and the scrollbar
|
|
577
|
+
# width depends on {#rect}`.width` and the scrollbar column, both of
|
|
568
578
|
# which trigger this hook. Also re-evaluates {#auto_scroll}: if items were
|
|
569
579
|
# assigned while the rect was empty (e.g. a {Popup}-wrapped list was
|
|
570
580
|
# populated before the popup was opened), the auto-scroll update
|
|
571
581
|
# was skipped because there was no viewport — re-run it now that there
|
|
572
582
|
# is one, so the list snaps to the bottom on first paint.
|
|
573
583
|
# @return [void]
|
|
574
|
-
def
|
|
584
|
+
def handle_width_changed
|
|
575
585
|
super
|
|
576
586
|
drop_row_cache
|
|
577
587
|
update_scroll_top_row_if_auto_scroll
|
|
@@ -738,7 +748,7 @@ module Tuile
|
|
|
738
748
|
# negating the auto-scroll. Skipped when {#rect} is empty: without a
|
|
739
749
|
# viewport the "items minus viewport" formula yields `@items.size`,
|
|
740
750
|
# which would leave `scroll_top_row` past the last item once a real rect
|
|
741
|
-
# arrives. {#
|
|
751
|
+
# arrives. {#handle_width_changed} re-runs this hook when the rect grows so
|
|
742
752
|
# the snap-to-bottom intent is preserved.
|
|
743
753
|
#
|
|
744
754
|
# Gated on {#following?}: once the user scrolls up off the bottom the
|
|
@@ -766,7 +776,7 @@ module Tuile
|
|
|
766
776
|
end
|
|
767
777
|
|
|
768
778
|
# @return [Integer] column width available for row content (rect width
|
|
769
|
-
# minus the scrollbar
|
|
779
|
+
# minus the scrollbar column, when visible). `0` when {#rect}'s width
|
|
770
780
|
# is non-positive.
|
|
771
781
|
def content_width
|
|
772
782
|
return 0 if rect.width <= 0
|
|
@@ -809,9 +819,11 @@ module Tuile
|
|
|
809
819
|
rendered.lines.first
|
|
810
820
|
end
|
|
811
821
|
|
|
812
|
-
# Pads `row` to one full row of the viewport (scrollbar
|
|
813
|
-
# excluded)
|
|
814
|
-
#
|
|
822
|
+
# Pads `row` to one full row of the viewport (scrollbar column
|
|
823
|
+
# excluded), including the one-column gutter either side — the trailing
|
|
824
|
+
# one is what keeps a row off the bar, as {TextView#scrollbar_columns}
|
|
825
|
+
# reserves separately. Rows wider than the content area are ellipsized
|
|
826
|
+
# via {StyledString#ellipsize} (span styles survive the cut); shorter
|
|
815
827
|
# ones are padded with default-styled spaces.
|
|
816
828
|
# @param row [StyledString]
|
|
817
829
|
# @return [StyledString] exactly {#content_width} display columns wide
|
|
@@ -837,7 +849,10 @@ module Tuile
|
|
|
837
849
|
base = index < @items.size ? padded_row(index) : blank_row
|
|
838
850
|
is_cursor = (active? || @show_cursor_when_inactive) && index < @items.size && @cursor.position == index
|
|
839
851
|
styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
|
|
840
|
-
|
|
852
|
+
if scrollbar
|
|
853
|
+
styled += StyledString.styled(scrollbar.scrollbar_char(row_in_viewport),
|
|
854
|
+
fg: screen.theme.scrollbar_color)
|
|
855
|
+
end
|
|
841
856
|
styled
|
|
842
857
|
end
|
|
843
858
|
end
|
|
@@ -51,6 +51,10 @@ module Tuile
|
|
|
51
51
|
# caret; {Select} would spend a branch on what a second arrow press already
|
|
52
52
|
# does) — and Enter/ESC, which carry driver-specific tails (see the class
|
|
53
53
|
# docs).
|
|
54
|
+
#
|
|
55
|
+
# A driver only ever sees the keys its own children decline, so a
|
|
56
|
+
# {ComboBox} never gets Ctrl+U — its field claims it to clear the query —
|
|
57
|
+
# while {Select}, wrapping no editor, gets every one of these.
|
|
54
58
|
# @return [Array<String>]
|
|
55
59
|
MOVE_KEYS = [Keys::UP_ARROW, Keys::DOWN_ARROW, Keys::PAGE_UP, Keys::PAGE_DOWN,
|
|
56
60
|
Keys::CTRL_U, Keys::CTRL_D].freeze
|
|
@@ -191,7 +195,7 @@ module Tuile
|
|
|
191
195
|
# @param width [Integer] the panel's width in columns, clamped to the
|
|
192
196
|
# screen. **Required, with no default:** `anchor.width` is the *parent's*
|
|
193
197
|
# width and would be meaningless here, so the caller measures (see
|
|
194
|
-
# `
|
|
198
|
+
# `design/decisions.md` `D_select` on why the width policy stays with the
|
|
195
199
|
# driver).
|
|
196
200
|
# @param max_rows [Integer] rows shown before the list scrolls.
|
|
197
201
|
# @return [void]
|
|
@@ -240,7 +244,7 @@ module Tuile
|
|
|
240
244
|
def move(key)
|
|
241
245
|
return false unless open? && MOVE_KEYS.include?(key)
|
|
242
246
|
|
|
243
|
-
@list.handle_key(key)
|
|
247
|
+
@list.handle_key?(key)
|
|
244
248
|
true
|
|
245
249
|
end
|
|
246
250
|
|
|
@@ -249,7 +253,7 @@ module Tuile
|
|
|
249
253
|
# own Enter branch.
|
|
250
254
|
# @return [Boolean] true iff a row was chosen (false when the cursor is
|
|
251
255
|
# off-content).
|
|
252
|
-
def choose = @list.handle_key(Keys::ENTER)
|
|
256
|
+
def choose = @list.handle_key?(Keys::ENTER)
|
|
253
257
|
end
|
|
254
258
|
end
|
|
255
259
|
end
|
|
@@ -8,18 +8,18 @@ module Tuile
|
|
|
8
8
|
# machinery of {MenuBar}; an app never names it.
|
|
9
9
|
#
|
|
10
10
|
# cascade.open_below(segment_rect, item) # Enter/Down on the strip
|
|
11
|
-
# return true if cascade.handle_key(key) # MenuBar#handle_key
|
|
11
|
+
# return true if cascade.handle_key?(key) # MenuBar#handle_key?, first
|
|
12
12
|
# cascade.close # focus lost, or rect changed
|
|
13
13
|
#
|
|
14
14
|
# A panel is a **non-modal overlay, not a child**, so it never takes focus:
|
|
15
15
|
# focus stays on the {MenuBar} for the whole interaction and every key
|
|
16
|
-
# arrives via {MenuBar#handle_key}, which offers it here first. That is
|
|
16
|
+
# arrives via {MenuBar#handle_key?}, which offers it here first. That is
|
|
17
17
|
# {Component::Select}'s architecture extended to N levels, and it is why
|
|
18
18
|
# nothing in the key-dispatch ladder changes.
|
|
19
19
|
#
|
|
20
20
|
# Widths are measured here, per level — the panel is as wide as the level's
|
|
21
21
|
# widest label — because {ListDropdown} deliberately measures nothing
|
|
22
|
-
# itself (`
|
|
22
|
+
# itself (`design/decisions.md` `D_select`).
|
|
23
23
|
#
|
|
24
24
|
# == Implementation details
|
|
25
25
|
# While open it consumes **everything** except the two keys that mean
|
|
@@ -73,7 +73,7 @@ module Tuile
|
|
|
73
73
|
# @return [Boolean] `true` when consumed — almost always, while open.
|
|
74
74
|
# `false` when closed, and for the two sideways keys {MenuBar} answers
|
|
75
75
|
# (see the class docs).
|
|
76
|
-
def handle_key(key)
|
|
76
|
+
def handle_key?(key)
|
|
77
77
|
return false unless open?
|
|
78
78
|
return true if deepest.move(key)
|
|
79
79
|
|
|
@@ -105,7 +105,7 @@ module Tuile
|
|
|
105
105
|
# @param key [String] a single printable, already downcased.
|
|
106
106
|
# @return [Boolean] whether an item on the deepest level claimed it. A
|
|
107
107
|
# miss is never offered to a shallower level.
|
|
108
|
-
def handle_mnemonic(key)
|
|
108
|
+
def handle_mnemonic?(key)
|
|
109
109
|
return false unless open?
|
|
110
110
|
|
|
111
111
|
level = depth - 1
|
|
@@ -42,7 +42,7 @@ module Tuile
|
|
|
42
42
|
#
|
|
43
43
|
# {Item} handles are minted by {#add_item} and nest via the *same* method, so
|
|
44
44
|
# depth is unlimited. There is no removal, no reordering and no dynamic
|
|
45
|
-
# rebuilding: a menu is built once, at construction. See `
|
|
45
|
+
# rebuilding: a menu is built once, at construction. See `design/decisions.md`
|
|
46
46
|
# `D_menu_bar`.
|
|
47
47
|
#
|
|
48
48
|
# == Sizing
|
|
@@ -242,7 +242,7 @@ module Tuile
|
|
|
242
242
|
#
|
|
243
243
|
# Both the highlight and the click hit test use it, so a click on the blank
|
|
244
244
|
# tail — or on a lower row, when the rect is taller than one — opens
|
|
245
|
-
# nothing. It still *focuses*: {
|
|
245
|
+
# nothing. It still *focuses*: {Mouse::Router}'s click-to-focus is
|
|
246
246
|
# ungated by geometry.
|
|
247
247
|
# @return [Size]
|
|
248
248
|
def extent
|
|
@@ -275,7 +275,7 @@ module Tuile
|
|
|
275
275
|
# panels on the pane — they are the {ScreenPane}'s children, not the bar's,
|
|
276
276
|
# so nothing else would take them down.
|
|
277
277
|
# @return [void]
|
|
278
|
-
def
|
|
278
|
+
def handle_detached
|
|
279
279
|
super
|
|
280
280
|
@cascade.close
|
|
281
281
|
end
|
|
@@ -288,11 +288,11 @@ module Tuile
|
|
|
288
288
|
# submenu — and both step to the sibling menu.
|
|
289
289
|
# @param key [String]
|
|
290
290
|
# @return [Boolean]
|
|
291
|
-
def handle_key(key)
|
|
291
|
+
def handle_key?(key)
|
|
292
292
|
# Ahead of the cascade: an open one swallows every printable it doesn't
|
|
293
293
|
# recognize, so a letter would never reach the strip otherwise.
|
|
294
|
-
return true if handle_mnemonic(key)
|
|
295
|
-
return true if @cascade.handle_key(key)
|
|
294
|
+
return true if handle_mnemonic?(key)
|
|
295
|
+
return true if @cascade.handle_key?(key)
|
|
296
296
|
|
|
297
297
|
if @cascade.open?
|
|
298
298
|
case key
|
|
@@ -310,17 +310,16 @@ module Tuile
|
|
|
310
310
|
end
|
|
311
311
|
end
|
|
312
312
|
|
|
313
|
-
# Opens the menu under a left
|
|
314
|
-
# open one;
|
|
315
|
-
#
|
|
316
|
-
# @param event [
|
|
317
|
-
# @return [
|
|
318
|
-
def
|
|
319
|
-
|
|
320
|
-
return unless event.button == :left
|
|
313
|
+
# Opens the menu under a left press, or closes it when it is already the
|
|
314
|
+
# open one; a press on the strip's padding claims the press and does
|
|
315
|
+
# nothing else.
|
|
316
|
+
# @param event [Mouse::DownEvent]
|
|
317
|
+
# @return [Boolean]
|
|
318
|
+
def handle_mouse_down?(event)
|
|
319
|
+
return false unless event.button == :left
|
|
321
320
|
|
|
322
321
|
index = index_at(event.point)
|
|
323
|
-
return if index.nil?
|
|
322
|
+
return true if index.nil?
|
|
324
323
|
|
|
325
324
|
if @cascade.open? && index == @highlighted_index
|
|
326
325
|
@cascade.close
|
|
@@ -328,6 +327,7 @@ module Tuile
|
|
|
328
327
|
self.highlight = index
|
|
329
328
|
open_highlighted
|
|
330
329
|
end
|
|
330
|
+
true
|
|
331
331
|
end
|
|
332
332
|
|
|
333
333
|
# @return [void]
|
|
@@ -372,7 +372,7 @@ module Tuile
|
|
|
372
372
|
# hook is the whole geometry story; {Component#rect=} invalidates for us,
|
|
373
373
|
# and {#rect=} closes the cascade rather than re-anchoring it.
|
|
374
374
|
# @return [void]
|
|
375
|
-
def
|
|
375
|
+
def handle_width_changed
|
|
376
376
|
super
|
|
377
377
|
adjust_left_column
|
|
378
378
|
end
|
|
@@ -512,11 +512,11 @@ module Tuile
|
|
|
512
512
|
# not offered to any other level.
|
|
513
513
|
# @param key [String]
|
|
514
514
|
# @return [Boolean] whether a mnemonic claimed the key.
|
|
515
|
-
def handle_mnemonic(key)
|
|
515
|
+
def handle_mnemonic?(key)
|
|
516
516
|
return false unless Keys.printable?(key)
|
|
517
517
|
|
|
518
518
|
down = key.downcase
|
|
519
|
-
return @cascade.handle_mnemonic(down) if @cascade.open?
|
|
519
|
+
return @cascade.handle_mnemonic?(down) if @cascade.open?
|
|
520
520
|
|
|
521
521
|
index = items.index { |item| item.mnemonic == down }
|
|
522
522
|
return false if index.nil?
|
|
@@ -28,20 +28,20 @@ module Tuile
|
|
|
28
28
|
# (floor {MIN_CAP_WIDTH}) and {HEIGHT_FRACTION} tall, and **grows but never
|
|
29
29
|
# shrinks** while it lives; a long message wraps to {MAX_ROWS_PER_MESSAGE}
|
|
30
30
|
# rows and is then ellipsized, and entries past the height cap wait unpainted.
|
|
31
|
-
# `
|
|
31
|
+
# `design/decisions.md` `D_notification` has why each of those is what it is.
|
|
32
32
|
#
|
|
33
33
|
# Three things it deliberately doesn't do:
|
|
34
34
|
#
|
|
35
35
|
# - **Take focus, or receive keys.** An {Overlay} sits off the
|
|
36
|
-
# key-dispatch scope ({ScreenPane#handle_key}), so no key arrives here at
|
|
37
|
-
# all. A left click *on the box* dismisses ({#
|
|
36
|
+
# key-dispatch scope ({ScreenPane#handle_key?}), so no key arrives here at
|
|
37
|
+
# all. A left click *on the box* dismisses ({#handle_mouse_down?}); an app
|
|
38
38
|
# wanting a key registers a global shortcut and calls {Overlay#close}. A
|
|
39
39
|
# click *elsewhere* does not — this is the one overlay with
|
|
40
40
|
# {Overlay#close_on_outside_click?} false, since a toast is timed and an
|
|
41
41
|
# unrelated click is not about it.
|
|
42
42
|
# - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
|
|
43
43
|
# the message is added — a toast lives seconds, so there is no
|
|
44
|
-
# {Component#
|
|
44
|
+
# {Component#handle_theme_changed} rebuild.
|
|
45
45
|
# - **Take a size.** An {Overlay} has no declared box; the messages decide
|
|
46
46
|
# this one's, in {#reposition}.
|
|
47
47
|
class Notification < Overlay
|
|
@@ -122,7 +122,7 @@ module Tuile
|
|
|
122
122
|
@messages = []
|
|
123
123
|
@high_water = 0
|
|
124
124
|
@ticker = nil
|
|
125
|
-
@view =
|
|
125
|
+
@view = View.new
|
|
126
126
|
@window = Window.new
|
|
127
127
|
@window.content = @view
|
|
128
128
|
super(content: @window, close_on_outside_click: false)
|
|
@@ -179,27 +179,41 @@ module Tuile
|
|
|
179
179
|
self.rect = Rect.new([screen.size.width - width, 0].max, 0, width, height)
|
|
180
180
|
end
|
|
181
181
|
|
|
182
|
-
# A left
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
|
|
187
|
-
# {HasContent#handle_mouse} may run, since both end at a
|
|
188
|
-
# `screen.focused = …` inside this subtree (see {Overlay#focusable?}).
|
|
189
|
-
# @param event [MouseEvent]
|
|
190
|
-
# @return [void]
|
|
191
|
-
def handle_mouse(event)
|
|
182
|
+
# A left press dismisses the whole box, every message with it. Every other
|
|
183
|
+
# press is claimed and inert, so nothing beneath the toast acts on it.
|
|
184
|
+
# @param event [Mouse::DownEvent]
|
|
185
|
+
# @return [Boolean]
|
|
186
|
+
def handle_mouse_down?(event)
|
|
192
187
|
close if event.button == :left
|
|
188
|
+
true
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Claimed and inert: a stray wheel spin over the toast must neither nuke it
|
|
192
|
+
# nor reach whatever it covers.
|
|
193
|
+
# @param _event [Mouse::ScrollEvent]
|
|
194
|
+
# @return [Boolean]
|
|
195
|
+
def handle_mouse_scroll?(_event) = true
|
|
196
|
+
|
|
197
|
+
# The message rows, minus the wheel — the one thing a plain
|
|
198
|
+
# {Component::TextView} would add here. On a short terminal the queued
|
|
199
|
+
# messages are taller than the box, and the box is unfocusable, so
|
|
200
|
+
# scrolling them would be a capability the keyboard cannot reach
|
|
201
|
+
# (`D_mouse`); they are meant to *wait* until the ticker retires the ones
|
|
202
|
+
# above.
|
|
203
|
+
class View < TextView
|
|
204
|
+
# @param _event [Mouse::ScrollEvent]
|
|
205
|
+
# @return [Boolean] true — claimed on the box's behalf, and inert.
|
|
206
|
+
def handle_mouse_scroll?(_event) = true
|
|
193
207
|
end
|
|
194
208
|
|
|
195
209
|
# @return [void]
|
|
196
|
-
def
|
|
210
|
+
def handle_attached
|
|
197
211
|
super
|
|
198
212
|
sync_ticker
|
|
199
213
|
end
|
|
200
214
|
|
|
201
215
|
# @return [void]
|
|
202
|
-
def
|
|
216
|
+
def handle_detached
|
|
203
217
|
super
|
|
204
218
|
sync_ticker
|
|
205
219
|
end
|
|
@@ -218,7 +232,7 @@ module Tuile
|
|
|
218
232
|
# Syncs the retirement clock from the invariant "something to retire, and on
|
|
219
233
|
# screen" — the sole writer of `@ticker`. Four sites change whether it is
|
|
220
234
|
# wanted (append, a retirement that empties the queue, {#close}, detach),
|
|
221
|
-
# which is the 2×2 a start-in-{#
|
|
235
|
+
# which is the 2×2 a start-in-{#handle_attached} / cancel-in-{#handle_detached} pair
|
|
222
236
|
# gets half wrong. The early return is also what keeps an append from
|
|
223
237
|
# *restarting* the clock and extending the oldest message's life.
|
|
224
238
|
# @return [void]
|
|
@@ -23,7 +23,7 @@ module Tuile
|
|
|
23
23
|
# **{#focusable?} and {#modal?} move together — flip both or neither.** The
|
|
24
24
|
# defaults here are inert (`false`, `false`): a bare overlay floats without
|
|
25
25
|
# disturbing focus or key dispatch. {Component::Popup} flips both. What must
|
|
26
|
-
# not appear is a *focusable non-modal* overlay: {ScreenPane#handle_key}
|
|
26
|
+
# not appear is a *focusable non-modal* overlay: {ScreenPane#handle_key?}
|
|
27
27
|
# scopes delivery to the topmost modal popup or else the tiled content, so
|
|
28
28
|
# such an overlay would hold focus outside the key scope, where delivery
|
|
29
29
|
# reaches nobody and *every* keystroke goes dead until Tab recovers. A
|
|
@@ -67,10 +67,27 @@ module Tuile
|
|
|
67
67
|
# (its content may still carry stops).
|
|
68
68
|
def tab_stop? = false
|
|
69
69
|
|
|
70
|
+
# Refused: an overlay is **dismissed, not hidden**. Use {#close} and
|
|
71
|
+
# {#open} — which already keep it alive between showings, the one thing
|
|
72
|
+
# hiding would buy — or hide the *content* if only a piece of it comes
|
|
73
|
+
# and goes.
|
|
74
|
+
#
|
|
75
|
+
# The pane resolves clicks and key scope from its popup list, which does
|
|
76
|
+
# not consult this flag, so a hidden overlay would paint nothing while
|
|
77
|
+
# still swallowing every click and, if modal, scoping every key: the
|
|
78
|
+
# invisible-modal trap the class docs warn about for {#focusable?} and
|
|
79
|
+
# {#modal?}.
|
|
80
|
+
# @param _value [Boolean]
|
|
81
|
+
# @raise [Tuile::Error] always.
|
|
82
|
+
# @return [void]
|
|
83
|
+
def visible=(_value)
|
|
84
|
+
raise Tuile::Error, "#{self.class} cannot be hidden — close it instead (Overlay#close)"
|
|
85
|
+
end
|
|
86
|
+
|
|
70
87
|
# Whether a left click outside this overlay closes it (default true). The
|
|
71
|
-
# pane does the closing — {ScreenPane#
|
|
72
|
-
# overlays *before*
|
|
73
|
-
# *after*, so a widget that toggles its own overlay from a click on its
|
|
88
|
+
# pane does the closing — {ScreenPane#dismissing_popups_outside} snapshots
|
|
89
|
+
# the open overlays *before* the press is routed and closes the
|
|
90
|
+
# dismissable ones *after*, so a widget that toggles its own overlay from a click on its
|
|
74
91
|
# face (a {Component::Select}, a {Component::MenuBar} title) still toggles
|
|
75
92
|
# correctly: the delivered click closes the overlay and the dismissal then
|
|
76
93
|
# no-ops on it, rather than closing and reopening it. Only `:left`
|
|
@@ -113,12 +130,12 @@ module Tuile
|
|
|
113
130
|
# A callback taking no arguments, fired once this overlay has left the
|
|
114
131
|
# screen — **however it left**: {#close}, a direct {Screen#remove_popup},
|
|
115
132
|
# an outside click, or teardown via {Screen#close}. That unconditionality
|
|
116
|
-
# is the point, so it hangs off {#
|
|
133
|
+
# is the point, so it hangs off {#handle_detached} rather than {#close}; a
|
|
117
134
|
# driver keeping its own record of open overlays reconciles it here and
|
|
118
135
|
# cannot drift ({Component::MenuBar::Cascade} is the worked example).
|
|
119
136
|
#
|
|
120
137
|
# It fires *after* the overlay is detached, so {#open?} is already false
|
|
121
|
-
# and the usual {Component#
|
|
138
|
+
# and the usual {Component#handle_detached} caveats apply: release state, don't
|
|
122
139
|
# inspect the tree, keep it trivial (it may run while the pane is mid-way
|
|
123
140
|
# through closing a batch of overlays, and a raise propagates).
|
|
124
141
|
# @return [Proc, nil]
|
|
@@ -144,7 +161,7 @@ module Tuile
|
|
|
144
161
|
# overlay = Component::Overlay.new(content: label).open # construct and mount
|
|
145
162
|
#
|
|
146
163
|
# There is deliberately no class-level `Overlay.open` factory — see
|
|
147
|
-
# `
|
|
164
|
+
# `design/decisions.md` `D_popup_open`; returning `self` is what keeps the
|
|
148
165
|
# one-liner above available without one.
|
|
149
166
|
# @return [self]
|
|
150
167
|
def open
|
|
@@ -174,7 +191,8 @@ module Tuile
|
|
|
174
191
|
# Fires {#on_close}. A subclass overriding this **must** call `super`, or
|
|
175
192
|
# the overlay's driver never hears that it closed.
|
|
176
193
|
# @return [void]
|
|
177
|
-
def
|
|
194
|
+
def handle_detached
|
|
195
|
+
super
|
|
178
196
|
@on_close&.call
|
|
179
197
|
end
|
|
180
198
|
|
|
@@ -3,11 +3,26 @@
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
5
|
# A {Window} that lists options identified by single keyboard keys, asks
|
|
6
|
-
# the user to pick one, and fires a callback with the picked key.
|
|
6
|
+
# the user to pick one, and fires a callback with the picked key. Each row
|
|
7
|
+
# is `"<key> <caption>"`:
|
|
8
|
+
#
|
|
9
|
+
# PickerWindow.open("Sort by", [%w[n name], %w[s size]]) { sort_by(_1) }
|
|
10
|
+
#
|
|
11
|
+
# ┌Sort by───────┐
|
|
12
|
+
# │n name │
|
|
13
|
+
# │s size │
|
|
14
|
+
# └──────────────┘
|
|
7
15
|
#
|
|
8
16
|
# Usable tiled (just add to a {Layout} and read picks via the block) or
|
|
9
17
|
# as a popup via {.open}, which wraps it in a {Popup} that closes itself
|
|
10
18
|
# after a pick. ESC / `q` close without firing the callback.
|
|
19
|
+
#
|
|
20
|
+
# Captions paint in the terminal's own foreground. To color them, hand in
|
|
21
|
+
# {StyledString} captions — the picker styles nothing itself, so an app's
|
|
22
|
+
# own token applies per option:
|
|
23
|
+
#
|
|
24
|
+
# PickerWindow.open("File", [["o", StyledString.plain("Open")],
|
|
25
|
+
# ["d", theme.fg(:danger, "Delete")]]) { … }
|
|
11
26
|
class PickerWindow < Window
|
|
12
27
|
# Scrolls the window when more items.
|
|
13
28
|
# @return [Integer]
|
|
@@ -18,26 +33,30 @@ module Tuile
|
|
|
18
33
|
# @!attribute [r] key
|
|
19
34
|
# @return [String] the keyboard key that picks this option.
|
|
20
35
|
# @!attribute [r] caption
|
|
21
|
-
# @return [
|
|
36
|
+
# @return [StyledString] the option caption, coerced from whatever the
|
|
37
|
+
# constructor was handed.
|
|
22
38
|
class Option < Data.define(:key, :caption)
|
|
23
39
|
end
|
|
24
40
|
|
|
25
41
|
# @param caption [String] the window caption.
|
|
26
|
-
# @param options [Array<Array(String,
|
|
27
|
-
# option caption.
|
|
42
|
+
# @param options [Array<Array(String, StyledString)>] pairs of
|
|
43
|
+
# keyboard key and option caption. A caption goes through
|
|
44
|
+
# {StyledString.parse}, so a plain String, an ANSI-coded one (what
|
|
45
|
+
# {Theme#fg} returns) and a {StyledString} are all accepted.
|
|
28
46
|
# @yield [key] called with the option key once one is selected by the
|
|
29
47
|
# user. Not called if the picker is dismissed without picking.
|
|
30
48
|
# @yieldparam key [String] the picked option key.
|
|
31
49
|
# @yieldreturn [void]
|
|
50
|
+
# @raise [ArgumentError] when `block` is missing or `options` is empty.
|
|
32
51
|
def initialize(caption, options, &block)
|
|
33
52
|
raise ArgumentError, "block required" unless block
|
|
34
53
|
raise ArgumentError, "options must not be empty" if options.empty?
|
|
35
54
|
|
|
36
55
|
super(caption)
|
|
37
|
-
@options = options.map { Option.new(_1[0], _1[1]) }
|
|
56
|
+
@options = options.map { Option.new(_1[0], StyledString.parse(_1[1])) }
|
|
38
57
|
@block = block
|
|
39
58
|
list = Component::List.new
|
|
40
|
-
list.renderer = ->(option) { "#{option.key}
|
|
59
|
+
list.renderer = ->(option) { StyledString.plain("#{option.key} ") + option.caption }
|
|
41
60
|
list.items = @options
|
|
42
61
|
list.cursor = Component::List::Cursor.new
|
|
43
62
|
list.on_item_chosen = ->(_index, option) { select_option(option.key) }
|
|
@@ -57,7 +76,7 @@ module Tuile
|
|
|
57
76
|
# option's `key` picks that option.
|
|
58
77
|
# @param key [String]
|
|
59
78
|
# @return [Boolean]
|
|
60
|
-
def handle_key(key)
|
|
79
|
+
def handle_key?(key)
|
|
61
80
|
if @options.any? { _1.key == key }
|
|
62
81
|
select_option(key)
|
|
63
82
|
true
|
|
@@ -69,7 +88,7 @@ module Tuile
|
|
|
69
88
|
# Opens a picker as a popup. Picking an option fires `block`, then
|
|
70
89
|
# closes the popup; ESC / `q` close without firing `block`.
|
|
71
90
|
# @param caption [String]
|
|
72
|
-
# @param options [Array<Array(String,
|
|
91
|
+
# @param options [Array<Array(String, StyledString)>]
|
|
73
92
|
# @yield [key]
|
|
74
93
|
# @yieldparam key [String]
|
|
75
94
|
# @yieldreturn [void]
|
|
@@ -28,7 +28,7 @@ module Tuile
|
|
|
28
28
|
#
|
|
29
29
|
# `q` and ESC close the popup — handled here, at the top of the popup's own
|
|
30
30
|
# subtree, so the key only arrives after every component on the focus chain
|
|
31
|
-
# declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
|
|
31
|
+
# declined it (see {ScreenPane#handle_key?}). That's why typing `q` into a
|
|
32
32
|
# nested {Component::TextField} doesn't dismiss the popup: the field consumes
|
|
33
33
|
# it first.
|
|
34
34
|
#
|
|
@@ -107,7 +107,7 @@ module Tuile
|
|
|
107
107
|
# focused content after that content declined to handle it.
|
|
108
108
|
# @param key [String]
|
|
109
109
|
# @return [Boolean] true if the key was handled.
|
|
110
|
-
def handle_key(key)
|
|
110
|
+
def handle_key?(key)
|
|
111
111
|
if [Keys::ESC, "q"].include?(key)
|
|
112
112
|
close
|
|
113
113
|
true
|