tuile 0.16.0 → 0.17.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +21 -12
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +5 -4
  7. data/book/05-focus.md +12 -9
  8. data/book/06-theming.md +55 -17
  9. data/book/07-components.md +188 -40
  10. data/book/08-testing.md +115 -15
  11. data/book/10-locale.md +1 -1
  12. data/book/README.md +5 -5
  13. data/examples/file_commander.rb +38 -27
  14. data/examples/hello_world.rb +1 -1
  15. data/examples/sampler.rb +225 -169
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +81 -80
  21. data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +19 -11
  24. data/lib/tuile/component/checkbox.rb +12 -10
  25. data/lib/tuile/component/checkbox_group.rb +11 -13
  26. data/lib/tuile/component/combo_box.rb +30 -40
  27. data/lib/tuile/component/confirm_window.rb +27 -22
  28. data/lib/tuile/component/date_field.rb +27 -20
  29. data/lib/tuile/component/date_time_field.rb +75 -31
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +98 -27
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +5 -12
  37. data/lib/tuile/component/has_validation.rb +39 -13
  38. data/lib/tuile/component/has_value.rb +70 -16
  39. data/lib/tuile/component/integer_field.rb +7 -6
  40. data/lib/tuile/component/label.rb +8 -15
  41. data/lib/tuile/component/layout/absolute.rb +86 -0
  42. data/lib/tuile/component/layout/box.rb +38 -63
  43. data/lib/tuile/component/layout.rb +124 -10
  44. data/lib/tuile/component/list.rb +197 -94
  45. data/lib/tuile/component/list_dropdown.rb +148 -88
  46. data/lib/tuile/component/menu_bar/cascade.rb +97 -27
  47. data/lib/tuile/component/menu_bar.rb +84 -64
  48. data/lib/tuile/component/notification.rb +44 -31
  49. data/lib/tuile/component/overlay.rb +210 -52
  50. data/lib/tuile/component/password_field.rb +1 -8
  51. data/lib/tuile/component/picker_window.rb +15 -10
  52. data/lib/tuile/component/popup.rb +13 -24
  53. data/lib/tuile/component/progress_bar.rb +7 -7
  54. data/lib/tuile/component/radio_group.rb +10 -12
  55. data/lib/tuile/component/scroller.rb +266 -0
  56. data/lib/tuile/component/select.rb +15 -31
  57. data/lib/tuile/component/slot.rb +1 -2
  58. data/lib/tuile/component/tab_sheet.rb +21 -28
  59. data/lib/tuile/component/tabs.rb +39 -24
  60. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  61. data/lib/tuile/component/text_area.rb +21 -19
  62. data/lib/tuile/component/text_field.rb +55 -39
  63. data/lib/tuile/component/text_view.rb +143 -79
  64. data/lib/tuile/component/time_field.rb +26 -21
  65. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  66. data/lib/tuile/component/window.rb +27 -26
  67. data/lib/tuile/component.rb +481 -259
  68. data/lib/tuile/component_background.rb +177 -0
  69. data/lib/tuile/component_util.rb +43 -0
  70. data/lib/tuile/event.rb +29 -0
  71. data/lib/tuile/event_queue.rb +14 -0
  72. data/lib/tuile/fake_screen.rb +41 -10
  73. data/lib/tuile/keys.rb +15 -6
  74. data/lib/tuile/layout_pass.rb +180 -0
  75. data/lib/tuile/listeners.rb +219 -0
  76. data/lib/tuile/mouse/router.rb +51 -35
  77. data/lib/tuile/mouse.rb +96 -29
  78. data/lib/tuile/point.rb +6 -0
  79. data/lib/tuile/rect.rb +33 -0
  80. data/lib/tuile/screen.rb +419 -84
  81. data/lib/tuile/screen_pane.rb +144 -31
  82. data/lib/tuile/strict_layout.rb +127 -0
  83. data/lib/tuile/styled_string.rb +139 -9
  84. data/lib/tuile/testing/gestures.rb +35 -0
  85. data/lib/tuile/testing.rb +310 -36
  86. data/lib/tuile/theme.rb +170 -19
  87. data/lib/tuile/theme_def.rb +4 -0
  88. data/lib/tuile/version.rb +1 -1
  89. data/lib/tuile.rb +53 -0
  90. data/sig/tuile.rbs +4951 -1158
  91. metadata +16 -2
  92. data/lib/tuile/vertical_scroll_bar.rb +0 -122
