tuile 0.12.0 → 0.13.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/DECISIONS.md +1297 -13
  4. data/README.md +136 -490
  5. data/TERMINOLOGY.md +11 -2
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +18 -5
  8. data/book/03-layout.md +11 -10
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +5 -2
  11. data/book/07-components.md +402 -12
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +22 -16
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +385 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +7 -6
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/component/abstract_string_field.rb +36 -0
  21. data/lib/tuile/component/combo_box.rb +3 -1
  22. data/lib/tuile/component/list.rb +22 -0
  23. data/lib/tuile/component/list_dropdown.rb +86 -3
  24. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  25. data/lib/tuile/component/menu_bar.rb +582 -0
  26. data/lib/tuile/component/notification.rb +14 -11
  27. data/lib/tuile/component/picker_window.rb +0 -5
  28. data/lib/tuile/component/popup.rb +75 -9
  29. data/lib/tuile/component/select.rb +3 -1
  30. data/lib/tuile/component/tab_sheet.rb +242 -0
  31. data/lib/tuile/component/tabs.rb +528 -0
  32. data/lib/tuile/component/text_area.rb +5 -4
  33. data/lib/tuile/component/text_field.rb +23 -6
  34. data/lib/tuile/component/text_view.rb +8 -5
  35. data/lib/tuile/component.rb +38 -13
  36. data/lib/tuile/event_queue.rb +25 -1
  37. data/lib/tuile/fake_screen.rb +14 -0
  38. data/lib/tuile/keys.rb +65 -0
  39. data/lib/tuile/screen.rb +94 -77
  40. data/lib/tuile/screen_pane.rb +109 -27
  41. data/lib/tuile/styled_string.rb +40 -0
  42. data/lib/tuile/version.rb +1 -1
  43. data/sig/tuile.rbs +1473 -93
  44. metadata +6 -3
  45. data/mise.toml +0 -2
@@ -52,6 +52,9 @@ module Tuile
52
52
  self.content = field
53
53
 
54
54
  @overlay = ListDropdown.new
55
+ # Outside-click dismissal spans the owner chain, so a click on this
56
+ # combo's dropdown must not dismiss a dialog the combo sits in.
57
+ @overlay.owner = self
55
58
  @overlay.renderer = ->(item) { @item_label.call(item) }
56
59
  @overlay.on_item_chosen = ->(_index, item) { commit(item) }
57
60
  end
@@ -98,7 +101,6 @@ module Tuile
98
101
  def cursor_position = content.cursor_position
99
102
 
100
103
  # @return [String]
101
- def keyboard_hint = "↑↓ #{screen.theme.hint("select")} ⏎ #{screen.theme.hint("accept")}"
102
104
 
103
105
  # Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
104
106
  # field via {#layout}.
@@ -294,6 +294,28 @@ module Tuile
294
294
  search_and_go(query, include_current: include_current, reverse: true)
295
295
  end
296
296
 
297
+ # Moves the cursor to the item at `index`, scrolling it into view and
298
+ # firing {#on_cursor_changed} — the positional member of the
299
+ # {#select_next} / {#select_prev} family, for a caller that already knows
300
+ # *which* item it wants:
301
+ #
302
+ # list.select(items.index(chosen))
303
+ #
304
+ # Refuses an index the current {#cursor} can't reach — out of range, or
305
+ # anything at all under {Cursor::None} — rather than stranding the cursor
306
+ # off-content.
307
+ # @param index [Integer]
308
+ # @return [Boolean] whether the cursor moved there.
309
+ def select(index)
310
+ return false unless @cursor.candidate_positions(@items.size).include?(index)
311
+
312
+ @cursor.go(index)
313
+ move_viewport_to_cursor
314
+ notify_cursor_changed
315
+ invalidate
316
+ true
317
+ end
318
+
297
319
  # @param event [MouseEvent]
298
320
  # @return [void]
299
321
  def handle_mouse(event)
@@ -19,9 +19,10 @@ module Tuile
19
19
  # drop.choose if key == Keys::ENTER # commit the highlight
20
20
  #
21
21
  # It owns only what every such dropdown shares — *placement* included, via
