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
@@ -40,6 +40,10 @@ module Tuile
40
40
  # declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
41
41
  # nested {Component::TextField} doesn't dismiss the popup: the field
42
42
  # consumes it first.
43
+ #
44
+ # A left click *outside* the popup closes it too, modal or not — see
45
+ # {#close_on_outside_click?} for the exact contract and {#on_close=} for
46
+ # the notice a driver hears when it happens.
43
47
  class Popup < Component
44
48
  include Component::HasContent
45
49
 
@@ -52,10 +56,16 @@ module Tuile
52
56
  # @param size [Size, Fraction] the popup's size, applied top-down. A
53
57
  # {Fraction} is resolved against the screen each layout pass; a {Size}
54
58
  # is clamped to the screen. Defaults to {Fraction::HALF}.
55
- def initialize(content: nil, modal: true, size: Fraction::HALF)
59
+ # @param close_on_outside_click [Boolean] true (default) to dismiss on a
60
+ # left click that misses this popup. See {#close_on_outside_click?}.
61
+ def initialize(content: nil, modal: true, size: Fraction::HALF,
62
+ close_on_outside_click: true)
56
63
  super()
57
64
  @modal = modal
58
65
  @size = size
66
+ @close_on_outside_click = close_on_outside_click
67
+ @owner = nil
68
+ @on_close = nil
59
69
  @content = nil
60
70
  self.content = content unless content.nil?
61
71
  reposition
@@ -67,6 +77,63 @@ module Tuile
67
77
  # @return [Boolean] whether this popup is modal. See {#initialize}.
68
78
  def modal? = @modal
69
79
 
80
+ # Whether a left click outside this popup closes it (default true, modal or
81
+ # not). The pane does the closing — {ScreenPane#handle_mouse} snapshots
82
+ # the open popups *before* routing the click and closes the dismissable
83
+ # ones *after*, so a widget that toggles its own overlay from a click on
84
+ # its face (a {Component::Select}, a {Component::MenuBar} title) still
85
+ # toggles correctly: the delivered click closes the overlay and the
86
+ # dismissal then no-ops on it, rather than closing and reopening it. Only
87
+ # `:left` dismisses; scroll and right clicks never do.
88
+ #
89
+ # **"Outside" spans the {#owner} chain, not just this rect.** A click
90
+ # counts as inside this popup when it lands in its rect *or* in any popup
91
+ # that belongs to it — so a dialog is not dismissed by a click on a
92
+ # dropdown its own field opened, and a menu cascade is not dismissed by a
93
+ # click on one of its deeper panels. Popups with no owner relationship are
94
+ # independent: clicking one dismisses the other, which is what a
95
+ # window-like overlay should do. A popup that must survive unrelated
96
+ # clicks entirely ({Component::Notification}) sets this false.
97
+ #
98
+ # Every dismissable popup closes, not just the topmost, and stacking order
99
+ # plays no part: a {Component::MenuBar} cascade must vanish whole on one
100
+ # click on the background, not peel one panel per click.
101
+ # @return [Boolean]
102
+ def close_on_outside_click? = @close_on_outside_click
103
+
104
+ # @return [Boolean] see {#close_on_outside_click?}.
105
+ attr_writer :close_on_outside_click
106
+
107
+ # The component this overlay is *part of*, or `nil` (the default) when it
108
+ # is an overlay in its own right. It exists for outside-click dismissal:
109
+ # a click inside this popup also counts as inside whatever popup encloses
110
+ # its owner, so the host is not dismissed by a click on a panel it put
111
+ # there. See {#close_on_outside_click?}.
112
+ #
113
+ # Set it to the *driver* — {Component::ComboBox} hands its dropdown
114
+ # `self` — rather than to the enclosing popup: the driver knows what it
115
+ # is, while the popup above it is a tree relationship the pane resolves
116
+ # at click time (so it cannot go stale). Any {Component} is accepted, and
117
+ # a `Popup` resolves to itself, which is how a
118
+ # {Component::MenuBar::Cascade} chains each panel to the one it dropped
119
+ # out of.
120
+ # @return [Component, nil]
121
+ attr_accessor :owner
122
+
123
+ # A callback taking no arguments, fired once this popup has left the
124
+ # screen — **however it left**: {#close}, a direct {Screen#remove_popup},
125
+ # an outside click, or teardown via {Screen#close}. That unconditionality
126
+ # is the point, so it hangs off {#on_detached} rather than {#close}; a
127
+ # driver keeping its own record of open popups reconciles it here and
128
+ # cannot drift ({Component::MenuBar::Cascade} is the worked example).
129
+ #
130
+ # It fires *after* the popup is detached, so {#open?} is already false and
131
+ # the usual {Component#on_detached} caveats apply: release state, don't
132
+ # inspect the tree, keep it trivial (it may run while the pane is mid-way
133
+ # through closing a batch of popups, and a raise propagates).
134
+ # @return [Proc, nil]
135
+ attr_accessor :on_close
136
+
70
137
  def focusable? = true