@@ -22,7 +22,16 @@ module Tuile
22
22
  # the highlight, Enter or Space activates a row or opens its submenu, RIGHT
23
23
  # opens a submenu, LEFT returns to the previous menu, ESC closes one level.
24
24
  # LEFT at the first level and RIGHT on a row with no submenu step to the
25
- # sibling menu, as they do in every menu bar. Book ch7 has the table.
25
+ # sibling menu, as they do in every menu bar. A menu stepped to that way is
26
+ # only *shown*: it opens with no row highlighted, so RIGHT steps on again,
27
+ # and Down, Enter or Space moves onto its first row, Up onto its last. Book
28
+ # ch7 has the table.
29
+ #
30
+ # The walk is governed by *menu mode*, not by whether a panel is up: a
31
+ # top-level item with no menu shows nothing when you step onto it, and the
32
+ # step after it still opens the next menu. ESC leaves the mode there — the
33
+ # one place the strip claims ESC, an unclaimed one being what stops the loop
34
+ # (`D_quit_key`). Enter on such an item fires it and enters no mode.
26
35
  #
27
36
  # == Mnemonics
28
37
  # An item given a `mnemonic:` answers to that letter, underlined in its
@@ -93,6 +102,14 @@ module Tuile
93
102
  #
94
103
  # Apps don't construct items; {MenuBar#add_item} and {#add_item} do.
95
104
  class Item
105
+ extend Listeners::Declare
106
+
107
+ # What {#on_click} fires.
108
+ #
109
+ # @!attribute [r] source
110
+ # @return [Item] the item that was activated.
111
+ ClickEvent = Data.define(:source) { include Tuile::Event }
112
+
96
113
  # @param caption [StyledString] already coerced by the caller.
97
114
  # @param mnemonic [String, nil] already validated by the caller, in the
98
115
  # case it was given in.
@@ -101,7 +118,7 @@ module Tuile
101
118
  @caption = caption
102
119
  @mnemonic = mnemonic&.downcase
103
120
  @cued_caption = build_cued_caption(caption, mnemonic)
104
- @on_click = on_click
121
+ self.on_click << on_click if on_click
105
122
  @items = []
106
123
  end
107
124
 
@@ -126,10 +143,12 @@ module Tuile
126
143
  # convention, like {Component#children} — grow it through {#add_item}.
127
144
  attr_reader :items
128
145
 
129
- # @return [Proc, Method, nil] no-arg callable fired when the item is
130
- # activated (Enter, Space or a left click), exactly as
131
- # {Button#on_click}. Never fired on an item with children.
132
- attr_accessor :on_click
146
+ # @!method on_click
147
+ # Fired with a {ClickEvent} when the item is activated (Enter, Space or
148
+ # a left click), exactly as {Button#on_click}. Never fired on an item
149
+ # with children.
150
+ # @return [Listeners]
151
+ listener :on_click
133
152
 
134
153
  # @return [Boolean] whether this item opens a submenu, i.e. has children.
135
154
  def submenu? = !@items.empty?
@@ -140,7 +159,7 @@ module Tuile
140
159
  # @param mnemonic [String, nil] the letter that activates this child
141
160
  # while *this* item's children are the live level; see
142
161
  # {MenuBar#add_item}.
143
- # @yield optional `on_click` callback; same as assigning {#on_click=}.
162
+ # @yield optional `on_click` listener; same as registering one on {#on_click}.
144
163
  # @raise [ArgumentError] see {MenuBar#add_item}.