22
- # {#anchor_to}. What stays with the driver: the width **policy** ({#anchor_to}
23
- # measures nothing itself), filtering, row rendering, the commit action, and
24
- # ESC/Enter handling. ESC and Enter carry driver-specific tails (ESC may
22
+ # {#anchor_to} (below a field) and {#anchor_beside} (beside a parent row, for
23
+ # a cascading submenu). What stays with the driver: the width **policy**
24
+ # (neither placement method measures anything itself), filtering, row
25
+ # rendering, the commit action, and ESC/Enter handling. ESC and Enter carry driver-specific tails (ESC may
25
26
  # revert a query; Enter may commit via {#choose} *or* via a separate submit
26
27
  # path), so {#move} claims neither — the driver calls {#choose} and {#close}
27
28
  # from its own branches.
@@ -88,6 +89,14 @@ module Tuile
88
89
  @list.on_item_chosen = proc
89
90
  end
90
91
 
92
+ # @param proc [Proc, Method, nil] highlight-moved callback; see
93
+ # {List#on_cursor_changed}. A cascading driver needs it to drop the
94
+ # panels that belonged to the row the highlight just left.
95
+ # @return [void]
96
+ def on_cursor_changed=(proc)
97
+ @list.on_cursor_changed = proc
98
+ end
99
+
91
100
  # @param cursor [List::Cursor] the highlight; see {List#cursor=}.
92
101
  # @return [void]
93
102
  def cursor=(cursor)
@@ -97,6 +106,13 @@ module Tuile
97
106
  # @return [List::Cursor] the list's cursor (the current highlight).
98
107
  def cursor = @list.cursor
99
108
 
109
+ # Moves the highlight to the item at `index`, scrolling it into view; see
110
+ # {List#select}. The positional counterpart of {#move}, for a driver that
111
+ # picked a row by something other than a key — a mnemonic letter, say.
112
+ # @param index [Integer]
113
+ # @return [Boolean] whether the highlight moved there.
114
+ def select(index) = @list.select(index)
115
+
100
116
  # Sizes and places the dropdown against `anchor`: directly beneath it,
101
117
  # flipped above when `rows` won't fit below, clamped — with the list
102
118
  # scrolling — when neither side has room. Horizontally the left edges line
@@ -140,6 +156,73 @@ module Tuile
140
156
  @list.scrollbar_visibility = rows > height ? :visible : :gone
141
157
  end
142
158
 
159
+ # Sizes and places the dropdown *beside* `anchor` — the placement a
160
+ # cascading submenu wants, where {#anchor_to} is the placement a field's
161
+ # dropdown wants.
162
+ #
163
+ # sub.anchor_beside(parent.cursor_row_rect, rows: kids.size, width: measured)
164
+ #
165
+ # Horizontally it sits against `anchor`'s right edge, **flipping** to its
166
+ # left when the right has no room (and clamping to the screen when neither
167
+ # side does). Vertically it **slides**: the panel's first row lines up with
168
+ # the anchored row, sliding up only far enough to keep the panel on screen.
169
+ #
170
+ # The two axes are the mirror image of {#anchor_to}'s, for the same reason:
171
+ # never cover the thing being chosen from. A field's dropdown must not
172
+ # cover the field, so it flips *vertically* and shares its columns; a
173
+ # submenu must not cover its parent panel, so it flips *horizontally* and
174
+ # shares its rows.
175
+ #
176
+ # @param anchor [Rect] the row the submenu belongs to — typically the
177
+ # parent dropdown's {#cursor_row_rect}. Its width is the parent panel's,
178
+ # which is what the submenu clears.
179
+ # @param rows [Integer] how many rows there are to show — the content
180
+ # count, not the height; more than fits turns the scrollbar on. `0`
181
+ # collapses the dropdown to an empty rect (drivers close instead).
182
+ # @param width [Integer] the panel's width in columns, clamped to the
183
+ # screen. **Required, with no default:** `anchor.width` is the *parent's*
184
+ # width and would be meaningless here, so the caller measures (see
185
+ # `DECISIONS.md` `D-select` on why the width policy stays with the
186
+ # driver).
187
+ # @param max_rows [Integer] rows shown before the list scrolls.
188
+ # @return [void]
189
+ def anchor_beside(anchor, rows:, width:, max_rows: MAX_VISIBLE_ROWS)
190
+ height = [rows, max_rows, screen.size.height].min
191
+ width = [width, screen.size.width].min
192
+ right = anchor.left + anchor.width
193
+ left = if right + width <= screen.size.width || (anchor.left - width).negative?
194
+ right
195
+ else
196
+ anchor.left - width
197
+ end
198
+ left = left.clamp(0, [screen.size.width - width, 0].max)
199
+ top = [anchor.top, screen.size.height - height].min.clamp(0, nil)
200
+ self.size = Size.new(width, height)
201
+ self.rect = Rect.new(left, top, width, height)
202
+ # After the geometry, as in {#anchor_to}: the setter rebuilds the list's
203
+ # padded rows against the width it can see.
204
+ @list.scrollbar_visibility = rows > height ? :visible : :gone
205
+ end
206
+
207
+ # The highlighted row's rect on screen — what a cascading submenu anchors
208
+ # against, via {#anchor_beside}.
209
+ #
210
+ # It lives here rather than in the driver because {ListDropdown} owns the
211
+ # list's geometry: a driver computing `top + position - scroll_top_row`
212
+ # itself would have to reach through to the private list.
213
+ # @return [Rect, nil] one row spanning the panel's width, or `nil` when
214
+ # the cursor is off-content ({List::Cursor::None}, an empty list) or its
215
+ # row is scrolled out of the viewport.
216
+ def cursor_row_rect
217
+ return nil if @list.rect.empty?
218
+ return nil unless @list.cursor.position.between?(0, @list.items.size - 1)
219
+
220
+ row = @list.cursor.position - @list.scroll_top_row
221
+ return nil unless row.between?(0, @list.rect.height - 1)
222
+
223
+ Rect.new(@list.rect.left, @list.rect.top + row, @list.rect.width, 1)
224
+ end
225
+
143
226
  # Forwards a cursor-movement key to the list. The driver calls this from
