tuile 0.11.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/DECISIONS.md +1970 -14
  4. data/README.md +136 -491
  5. data/TERMINOLOGY.md +70 -0
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +19 -6
  8. data/book/03-layout.md +12 -11
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +6 -3
  11. data/book/07-components.md +498 -38
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +27 -20
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +422 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +16 -10
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/buffer.rb +7 -7
  21. data/lib/tuile/component/abstract_string_field.rb +36 -0
  22. data/lib/tuile/component/button.rb +1 -1
  23. data/lib/tuile/component/checkbox.rb +1 -1
  24. data/lib/tuile/component/checkbox_group.rb +31 -26
  25. data/lib/tuile/component/combo_box.rb +13 -8
  26. data/lib/tuile/component/info_window.rb +1 -1
  27. data/lib/tuile/component/label.rb +14 -14
  28. data/lib/tuile/component/list.rb +313 -216
  29. data/lib/tuile/component/list_dropdown.rb +100 -10
  30. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  31. data/lib/tuile/component/menu_bar.rb +582 -0
  32. data/lib/tuile/component/notification.rb +320 -0
  33. data/lib/tuile/component/picker_window.rb +3 -8
  34. data/lib/tuile/component/popup.rb +83 -19
  35. data/lib/tuile/component/progress_bar.rb +1 -1
  36. data/lib/tuile/component/radio_group.rb +32 -30
  37. data/lib/tuile/component/select.rb +10 -8
  38. data/lib/tuile/component/tab_sheet.rb +242 -0
  39. data/lib/tuile/component/tabs.rb +528 -0
  40. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  41. data/lib/tuile/component/text_area.rb +84 -277
  42. data/lib/tuile/component/text_field.rb +24 -7
  43. data/lib/tuile/component/text_view.rb +197 -180
  44. data/lib/tuile/component/window.rb +8 -8
  45. data/lib/tuile/component.rb +43 -18
  46. data/lib/tuile/event_queue.rb +25 -1
  47. data/lib/tuile/fake_screen.rb +14 -0
  48. data/lib/tuile/keys.rb +65 -0
  49. data/lib/tuile/screen.rb +95 -78
  50. data/lib/tuile/screen_pane.rb +109 -27
  51. data/lib/tuile/styled_string.rb +52 -12
  52. data/lib/tuile/version.rb +1 -1
  53. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  54. data/sig/tuile.rbs +2307 -516
  55. metadata +9 -3
  56. data/mise.toml +0 -2
@@ -9,18 +9,20 @@ module Tuile
9
9
  # highlight, and reads the pick.
10
10
  #
11
11
  # drop = Component::ListDropdown.new
12
- # drop.on_item_chosen = ->(index, _line) { commit(index) } # caller commits
12
+ # drop.renderer = method(:label_for) # caller renders
13
+ # drop.on_item_chosen = ->(_index, item) { commit(item) } # caller commits
13
14
  # # …then, from the driver's key handler:
14
- # drop.lines = matches.map { |m| render(m) } # caller filters + renders
15
+ # drop.items = matches # caller filters
15
16
  # drop.anchor_to(rect, rows: matches.size) # below the driver, or flipped
16
17
  # drop.open
17
18
  # return true if drop.move(key) # Up/Down/PgUp/PgDn/^U/^D → list scroll
18
19
  # drop.choose if key == Keys::ENTER # commit the highlight
19
20
  #
20
21
  # It owns only what every such dropdown shares — *placement* included, via
21
- # {#anchor_to}. What stays with the driver: the width **policy** ({#anchor_to}
22
- # measures nothing itself), filtering, row rendering, the commit action, and
23
- # 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
24
26
  # revert a query; Enter may commit via {#choose} *or* via a separate submit
25
27
  # path), so {#move} claims neither — the driver calls {#choose} and {#close}
26
28
  # from its own branches.
@@ -66,14 +68,20 @@ module Tuile
66
68
  self.bg_color = Theme.ref(:input_bg_color)
67
69
  end
68
70
 
69
- # @param lines [Array] the rows to show; see {List#lines=}.
71
+ # @param items [Array] the items to show, one row each; see {List#items=}.
70
72
  # @return [void]
71
- def lines=(lines)
72
- @list.lines = lines
73
+ def items=(items)
74
+ @list.items = items
73
75
  end
74
76
 
75
- # @return [Array<StyledString>] the current rows.
76
- def lines = @list.lines
77
+ # @return [Array] the items currently shown.
78
+ def items = @list.items
79
+
80
+ # @param proc [Proc, Method] item -> row; see {List#renderer}.
81
+ # @return [void]
82
+ def renderer=(proc)
83
+ @list.renderer = proc
84
+ end
77
85
 
78
86
  # @param proc [Proc, Method, nil] commit callback; see {List#on_item_chosen}.
79
87
  # @return [void]
@@ -81,6 +89,14 @@ module Tuile
81
89
  @list.on_item_chosen = proc
82
90
  end
83
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
+
84
100
  # @param cursor [List::Cursor] the highlight; see {List#cursor=}.
85
101
  # @return [void]
86
102
  def cursor=(cursor)
@@ -90,6 +106,13 @@ module Tuile
90
106
  # @return [List::Cursor] the list's cursor (the current highlight).
91
107
  def cursor = @list.cursor
92
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
+
93
116
  # Sizes and places the dropdown against `anchor`: directly beneath it,
94
117
  # flipped above when `rows` won't fit below, clamped — with the list
95
118
  # scrolling — when neither side has room. Horizontally the left edges line
@@ -133,6 +156,73 @@ module Tuile
133
156
  @list.scrollbar_visibility = rows > height ? :visible : :gone
134
157
  end
135
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
+
136
226
  # Forwards a cursor-movement key to the list. The driver calls this from
137
227
  # its own key handler; a truthy return means "consumed — stop here", falsy
138
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