145
164
  # @return [Item]
146
165
  def add_item(caption = nil, mnemonic: nil, &on_click)
@@ -251,14 +270,14 @@ module Tuile
251
270
  Size.new([painted_width - @left_column, rect.width].min, 1)
252
271
  end
253
272
 
254
- # @param new_rect [Rect]
273
+ # Closes the cascade — only on a *changed* rect, which is all this hook
274
+ # hears: a layout re-assigning the same rect (which {Layout::Box} does on
275
+ # any child mutation) must not.
276
+ # @param old_rect [Rect]
255
277
  # @return [void]
256
- def rect=(new_rect)
257
- # Only a *changed* rect closes the menu: a layout re-assigning the same
258
- # rect (which {Layout::Box} does on any child mutation) must not.
259
- changed = rect != new_rect
278
+ def handle_rect_changed(old_rect)
260
279
  super
261
- @cascade.close if changed
280
+ @cascade.close
262
281
  end
263
282
 
264
283
  # Closes the cascade when the strip leaves the focus chain, so tabbing (or
@@ -281,11 +300,11 @@ module Tuile
281
300
  end
282
301
 
283
302
  # Offers the key to the open cascade first, then to the strip's own
284
- # LEFT/RIGHT/Enter/Space/Down.
303
+ # LEFT/RIGHT/Enter/Space/Down/ESC.
285
304
  #
286
305
  # With a cascade open, the only keys reaching the strip are the two the
287
- # cascade declines — LEFT at the first level, RIGHT on a row with no
288
- # submenu — and both step to the sibling menu.
306
+ # cascade declines — LEFT at the first level, RIGHT with no submenu under
307
+ # the highlight — and both step to the sibling menu.
289
308
  # @param key [String]
290
309
  # @return [Boolean]
291
310
  def handle_key?(key)
@@ -294,19 +313,12 @@ module Tuile
294
313
  return true if handle_mnemonic?(key)
295
314
  return true if @cascade.handle_key?(key)
296
315
 
297
- if @cascade.open?
298
- case key
299
- when Keys::LEFT_ARROW then step_menu(-1)
300
- when Keys::RIGHT_ARROW then step_menu(1)
301
- else false
302
- end
303
- else
304
- case key
305
- when Keys::LEFT_ARROW then move_highlight(-1)
306
- when Keys::RIGHT_ARROW then move_highlight(1)
307
- when Keys::ENTER, " ", Keys::DOWN_ARROW then open_highlighted
308
- else false
309
- end
316
+ case key
317
+ when Keys::LEFT_ARROW then @cascade.browsing? ? step_menu(-1) : move_highlight(-1)
318
+ when Keys::RIGHT_ARROW then @cascade.browsing? ? step_menu(1) : move_highlight(1)
319
+ when Keys::ENTER, " ", Keys::DOWN_ARROW then open_highlighted
320
+ when Keys::ESC then leave_menus
321
+ else false
310
322
  end
311
323
  end
312
324
 
@@ -330,14 +342,15 @@ module Tuile
330
342
  true
331
343
  end
332
344
 
345
+ # @param canvas [Canvas] see {Component#repaint}.
333
346
  # @return [void]
334
- def repaint
347
+ def repaint(canvas)
335
348
  super
336
349
  return if rect.empty?
337
350
 
338
351
  row = strip_row.slice(@left_column, rect.width)
339
- draw_text(rect.left, rect.top, row)
340
- draw_cues(row)
352
+ canvas.set_text(0, 0, row)
353
+ draw_cues(canvas, row)
341
354
  end
342
355
 
343
356
  private
@@ -368,11 +381,10 @@ module Tuile
368
381
  invalidate
369
382
  end
370
383
 
371
- # The rect's *width* is the only part of it the offset depends on, so this
372
- # hook is the whole geometry story; {Component#rect=} invalidates for us,
373
- # and {#rect=} closes the cascade rather than re-anchoring it.
384
+ # The offset follows the width; {Component#rect=} invalidates for us,
385
+ # and {#handle_rect_changed} closes the cascade rather than re-anchoring it.
374
386
  # @return [void]