71
138
 
72
139
  # Sets the popup's size and repositions it. Accepts a {Fraction}
@@ -147,14 +214,6 @@ module Tuile
147
214
  self.rect = rect.centered(screen.size)
148
215
  end
149
216
 
150
- # Hint for the status bar: own "q Close" plus the wrapped content's hint.
151
- # @return [String]
152
- def keyboard_hint
153
- prefix = "q #{screen.theme.hint("Close")}"
154
- child_hint = @content&.keyboard_hint.to_s
155
- child_hint.empty? ? prefix : "#{prefix} #{child_hint}"
156
- end
157
-
158
217
  # `q` and ESC close the popup. The popup sits on the focus chain of
159
218
  # whatever it wraps, so the key reaches here by bubbling up from the
160
219
  # focused content after that content declined to handle it.
@@ -169,6 +228,13 @@ module Tuile
169
228
  end
170
229
  end
171
230
 
231
+ # Fires {#on_close}. A subclass overriding this **must** call `super`, or
232
+ # the popup's driver never hears that it closed.
233
+ # @return [void]
234
+ def on_detached
235
+ @on_close&.call
236
+ end
237
+
172
238
  protected
173
239
 
174
240
  # Content fills the popup's full rect — Popup has no border to subtract.
@@ -72,6 +72,9 @@ module Tuile
72
72
  @value = value
73
73
  @on_value_change = nil
74
74
  @overlay = ListDropdown.new
75
+ # Outside-click dismissal spans the owner chain, so a click on this
76
+ # select's dropdown must not dismiss a dialog the select sits in.
77
+ @overlay.owner = self
75
78
  @overlay.renderer = method(:label_for)
76
79
  @overlay.on_item_chosen = ->(_index, item) { commit(item) }
77
80
  end
@@ -108,7 +111,6 @@ module Tuile
108
111
  end
109
112
 
110
113
  # @return [String]
111
- def keyboard_hint = "⏎ #{screen.theme.hint("open")} ↑↓ #{screen.theme.hint("select")}"
112
114
 
113
115
  # Re-anchors the (open) dropdown after a move or resize.
