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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -49
  3. data/README.md +53 -17
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +152 -22
  6. data/book/06-theming.md +105 -25
  7. data/book/07-components.md +531 -50
  8. data/book/08-testing.md +100 -20
  9. data/book/10-locale.md +216 -0
  10. data/book/README.md +19 -9
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +654 -40
  14. data/lib/tuile/component/abstract_string_field.rb +114 -68
  15. data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
  16. data/lib/tuile/component/big_decimal_field.rb +52 -79
  17. data/lib/tuile/component/button.rb +8 -8
  18. data/lib/tuile/component/checkbox.rb +9 -9
  19. data/lib/tuile/component/checkbox_group.rb +38 -21
  20. data/lib/tuile/component/combo_box.rb +102 -59
  21. data/lib/tuile/component/confirm_window.rb +7 -5
  22. data/lib/tuile/component/date_field.rb +347 -0
  23. data/lib/tuile/component/date_time_field.rb +275 -0
  24. data/lib/tuile/component/float_field.rb +57 -82
  25. data/lib/tuile/component/has_bad_input.rb +88 -0
  26. data/lib/tuile/component/has_caption.rb +8 -0
  27. data/lib/tuile/component/has_content.rb +32 -13
  28. data/lib/tuile/component/has_placeholder.rb +62 -0
  29. data/lib/tuile/component/has_validation.rb +115 -0
  30. data/lib/tuile/component/has_value.rb +28 -1
  31. data/lib/tuile/component/integer_field.rb +51 -78
  32. data/lib/tuile/component/label.rb +7 -39
  33. data/lib/tuile/component/layout/box.rb +90 -19
  34. data/lib/tuile/component/layout.rb +15 -5
  35. data/lib/tuile/component/list.rb +53 -38
  36. data/lib/tuile/component/list_dropdown.rb +7 -3
  37. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  38. data/lib/tuile/component/menu_bar.rb +18 -18
  39. data/lib/tuile/component/notification.rb +32 -18
  40. data/lib/tuile/component/overlay.rb +26 -8
  41. data/lib/tuile/component/picker_window.rb +27 -8
  42. data/lib/tuile/component/popup.rb +2 -2
  43. data/lib/tuile/component/progress_bar.rb +10 -4
  44. data/lib/tuile/component/radio_group.rb +41 -23
  45. data/lib/tuile/component/select.rb +23 -16
  46. data/lib/tuile/component/slot.rb +3 -3
  47. data/lib/tuile/component/tab_sheet.rb +6 -6
  48. data/lib/tuile/component/tabs.rb +11 -11
  49. data/lib/tuile/component/text_area.rb +26 -18
  50. data/lib/tuile/component/text_field.rb +55 -26
  51. data/lib/tuile/component/text_view.rb +40 -19
  52. data/lib/tuile/component/time_field.rb +479 -0
  53. data/lib/tuile/component/window.rb +26 -13
  54. data/lib/tuile/component.rb +635 -131
  55. data/lib/tuile/event_queue.rb +4 -4
  56. data/lib/tuile/fake_event_queue.rb +1 -1
  57. data/lib/tuile/fake_screen.rb +95 -3
  58. data/lib/tuile/final.rb +75 -0
  59. data/lib/tuile/locale.rb +851 -0
  60. data/lib/tuile/mouse/router.rb +217 -0
  61. data/lib/tuile/mouse.rb +177 -0
  62. data/lib/tuile/screen.rb +219 -68
  63. data/lib/tuile/screen_pane.rb +51 -42
  64. data/lib/tuile/styled_string.rb +5 -5
  65. data/lib/tuile/testing.rb +198 -0
  66. data/lib/tuile/theme.rb +110 -32
  67. data/lib/tuile/version.rb +1 -1
  68. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  69. data/lib/tuile.rb +1 -0
  70. data/sig/tuile.rbs +4595 -825
  71. metadata +14 -9
  72. data/COMPARISON.md +0 -101
  73. data/DECISIONS.md +0 -5422
  74. data/TERMINOLOGY.md +0 -71
  75. data/ideas/arrow-key-navigation.md +0 -221
  76. data/ideas/modal-backdrop.md +0 -24
  77. data/ideas/new-components.md +0 -124
  78. data/ideas/per-component-buffers.md +0 -55
  79. data/lib/tuile/mouse_event.rb +0 -68
@@ -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.hint(" #{p.email}") }
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
- # @param event [MouseEvent]
320
- # @return [void]
321
- def handle_mouse(event)
322
- super
323
- if event.button == :scroll_down
324
- move_scroll_top_row_by(4)
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
- item_index = event.y - rect.top + scroll_top_row
331
- if @cursor.handle_mouse(item_index, event, @items.size)
332
- move_viewport_to_cursor
333
- notify_cursor_changed
334
- invalidate
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 [MouseEvent]
397
+ # @param _event [Mouse::DownEvent]
388
398
  # @param _item_count [Integer]
389
399
  # @return [Boolean]
390
- def handle_mouse(_item_index, _event, _item_count)
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 the cursor is hovering over.
445
- # @param event [MouseEvent] the 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 event was handled.
448
- def handle_mouse(item_index, event, item_count)
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 [MouseEvent]
520
+ # @param event [Mouse::DownEvent]
511
521
  # @param _item_count [Integer]
512
522
  # @return [Boolean]
513
- def handle_mouse(item_index, event, _item_count)
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 gutter, both of
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 on_width_changed
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. {#on_width_changed} re-runs this hook when the rect grows so
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 gutter, when visible). `0` when {#rect}'s width
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 gutter
813
- # excluded). Rows wider than the content area are ellipsized via
814
- # {StyledString#ellipsize} (span styles survive the cut); shorter
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
- styled += StyledString.plain(scrollbar.scrollbar_char(row_in_viewport)) if scrollbar
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
- # `DECISIONS.md` `D_select` on why the width policy stays with the
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, first
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 (`DECISIONS.md` `D_select`).
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 `DECISIONS.md`
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*: {Component#handle_mouse}'s click-to-focus is
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 on_detached
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 click, or closes it when it is already the
314
- # open one; `super` runs first, so a click anywhere in {#rect} still
315
- # focuses.
316
- # @param event [MouseEvent]
317
- # @return [void]
318
- def handle_mouse(event)
319
- super
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 on_width_changed
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
- # `DECISIONS.md` `D_notification` has why each of those is what it is.
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 ({#handle_mouse}); an app
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#on_theme_changed} rebuild.
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 = TextView.new
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 click dismisses the whole box, every message with it. Other buttons
183
- # are consumed and inert — including the scroll wheel, which would otherwise
184
- # nuke the box on a stray spin.
185
- #
186
- # Deliberately *replaces* rather than augments: neither `super` nor
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 on_attached
210
+ def handle_attached
197
211
  super
198
212
  sync_ticker
199
213
  end
200
214
 
201
215
  # @return [void]
202
- def on_detached
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-{#on_attached} / cancel-in-{#on_detached} pair
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#handle_mouse} snapshots the open
72
- # overlays *before* routing the click and closes the dismissable ones
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 {#on_detached} rather than {#close}; a
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#on_detached} caveats apply: release state, don't
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
- # `DECISIONS.md` `D_popup_open`; returning `self` is what keeps the
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 on_detached
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 [String] the option caption.
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, String)>] pairs of keyboard key and
27
- # option caption. No Rainbow formatting must be used.
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} #{screen.theme.hint(option.caption)}" }
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, 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