tuile 0.12.0 → 0.14.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -27
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +2342 -158
  5. data/README.md +151 -505
  6. data/TERMINOLOGY.md +15 -5
  7. data/book/01-first-app.md +22 -17
  8. data/book/02-repaint.md +18 -5
  9. data/book/03-layout.md +28 -20
  10. data/book/05-focus.md +137 -19
  11. data/book/06-theming.md +103 -2
  12. data/book/07-components.md +567 -27
  13. data/book/08-testing.md +34 -4
  14. data/book/09-styled-text.md +3 -3
  15. data/book/README.md +7 -5
  16. data/examples/file_commander.rb +23 -17
  17. data/examples/hello_world.rb +17 -5
  18. data/examples/sampler.rb +527 -108
  19. data/ideas/arrow-key-navigation.md +17 -1
  20. data/ideas/modal-backdrop.md +24 -0
  21. data/ideas/new-components.md +28 -27
  22. data/lib/tuile/ansi.rb +10 -0
  23. data/lib/tuile/buffer.rb +51 -3
  24. data/lib/tuile/color.rb +143 -0
  25. data/lib/tuile/color_depth.rb +80 -0
  26. data/lib/tuile/component/abstract_string_field.rb +36 -0
  27. data/lib/tuile/component/button.rb +3 -3
  28. data/lib/tuile/component/checkbox.rb +3 -3
  29. data/lib/tuile/component/combo_box.rb +12 -3
  30. data/lib/tuile/component/confirm_window.rb +442 -0
  31. data/lib/tuile/component/has_content.rb +22 -9
  32. data/lib/tuile/component/has_value.rb +1 -1
  33. data/lib/tuile/component/info_window.rb +64 -16
  34. data/lib/tuile/component/layout.rb +0 -10
  35. data/lib/tuile/component/list.rb +22 -0
  36. data/lib/tuile/component/list_dropdown.rb +102 -11
  37. data/lib/tuile/component/log_text_view.rb +71 -0
  38. data/lib/tuile/component/log_window.rb +13 -48
  39. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  40. data/lib/tuile/component/menu_bar.rb +582 -0
  41. data/lib/tuile/component/notification.rb +24 -39
  42. data/lib/tuile/component/overlay.rb +192 -0
  43. data/lib/tuile/component/picker_window.rb +0 -5
  44. data/lib/tuile/component/popup.rb +61 -123
  45. data/lib/tuile/component/progress_bar.rb +1 -1
  46. data/lib/tuile/component/select.rb +17 -7
  47. data/lib/tuile/component/slot.rb +54 -0
  48. data/lib/tuile/component/tab_sheet.rb +231 -0
  49. data/lib/tuile/component/tabs.rb +528 -0
  50. data/lib/tuile/component/text_area.rb +5 -4
  51. data/lib/tuile/component/text_field.rb +23 -6
  52. data/lib/tuile/component/text_view.rb +8 -5
  53. data/lib/tuile/component/window.rb +22 -46
  54. data/lib/tuile/component.rb +186 -31
  55. data/lib/tuile/event_queue.rb +45 -1
  56. data/lib/tuile/fake_screen.rb +40 -2
  57. data/lib/tuile/keys.rb +72 -0
  58. data/lib/tuile/screen.rb +212 -113
  59. data/lib/tuile/screen_pane.rb +125 -41
  60. data/lib/tuile/styled_string.rb +80 -7
  61. data/lib/tuile/terminal_background.rb +74 -16
  62. data/lib/tuile/version.rb +1 -1
  63. data/sig/tuile.rbs +2527 -358
  64. metadata +13 -3
  65. data/mise.toml +0 -2
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A one-child region: a place in the tree reserved for content that may be
6
+ # absent, arrive late, or be swapped. The occupant fills the slot's {#rect}.
7
+ #
8
+ # Wire one per region at construction, then swap occupants through
9
+ # {HasContent#content=} — which is how {Window} holds its footer:
10
+ #
11
+ # @footer_slot = Slot.new
12
+ # add_child(@footer_slot) # the region, wired once
13
+ # …
14
+ # @footer_slot.content = new_footer # the occupant, swapped at will
15
+ #
16
+ # Because the slot never leaves {Component#children}, a swap's insert index
17
+ # is always 0 — never a function of which *other* regions happen to be
18
+ # occupied, which is the whole reason to reach for one (`D_slots`).
19
+ #
20
+ # An empty slot does not collapse: it keeps its rect and clears it, so a
21
+ # dialog with no message shows the hole. Close the gap with a zero extent
22
+ # from the parent, or assign an empty {Rect} to suppress the clear entirely
23
+ # (what {Window} does with an absent footer); never detach it.
24
+ #
25
+ # Transparent to input: not {Component#focusable?},
26
+ # {Component#handle_mouse} descends through it, and a departing occupant's
27
+ # focus repair is handed to the container.
28
+ class Slot < Component
29
+ include Component::HasContent
30
+
31
+ # @param content [Component, nil] the initial occupant.
32
+ def initialize(content = nil)
33
+ super()
34
+ @content = nil
35
+ self.content = content unless content.nil?
36
+ end
37
+
38
+ # Hands the repair to the container rather than doing it here: the default
39
+ # would move focus to `self`, and a slot is inert — no cursor, no keys,
40
+ # nothing to bubble from.
41
+ # @param child [Component] the just-detached occupant.
42
+ # @return [void]
43
+ def on_child_removed(child)
44
+ parent&.on_child_removed(child)
45
+ end
46
+
47
+ protected
48
+
49
+ # @param content [Component]
50
+ # @return [void]
51
+ def layout(content) = content.rect = rect
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,231 @@
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
+ # Sends focus to the strip: a sheet is a container, and the strip is where
170
+ # a tab switch is driven from. The pane is a Tab press away.
171
+ # @return [void]
172
+ def on_focus
173
+ super
174
+ screen.focused = @strip
175
+ end
176
+
177
+ # Lands focus on the strip rather than on `self` when the focused pane is
178
+ # swapped out — a bare container can't use keys, and the user's last
179
+ # action was a tab switch.
180
+ # @param child [Component]
181
+ # @return [void]
182
+ def on_child_removed(child)
183
+ super
184
+ screen.focused = @strip if attached? && screen.focused.equal?(self)
185
+ end
186
+
187
+ private
188
+
189
+ # Makes the visible pane match `strip.selected`, swapping if it doesn't.
190
+ # Idempotent and the sole writer of `@pane`: it derives everything from
191
+ # current state, so {#add_tab} can register a pane after the strip has
192
+ # already selected its tab.
193
+ # @return [void]
194
+ def sync_pane
195
+ forget_removed_tabs
196
+ wanted = @panes[@strip.selected]
197
+ return if wanted.equal?(@pane)
198
+
199
+ old = @pane
200
+ detach_child(old) unless old.nil?
201
+ @pane = wanted
202
+ unless wanted.nil?
203
+ add_child(wanted) # appended: the strip stays at index 0
204
+ wanted.invalidate
205
+ layout_pane
206
+ end
207
+ invalidate
208
+ on_child_removed(old) unless old.nil?
209
+ end
210
+
211
+ # Drops entries whose tab is gone. {Tabs::Tab#remove} takes a tab off the
212
+ # strip without passing through {#remove_tab}, and a detached tab can never
213
+ # be selected again, so its entry is dead weight — it pins the pane against
214
+ # garbage collection and makes {#add_tab} reject that pane as still in use.
215
+ # Idempotent and the only cleaner, because the rule it enforces is an
216
+ # invariant ("every key is a live tab of my strip") rather than a step in
217
+ # one code path.
218
+ # @return [void]
219
+ def forget_removed_tabs
220
+ @panes.delete_if { |tab, _pane| !tab.attached? }
221
+ end
222
+
223
+ # @return [void]
224
+ def layout_pane
225
+ return if @pane.nil?
226
+
227
+ @pane.rect = Rect.new(rect.left, rect.top + 1, rect.width, [rect.height - 1, 0].max)
228
+ end
229
+ end
230
+ end
231
+ end