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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/DECISIONS.md +1970 -14
- data/README.md +136 -491
- data/TERMINOLOGY.md +70 -0
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +19 -6
- data/book/03-layout.md +12 -11
- data/book/05-focus.md +133 -18
- data/book/06-theming.md +6 -3
- data/book/07-components.md +498 -38
- data/book/08-testing.md +18 -4
- data/book/README.md +7 -5
- data/examples/file_commander.rb +27 -20
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +422 -66
- data/ideas/arrow-key-navigation.md +16 -0
- data/ideas/new-components.md +16 -10
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +7 -7
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/button.rb +1 -1
- data/lib/tuile/component/checkbox.rb +1 -1
- data/lib/tuile/component/checkbox_group.rb +31 -26
- data/lib/tuile/component/combo_box.rb +13 -8
- data/lib/tuile/component/info_window.rb +1 -1
- data/lib/tuile/component/label.rb +14 -14
- data/lib/tuile/component/list.rb +313 -216
- data/lib/tuile/component/list_dropdown.rb +100 -10
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +320 -0
- data/lib/tuile/component/picker_window.rb +3 -8
- data/lib/tuile/component/popup.rb +83 -19
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +32 -30
- data/lib/tuile/component/select.rb +10 -8
- data/lib/tuile/component/tab_sheet.rb +242 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
- data/lib/tuile/component/text_area.rb +84 -277
- data/lib/tuile/component/text_field.rb +24 -7
- data/lib/tuile/component/text_view.rb +197 -180
- data/lib/tuile/component/window.rb +8 -8
- data/lib/tuile/component.rb +43 -18
- data/lib/tuile/event_queue.rb +25 -1
- data/lib/tuile/fake_screen.rb +14 -0
- data/lib/tuile/keys.rb +65 -0
- data/lib/tuile/screen.rb +95 -78
- data/lib/tuile/screen_pane.rb +109 -27
- data/lib/tuile/styled_string.rb +52 -12
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +6 -6
- data/sig/tuile.rbs +2307 -516
- metadata +9 -3
- 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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
212
|
-
# @param
|
|
214
|
+
# Adopts the chosen item as {#value} and closes the dropdown.
|
|
215
|
+
# @param item [Object]
|
|
213
216
|
# @return [void]
|
|
214
|
-
def commit(
|
|
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
|