375
- def handle_width_changed
387
+ def relayout
376
388
  super
377
389
  adjust_left_column
378
390
  end
@@ -427,23 +439,25 @@ module Tuile
427
439
  # make the window width a function of the offset computed from it. Painted
428
440
  # focused or not: overflow is a fact about the captions and the rect, not
429
441
  # about focus.
442
+ # @param canvas [Canvas] the surface to paint onto.
430
443
  # @param row [StyledString] the windowed row, as painted.
431
444
  # @return [void]
432
- def draw_cues(row)
433
- draw_cue(row, 0, "<") if @left_column.positive?
434
- draw_cue(row, rect.width - 1, ">") if @left_column + rect.width < painted_width
445
+ def draw_cues(canvas, row)
446
+ draw_cue(canvas, row, 0, "<") if @left_column.positive?
447
+ draw_cue(canvas, row, rect.width - 1, ">") if @left_column + rect.width < painted_width
435
448
  end
436
449
 
437
450
  # The cue keeps the style of the cell it covers, so one landing on the
438
451
  # highlighted segment doesn't punch a default-background hole in its
439
452
  # highlight.
453
+ # @param canvas [Canvas] the surface to paint onto.
440
454
  # @param row [StyledString] the windowed row.
441
455
  # @param column [Integer] relative to {#rect}`.left`.
442
456
  # @param glyph [String]
443
457
  # @return [void]
444
- def draw_cue(row, column, glyph)
458
+ def draw_cue(canvas, row, column, glyph)
445
459
  style = row.slice(column, 1).spans.first&.style || StyledString::Style::DEFAULT
446
- draw_char(rect.left + column, rect.top, glyph, style)
460
+ canvas.set_char(column, 0, glyph, style)
447
461
  end
448
462
 
449
463
  # One `[item, start_column, width]` triple per top-level item, in strip
@@ -470,17 +484,19 @@ module Tuile
470
484
  # @return [Integer, nil] the index of the item painted at `point`; `nil`
471
485
  # for the blank tail or a row the strip doesn't paint.
472
486
  def index_at(point)
473
- return nil unless extent_rect.contains?(point)
487
+ return nil unless local_extent_rect.contains?(point)
474
488
 
475
- column = point.x - rect.left + @left_column
489
+ column = point.x + @left_column
476
490
  segments.index { |_item, start, width| column >= start && column < start + width }
477
491
  end
478
492
 
493
+ # The cascade hangs off {ScreenPane}, so it shares no offset with this
494
+ # strip and has to be given screen coordinates (`D_relative_rect`).
479
495
  # @param index [Integer]
480
496
  # @return [Rect] the segment's cells on screen — the cascade's anchor.
481
497
  def segment_rect(index)
482
498
  _item, start, width = segments[index]
483
- Rect.new(rect.left + start - @left_column, rect.top, width, 1)
499
+ Rect.new(0, 0, width, 1).at(to_screen(Point.new(start - @left_column, 0)))
484
500
  end
485
501
 
486
502
  # @return [StyledString] the whole strip as one row, unclipped. {#repaint}
@@ -536,21 +552,33 @@ module Tuile
536
552
  true
537
553
  end
538
554
 
539
- # Steps to the neighbouring menu, showing *its* menu instead — or closing
540
- # the cascade, when the neighbour is a top-level button with no menu to
541
- # show. The cascade is left alone when the highlight is already at an end:
542
- # reopening the same menu would throw away the submenu the user is standing
543
- # in.
555
+ # Steps to the neighbour and shows *its* menu; a top-level button shows
556
+ # nothing and keeps menu mode. The cascade is left alone when the
557
+ # highlight is already at an end: reopening the same menu would throw away
558
+ # the submenu the user is standing in.
544
559
  #