114
116
  # @param new_rect [Rect]
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A {Component::Tabs} strip on its top row plus the pane belonging to the
6
+ # selected tab underneath it:
7
+ #
8
+ # ␣Details␣│␣Payment␣│␣Shipping␣
9
+ # the selected tab's pane fills the rest of the rect
10
+ #
11
+ # sheet = Component::TabSheet.new
12
+ # sheet.add_tab("Details", details_form) # selected, and shown
13
+ # sheet.add_tab("Payment", payment_form)
14
+ # sheet.select_next # shows payment_form
15
+ # sheet.on_tab_selected = ->(index, tab) { log("now on #{tab&.caption}") }
16
+ #
17
+ # Tab lands on the strip first and enters the pane on the next press, which
18
+ # is the browser's order; switching tabs does *not* move focus into the new
19
+ # pane, but if focus was inside the pane that just went away it lands back
20
+ # on the strip.
21
+ #
22
+ # == Hidden panes are detached
23
+ # Only the selected tab's pane is in the component tree — the others are
24
+ # detached, which is how Tuile hides a component (there is no visibility
25
+ # flag, and an empty rect gates painting only). Consequences worth
26
+ # designing around:
27
+ #
28
+ # - A hidden pane is invisible to *everything*: the Tab cycle, focus
29
+ # cascades, repaint, the cursor, `on_tree` walks. No gates anywhere.
30
+ # - Its state survives, because state is ivars — scroll position, caret,
31
+ # list cursor, text are all exactly as the user left them, and mutating a
32
+ # hidden pane is safe (`invalidate` while detached is a silent no-op).
33
+ # - {Component#on_detached} / {Component#on_attached} fire on every switch,
34
+ # so a pane holding a mounted-lifetime resource — a {Component::ProgressBar}'s
35
+ # ticker — releases it while hidden and re-acquires it on return. A pane
36
+ # that must keep something alive while hidden can't; that something
37
+ # belongs in the model the pane renders, not in the pane.
38
+ #
39
+ # == Implementation details
40
+ # `children` is `[strip, pane]`, the strip pinned at index 0, so pre-order
41
+ # traversal gives the strip-then-pane Tab order for free. The swap follows
42
+ # the slot-swap recipe {Component#detach_child} documents — detach, rewire,
43
+ # `on_child_removed` last, so the focus repair sees the new occupant.
44
+ #
45
+ # Panes live in an identity-keyed `Tab => Component` map here rather than in
46
+ # a slot on {Tabs::Tab}: the strip's tab array stays the sole ordering
47
+ # authority, and the strip itself stays ignorant of panes. One idempotent
48
+ # `sync_pane` is the sole writer of the visible pane, deriving it from
49
+ # `strip.selected` on every call, so registering a pane and selecting a tab
50
+ # can happen in either order.
51
+ #
52
+ # The sheet owns the strip's `on_tab_selected` (that is what drives the
53
+ # swap); an app's listener goes on {#on_tab_selected} here, which fires
54
+ # after the pane has been swapped in.
55
+ class TabSheet < Component
56
+ # An app's own selection listener, called after the pane has been swapped
57
+ # in — `(index, tab)`, or `(nil, nil)` once the last tab is gone. Same
58
+ # contract as {Tabs#on_tab_selected}: it reports that the selection
59
+ # changed, whatever changed it.
60
+ # @return [Proc, nil]
61
+ attr_accessor :on_tab_selected
62
+
63
+ # @param separator [String, StyledString] the strip's separator; see
64
+ # {Tabs#separator=}.
65
+ def initialize(separator: Tabs::DEFAULT_SEPARATOR)
66
+ super()
67
+ @panes = {}.compare_by_identity
68
+ @strip = Tabs.new(separator:)
69
+ add_child(@strip)
70
+ @strip.on_tab_selected = lambda do |index, tab|
71
+ sync_pane
72
+ @on_tab_selected&.call(index, tab)
73
+ end
74
+ end
75
+
76
+ # @return [Tabs] the strip. Reach through it for the rest of its API —
77
+ # `sheet.strip.separator = "|"` — but leave its `on_tab_selected` alone:
78
+ # the sheet drives the pane swap through it, and {#on_tab_selected} is
79
+ # where an app's listener goes.
80
+ attr_reader :strip
81
+
82
+ # @return [Component, nil] the pane currently in the tree — the selected
83
+ # tab's, `nil` while the sheet has no tabs.
84
+ attr_reader :pane
85
+
86
+ # Adds a tab and the pane to show while it is selected. The first tab
87
+ # added becomes the selection, so its pane is shown immediately.
88
+ # @param caption [String, StyledString, nil] parsed as {Tabs::Tab#caption=}
89
+ # parses it.
90
+ # @param pane [Component] shown while this tab is selected, detached while
91
+ # it isn't.
92
+ # @raise [TypeError] when `pane` isn't a {Component}.
93
+ # @raise [ArgumentError] when `pane` is already this sheet's pane for
94
+ # another tab — a component has one parent, so two tabs cannot share it.
95
+ # @return [Tabs::Tab] the new tab's handle.
96
+ def add_tab(caption, pane)
97
+ raise TypeError, "expected Component, got #{pane.inspect}" unless pane.is_a?(Component)
98
+
99
+ forget_removed_tabs
100
+ if @panes.each_value.any? { |existing| existing.equal?(pane) }
101
+ raise ArgumentError, "#{pane} is already a pane of this TabSheet"
102
+ end
103
+
104
+ tab = @strip.add_tab(caption)
105
+ @panes[tab] = pane
106
+ sync_pane
107
+ tab
108
+ end
109
+
110
+ # Removes a tab and forgets its pane, detaching it if it was the visible
111
+ # one. The strip re-selects as {Tabs#remove_tab} describes, and this
112
+ # sheet shows whatever it lands on.
113
+ # @param tab [Tabs::Tab] one of this sheet's tabs.
114
+ # @raise [ArgumentError] when the tab isn't on this sheet's strip.
115
+ # @return [Component, nil] the pane that tab owned.
116
+ def remove_tab(tab)
117
+ pane = @panes[tab] # read first: the strip's own removal may prune the entry
118
+ @strip.remove_tab(tab)
119
+ @panes.delete(tab)
120
+ pane
121
+ end
122
+
123
+ # @param tab [Tabs::Tab, nil]
124
+ # @return [Component, nil] the pane registered for `tab`; `nil` for a
125
+ # removed tab, a tab of another sheet, or `nil`.
126
+ def pane_for(tab)
127
+ return nil unless tab&.attached?
128
+
129
+ @panes[tab]
130
+ end
131
+
132
+ # @return [Array<Tabs::Tab>] the strip's tabs, in order.
133
+ def tabs = @strip.tabs
134
+
135
+ # @return [Tabs::Tab, nil] the selected tab.
136
+ def selected = @strip.selected
137
+
138
+ # @param tab [Tabs::Tab] one of this sheet's tabs.
139
+ # @return [void]
140
+ def selected=(tab)
141
+ @strip.selected = tab
142
+ end
143
+
144
+ # @return [Integer, nil] the selected tab's position.
145
+ def selected_index = @strip.selected_index
146
+
147
+ # @param index [Integer] a position in `0...tabs.size`.
148
+ # @return [void]
149
+ def selected_index=(index)
150
+ @strip.selected_index = index
151
+ end
152
+
153
+ # Selects the next tab, clamping at the last one.
154
+ # @return [Boolean] `false` only when there are no tabs.
155
+ def select_next = @strip.select_next
156
+
157
+ # Selects the previous tab, clamping at the first one.
158
+ # @return [Boolean] `false` only when there are no tabs.
159
+ def select_previous = @strip.select_previous
160
+
161
+ # @param new_rect [Rect]
162
+ # @return [void]
163
+ def rect=(new_rect)
164
+ super
165
+ @strip.rect = Rect.new(rect.left, rect.top, rect.width, [rect.height, 1].min)
166
+ layout_pane
167
+ end
168
+
169
+ # Forwards to whichever child the click landed on — the strip's row, or
170
+ # the pane below it.
171
+ # @param event [MouseEvent]
172
+ # @return [void]
173
+ def handle_mouse(event)
174
+ super
175
+ children.each do |child|
176
+ child.handle_mouse(event) if child.rect.contains?(event.point)
177
+ end
178
+ end
179
+
180
+ # Sends focus to the strip: a sheet is a container, and the strip is where
181
+ # a tab switch is driven from. The pane is a Tab press away.
182
+ # @return [void]
183
+ def on_focus
184
+ super
185
+ screen.focused = @strip
186
+ end
187
+
188
+ # Lands focus on the strip rather than on `self` when the focused pane is
189
+ # swapped out — a bare container can't use keys, and the user's last
190
+ # action was a tab switch.
191
+ # @param child [Component]
192
+ # @return [void]
193
+ def on_child_removed(child)
194
+ super
195
+ screen.focused = @strip if attached? && screen.focused.equal?(self)
196
+ end
197
+
198
+ private
199
+
200
+ # Makes the visible pane match `strip.selected`, swapping if it doesn't.
201
+ # Idempotent and the sole writer of `@pane`: it derives everything from
202
+ # current state, so {#add_tab} can register a pane after the strip has
203
+ # already selected its tab.
204
+ # @return [void]
205
+ def sync_pane
206
+ forget_removed_tabs
207
+ wanted = @panes[@strip.selected]
208
+ return if wanted.equal?(@pane)
209
+
210
+ old = @pane
211
+ detach_child(old) unless old.nil?
212
+ @pane = wanted
213
+ unless wanted.nil?
214
+ add_child(wanted) # appended: the strip stays at index 0
215
+ wanted.invalidate
216
+ layout_pane
217
+ end
218
+ invalidate
219
+ on_child_removed(old) unless old.nil?
220
+ end
221
+
222
+ # Drops entries whose tab is gone. {Tabs::Tab#remove} takes a tab off the
223
+ # strip without passing through {#remove_tab}, and a detached tab can never
224
+ # be selected again, so its entry is dead weight — it pins the pane against
225
+ # garbage collection and makes {#add_tab} reject that pane as still in use.
226
+ # Idempotent and the only cleaner, because the rule it enforces is an
227
+ # invariant ("every key is a live tab of my strip") rather than a step in
228
+ # one code path.
229
+ # @return [void]
230
+ def forget_removed_tabs
231
+ @panes.delete_if { |tab, _pane| !tab.attached? }
232
+ end
233
+
234
+ # @return [void]
235
+ def layout_pane
236
+ return if @pane.nil?
237
+
238
+ @pane.rect = Rect.new(rect.left, rect.top + 1, rect.width, [rect.height - 1, 0].max)
239
+ end
240
+ end
241
+ end
242
+ end