144
227
  # its own key handler; a truthy return means "consumed — stop here", falsy
145
228
  # means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ class MenuBar
6
+ # The stack of open menu panels — one {ListDropdown} per level, the last
7
+ # deepest — and the drill/pop/activate logic driving them. Private
8
+ # machinery of {MenuBar}; an app never names it.
9
+ #
10
+ # cascade.open_below(segment_rect, item) # Enter/Down on the strip
11
+ # return true if cascade.handle_key(key) # MenuBar#handle_key, first
12
+ # cascade.close # focus lost, or rect changed
13
+ #
14
+ # A panel is a **non-modal overlay, not a child**, so it never takes focus:
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
17
+ # {Component::Select}'s architecture extended to N levels, and it is why
18
+ # nothing in the key-dispatch ladder changes.
19
+ #
20
+ # Widths are measured here, per level — the panel is as wide as the level's
21
+ # widest label — because {ListDropdown} deliberately measures nothing
22
+ # itself (`DECISIONS.md` `D-select`).
23
+ #
24
+ # == Implementation details
25
+ # While open it consumes **everything** except the two keys that mean
26
+ # "leave this menu sideways", which only the strip can answer: LEFT at
27
+ # depth 1, and RIGHT on a row with no submenu. An open menu is quasi-modal
28
+ # — firing an app's `s`-to-save behind a visible panel is worse than a dead
29
+ # keystroke.
30
+ #
31
+ # UI-thread-confined, like everything in the tree (see {Screen}).
32
+ class Cascade
33
+ # The affordance painted on a row that opens a submenu. U+25B8 rather
34
+ # than the obvious `▶`: like {Component::Select}'s `▾` it is East-Asian
35
+ # **Neutral**, so it measures one column even under ambiguous-as-wide and
36
+ # stays outside `D-ambiguous-width`'s bet, where `▶` and `▼` are
37
+ # Ambiguous and would need an ASCII opt-in.
38
+ # @return [String]
39
+ SUBMENU_ARROW = "▸"
40
+
41
+ # Columns a submenu arrow occupies, the space before it included.
42
+ # @return [Integer]
43
+ ARROW_WIDTH = 2
44
+
45
+ def initialize
46
+ @levels = []
47
+ end
48
+
49
+ # @return [Boolean] whether any panel is open.
50
+ def open? = !@levels.empty?
51
+
52
+ # @return [Integer] how many panels are open; `0` when closed.
53
+ def depth = @levels.size
54
+
55
+ # Opens `item`'s children directly beneath `anchor`, closing anything
56
+ # already open first.
57
+ # @param anchor [Rect] the strip segment the menu drops from.
58
+ # @param item [Item] a childless one opens nothing.
59
+ # @return [void]
60
+ def open_below(anchor, item)
61
+ close
62
+ return unless item.submenu?
63
+
64
+ push(item) { |drop, rows, width| drop.anchor_to(anchor, rows: rows, width: width) }
65
+ end
66
+
67
+ # Closes every open panel, deepest first.
68
+ # @return [void]
69
+ def close = truncate(0)
70
+
71
+ # Offers a key to the deepest panel and to the cascade's own verbs.
72
+ # @param key [String]
73
+ # @return [Boolean] `true` when consumed — almost always, while open.
74
+ # `false` when closed, and for the two sideways keys {MenuBar} answers
75
+ # (see the class docs).
76
+ def handle_key(key)
77
+ return false unless open?
78
+ return true if deepest.move(key)
79
+
80
+ case key
81
+ when Keys::ENTER, " " then activate_highlighted
82
+ when Keys::RIGHT_ARROW
83
+ return false unless highlighted(depth - 1)&.submenu?
84
+
85
+ activate_highlighted
86
+ when Keys::LEFT_ARROW
87
+ return false if depth < 2
88
+
89
+ pop
90
+ when Keys::ESC then pop
91
+ else
92
+ # Only a printable: an open menu also swallows HOME, function keys
93
+ # and the five-byte junk Keys.getkey returns for an unrecognized
94
+ # escape sequence, and ringing at terminal noise is worse than
95
+ # silence. A printable is a deliberate, visible act.
96
+ Screen.instance.beep if Keys.printable?(key)
97
+ end
98
+ true
99
+ end
100
+
101
+ # Activates the deepest level's item bound to `key` — the drill-or-fire
102
+ # the mnemonic shares with Enter. The highlight moves there *first*, so a
103
+ # submenu anchors beside the row that opened it rather than beside
104
+ # wherever the cursor happened to be.
105
+ # @param key [String] a single printable, already downcased.
106
+ # @return [Boolean] whether an item on the deepest level claimed it. A
107
+ # miss is never offered to a shallower level.
108
+ def handle_mnemonic(key)
109
+ return false unless open?
110
+
111
+ level = depth - 1
112
+ item, drop = @levels[level]
113
+ index = item.items.index { |child| child.mnemonic == key }
114
+ return false if index.nil?
115
+
116
+ drop.select(index)
117
+ activate(level, item.items[index])
118
+ true
119
+ end
120
+
121
+ private
122
+
123
+ # @return [ListDropdown] the deepest open panel.
124
+ def deepest = @levels.last[1]
125
+
126
+ # @return [void]
127
+ def activate_highlighted = activate(depth - 1, highlighted(depth - 1))
128
+
129
+ # Drills into `item`, or fires it and closes the cascade.
130
+ #
131
+ # @param level [Integer] the panel the item belongs to.
132
+ # @param item [Item, nil] `nil` (an off-content cursor) does nothing.
133
+ # @return [void]
134
+ def activate(level, item)
135
+ # Truncate first, for the mouse: a click on a shallower panel that is
136
+ # still visible routes to *that* panel, so anything deeper is stale.
137
+ truncate(level + 1)
138
+ return if item.nil?
139
+
140
+ if item.submenu?
141
+ push_beside(level, item)
142
+ else
143
+ # Closed before the listener runs, so an action that opens a dialog
144
+ # doesn't paint it under a menu. A listener-less leaf still closes:
145
+ # activation stays uniform.
146
+ close
147
+ item.on_click&.call
148
+ end
149
+ end
150
+
151
+ # Opens `item`'s children beside the row highlighted in `level`.
152
+ # @param level [Integer]
153
+ # @param item [Item]
154
+ # @return [void]
155
+ def push_beside(level, item)
156
+ anchor = @levels[level][1].cursor_row_rect
157
+ return if anchor.nil?
158
+
159
+ push(item) { |drop, rows, width| drop.anchor_beside(anchor, rows: rows, width: width) }
160
+ end
161
+
162
+ # Mounts a panel for `item`'s children and yields it for geometry.
163
+ # @param item [Item]
164
+ # @yieldparam drop [ListDropdown]
165
+ # @yieldparam rows [Integer]
166
+ # @yieldparam width [Integer]
167
+ # @return [void]
168
+ def push(item)
169
+ children = item.items
170
+ drop = ListDropdown.new
171
+ drop.renderer = renderer_for(children)
172
+ drop.items = children
173
+ drop.cursor = List::Cursor.new
174
+ level = @levels.size
175
+ # Wired *after* the items and cursor: {List#items=} and {List#cursor=}
176
+ # both fire on_cursor_changed, so wiring first would have the fresh
177
+ # panel truncate itself away as it was built.
178
+ drop.on_item_chosen = ->(_index, child) { activate(level, child) }
179
+ drop.on_cursor_changed = ->(_index, _child) { truncate(level + 1) }
180
+ # The cascade's own record of what is open is reconciled from the
181
+ # popup's own closure, not maintained alongside it: an outside click
182
+ # closes panels behind our back ({Popup#close_on_outside_click?}), and
183
+ # a level left in `@levels` after its panel is gone would have `depth`,
184
+ # `deepest` and `highlighted` all lying. Identity-keyed and idempotent,
185
+ # because the notice also arrives from `truncate` (which has already
186
+ # popped the entry) and from teardown, in no guaranteed order.
187
+ drop.on_close = -> { @levels.delete_if { |(_i, d)| d.equal?(drop) } }
188
+ # Chain each panel to the one it dropped out of, so a click on a
189
+ # deeper panel is "inside" the shallower ones and doesn't dismiss
190
+ # them. Level 0 owns nothing on purpose: a click on a dialog hosting
191
+ # the bar *should* close the whole menu and keep the dialog.
192
+ drop.owner = @levels.last&.last
193
+ @levels << [item, drop]
194
+ drop.open
195
+ yield(drop, children.size, width_for(children))
196
+ end
197
+
198
+ # Closes the deepest panel; at depth 1 that closes the cascade.
199
+ # @return [void]
200
+ def pop = truncate(depth - 1)
201
+
202
+ # @param count [Integer] how many panels to keep.
203
+ # @return [void]
204
+ def truncate(count)
205
+ while depth > count
206
+ _item, drop = @levels.pop
207
+ drop.close
208
+ end
209
+ end
210
+
211
+ # @param level [Integer]
212
+ # @return [Item, nil] the item under `level`'s cursor; `nil` when it sits
213
+ # off-content. The range guard matters: a cursor at `-1` would
214
+ # otherwise index the *last* child.
215
+ def highlighted(level)
216
+ item, drop = @levels[level]
217
+ position = drop.cursor.position
218
+ position.between?(0, item.items.size - 1) ? item.items[position] : nil
219
+ end
220
+
221
+ # @param items [Array<Item>]
222
+ # @return [Proc] item -> row: the label padded to the level's widest, plus
223
+ # an arrow column when any sibling has a submenu — so every arrow lands
224
+ # in the same column without asking the {List} how wide it ended up.
225
+ def renderer_for(items)
226
+ label_width = label_width_of(items)
227
+ arrows = items.any?(&:submenu?)
228
+ lambda do |item|
229
+ row = item.cued_caption.ellipsize(label_width)
230
+ row += StyledString.plain(" " * (label_width - row.display_width))
231
+ next row unless arrows
232
+
233
+ row + StyledString.plain(item.submenu? ? " #{SUBMENU_ARROW}" : " " * ARROW_WIDTH)
234
+ end
235
+ end
236
+
237
+ # @param items [Array<Item>]
238
+ # @return [Integer] the panel width: the rendered row plus {List}'s two
239
+ # row gutters, plus a scrollbar column when the rows can't all be shown.
240
+ # As in {Component::Select}, the scrollbar is predicted from the item
241
+ # count rather than the final height — a panel the screen clamps
242
+ # shorter than {ListDropdown::MAX_VISIBLE_ROWS} scrolls without having
243
+ # bought that column, and ellipsizes one character early.
244
+ def width_for(items)
245
+ row = label_width_of(items) + (items.any?(&:submenu?) ? ARROW_WIDTH : 0)
246
+ row + 2 + (items.size > ListDropdown::MAX_VISIBLE_ROWS ? 1 : 0)
247
+ end
248
+
249
+ # @param items [Array<Item>]
250
+ # @return [Integer]
251
+ def label_width_of(items) = items.map { _1.cued_caption.display_width }.max || 0
252
+ end
253
+ end
254
+ end
255
+ end