545
560
  # It deliberately never *activates*. An item arrowed past is highlighted,
546
561
  # not pressed, so a top-level button waits for Enter or Space — otherwise
547
562
  # walking the strip would fire every button on it.
548
563
  # @param delta [Integer] `+1` / `-1`.
549
- # @return [Boolean] always `true`: an open menu swallows the key either way.
564
+ # @return [Boolean] always `true`: menu mode swallows the key either way.
550
565
  def step_menu(delta)
551
566
  was = @highlighted_index
552
567
  move_highlight(delta)
553
- show_highlighted_menu unless @highlighted_index == was
568
+ return true if @highlighted_index == was
569
+
570
+ @cascade.step_to(segment_rect(@highlighted_index), items[@highlighted_index])
571
+ true
572
+ end
573
+
574
+ # Leaves menu mode — the ESC that gets you out where stepping has landed
575
+ # on a top-level item with no menu, so there is no panel left to close.
576
+ # Declined otherwise, so ESC keeps its app-level meaning (`D_quit_key`).
577
+ # @return [Boolean]
578
+ def leave_menus
579
+ return false unless @cascade.browsing?
580
+
581
+ @cascade.close
554
582
  true
555
583
  end
556
584
 
@@ -562,20 +590,12 @@ module Tuile
562
590
  return false if items.empty?
563
591
 
564
592
  item = items[@highlighted_index]
565
- show_highlighted_menu
566
- # Fired after the close above, exactly as {Cascade} activates a leaf: an
567
- # action that opens a dialog must not paint it under a menu.
568
- item.on_click&.call unless item.submenu?
569
- true
570
- end
571
-
572
- # Shows the highlighted item's menu, closing the cascade when it has none.
573
- # @return [void]
574
- def show_highlighted_menu
575
- item = items[@highlighted_index]
576
- return @cascade.close unless item.submenu?
577
-
578
593
  @cascade.open_below(segment_rect(@highlighted_index), item)
594
+ # Fired after the close inside `open_below`, exactly as {Cascade}
595
+ # activates a leaf: an action that opens a dialog must not paint it
596
+ # under a menu.
597
+ item.on_click.fire(Item::ClickEvent.new(source: item)) unless item.submenu?
598
+ true
579
599
  end
580
600
  end
581
601
  end
@@ -42,8 +42,7 @@ module Tuile
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
44
  # {Component#handle_theme_changed} rebuild.
45
- # - **Take a size.** An {Overlay} has no declared box; the messages decide
46
- # this one's, in {#reposition}.
45
+ # - **Take a size.** The messages decide the box, in {#declared_size_in}.
47
46
  class Notification < Overlay
48
47
  # Most messages held at once, counting both the painted ones and any
49
48
  # waiting for room. Chosen from reading time rather than geometry: the
@@ -117,8 +116,6 @@ module Tuile
117
116
  private_class_method :new
118
117
 
119
118
  def initialize
120
- # Built before `super`, because Overlay#initialize assigns the content
121
- # and our #reposition override reads every one of these.
122
119
  @messages = []
123
120
  @high_water = 0
124
121
  @ticker = nil
@@ -152,31 +149,22 @@ module Tuile
152
149
 
153
150
  @messages << message
154
151
  @high_water = [@high_water, natural_width(message)].max
155
- reposition
152
+ restack
156
153
  sync_ticker
157
154
  end
158
155
 
159
- # Recomputes the box from its messages and re-anchors it to the screen's
160
- # top-right corner — so a SIGWINCH re-wraps and re-anchors, where
161
- # {Overlay#reposition} would have kept the stale left column of a *derived*
162
- # position (off-screen entirely if the terminal narrowed).
163
- #
164
- # Rebuilds the {TextView}'s text too, and every mutation routes through
165
- # here, because the four are one computation: the wrap width *is* the box
166
- # width, the height *is* the wrapped row count, the left edge *is* derived
167
- # from the width.
168
- # @return [void]
169
- def reposition
170
- if @messages.empty?
171
- self.rect = Rect.new(0, 0, 0, 0)
172
- return
173
- end
156
+ # @return [Overlay::TopRight] a notification opens in the corner.
157
+ def default_placement = TopRight[]
158
+
159
+ # The box its messages need: as wide as the widest message held so far
160
+ # and as tall as they wrap to at that width, each capped by the screen.
161
+ # @param screen_size [Size]
162
+ # @return [Size]
163
+ def declared_size_in(screen_size)
164
+ return Size.new(0, 0) if @messages.empty?
174
165
 
