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
@@ -72,7 +72,11 @@ module Tuile
72
72
  @value = value
73
73
  @on_value_change = nil
74
74
  @overlay = ListDropdown.new
75
- @overlay.on_item_chosen = ->(index, _line) { commit(index) }
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
78
+ @overlay.renderer = method(:label_for)
79
+ @overlay.on_item_chosen = ->(_index, item) { commit(item) }
76
80
  end
77
81
 
78
82
  # @return [Array] the options.
@@ -107,7 +111,6 @@ module Tuile
107
111
  end
108
112
 
109
113
  # @return [String]
110
- def keyboard_hint = "⏎ #{screen.theme.hint("open")} ↑↓ #{screen.theme.hint("select")}"
111
114
 
112
115
  # Re-anchors the (open) dropdown after a move or resize.
113
116
  # @param new_rect [Rect]
@@ -171,7 +174,7 @@ module Tuile
171
174
 
172
175
  tail = Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)
173
176
  clear_background(tail) unless tail.empty?
174
- draw_line(rect.left, rect.top, face_row)
177
+ draw_text(rect.left, rect.top, face_row)
175
178
  end
176
179
 
177
180
  private
@@ -196,7 +199,7 @@ module Tuile
196
199
  return
197
200
  end
198
201
 
199
- @overlay.lines = @items.map { |item| label_for(item) }
202
+ @overlay.items = @items
200
203
  @overlay.cursor = List::Cursor.new(position: @items.index(value) || 0)
201
204
  @overlay.open unless @overlay.open?
202
205
  anchor
@@ -208,11 +211,10 @@ module Tuile
208
211
  # @return [void]
209
212
  def close_menu = (@overlay.close if @overlay.open?)
210
213
 
211
- # Adopts the item on row `index` as {#value} and closes the dropdown.
212
- # @param index [Integer]
214
+ # Adopts the chosen item as {#value} and closes the dropdown.
215
+ # @param item [Object]
213
216
  # @return [void]
214
- def commit(index)
215
- item = @items[index]
217
+ def commit(item)
216
218
  close_menu
217
219
  self.value = item
218
220
  end
@@ -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