175
- width = box_width
176
- rows = @messages.flat_map { |message| wrap_message(message, width - 2) }
177
- height = [rows.size + 2, cap_height].min
178
- @view.text = join_rows(rows)
179
- self.rect = Rect.new([screen.size.width - width, 0].max, 0, width, height)
166
+ width = box_width(screen_size)
167
+ Size.new(width, [wrapped_rows(width).size + 2, cap_height(screen_size)].min)
180
168
  end
181
169
 
182
170
  # A left press dismisses the whole box, every message with it. Every other
@@ -218,14 +206,36 @@ module Tuile
218
206
  sync_ticker
219
207
  end
220
208
 
209
+ protected
210
+
211
+ # Wraps the messages to the width the pane gave the box — the wrap the
212
+ # height in {#declared_size_in} was measured with.
213
+ # @return [void]
214
+ def relayout
215
+ super
216
+ @view.text = join_rows(wrapped_rows(rect.width)) if rect.width > 2
217
+ end
218
+
221
219
  private
222
220
 
221
+ # A message came or went: the text is re-wrapped here, and the box is
222
+ # re-measured by the pane.
223
+ # @return [void]
224
+ def restack
225
+ invalidate_layout
226
+ reposition
227
+ end
228
+
229
+ # @param width [Integer] the box width, border included.
230
+ # @return [Array<StyledString>] every message, wrapped inside the border.
231
+ def wrapped_rows(width) = @messages.flat_map { |message| wrap_message(message, width - 2) }
232
+
223
233
  # Retires the oldest message, closing the box when it was the last. Runs on
224
234
  # the event-loop thread, from the ticker.
225
235
  # @return [void]
226
236
  def retire_oldest
227
237
  @messages.shift
228
- @messages.empty? ? close : reposition
238
+ @messages.empty? ? close : restack
229
239
  sync_ticker
230
240
  end
231
241
 
@@ -269,17 +279,20 @@ module Tuile
269
279
  # is applied here, last. Storing the clamped value instead would let a
270
280
  # SIGWINCH that narrows the terminal ratchet the box permanently down to
271
281
  # the narrow cap, with nothing to restore it when the terminal widens.
282
+ # @param screen_size [Size]
272
283
  # @return [Integer]
273
- def box_width = [@high_water + 2, cap_width].min
284
+ def box_width(screen_size) = [@high_water + 2, cap_width(screen_size)].min
274
285
 
286
+ # @param screen_size [Size]
275
287
  # @return [Integer]
276
- def cap_width
277
- [[(screen.size.width * WIDTH_FRACTION).to_i, MIN_CAP_WIDTH].max, screen.size.width].min
288
+ def cap_width(screen_size)
289
+ [[(screen_size.width * WIDTH_FRACTION).to_i, MIN_CAP_WIDTH].max, screen_size.width].min
278
290
  end
279
291
 
292
+ # @param screen_size [Size]
280
293
  # @return [Integer] at least 3: two border rows plus one row of message.
281
- def cap_height
282
- [[(screen.size.height * HEIGHT_FRACTION).to_i, 3].max, screen.size.height].min
294
+ def cap_height(screen_size)
295
+ [[(screen_size.height * HEIGHT_FRACTION).to_i, 3].max, screen_size.height].min
283
296
  end
284
297
 
285
298
  # Wraps one message to `width` columns, capped at {MAX_ROWS_PER_MESSAGE}