tuile 0.13.0 → 0.15.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 +150 -37
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +4266 -226
- data/README.md +44 -24
- data/TERMINOLOGY.md +22 -7
- data/book/03-layout.md +17 -10
- data/book/05-focus.md +67 -3
- data/book/06-theming.md +153 -7
- data/book/07-components.md +643 -67
- data/book/08-testing.md +94 -0
- data/book/09-styled-text.md +3 -3
- data/book/10-locale.md +216 -0
- data/book/README.md +14 -5
- data/examples/file_commander.rb +1 -1
- data/examples/sampler.rb +402 -62
- data/ideas/arrow-key-navigation.md +2 -2
- data/ideas/binder.md +177 -0
- data/ideas/composite-field.md +77 -0
- data/ideas/focus-accent.md +116 -0
- data/ideas/form-layout.md +151 -0
- data/ideas/hover/probe.rb +241 -0
- data/ideas/hover/probe_spec.rb +82 -0
- data/ideas/hover.md +909 -0
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +49 -29
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/abstract_string_field.rb +106 -58
- data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/checkbox_group.rb +36 -20
- data/lib/tuile/component/combo_box.rb +68 -33
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/date_field.rb +322 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +43 -11
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +28 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +6 -38
- data/lib/tuile/component/layout/box.rb +87 -19
- data/lib/tuile/component/layout.rb +13 -13
- data/lib/tuile/component/list.rb +11 -6
- data/lib/tuile/component/list_dropdown.rb +22 -10
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +3 -3
- data/lib/tuile/component/menu_bar.rb +5 -5
- data/lib/tuile/component/notification.rb +16 -34
- data/lib/tuile/component/overlay.rb +209 -0
- data/lib/tuile/component/popup.rb +59 -187
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +39 -22
- data/lib/tuile/component/select.rb +26 -10
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +0 -11
- data/lib/tuile/component/tabs.rb +5 -5
- data/lib/tuile/component/text_area.rb +14 -8
- data/lib/tuile/component/text_field.rb +42 -15
- data/lib/tuile/component/text_view.rb +25 -8
- data/lib/tuile/component/time_field.rb +454 -0
- data/lib/tuile/component/window.rb +48 -59
- data/lib/tuile/component.rb +580 -54
- data/lib/tuile/event_queue.rb +21 -1
- data/lib/tuile/fake_screen.rb +37 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/keys.rb +7 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/screen.rb +251 -55
- data/lib/tuile/screen_pane.rb +50 -44
- data/lib/tuile/styled_string.rb +40 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +100 -10
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +4545 -770
- metadata +25 -1
|
@@ -26,6 +26,13 @@ module Tuile
|
|
|
26
26
|
# Nest boxes to vary the gap — a `Vertical.new(spacing: 0)` inside a
|
|
27
27
|
# `Vertical.new(spacing: 1)` groups two rows tightly within a looser stack.
|
|
28
28
|
#
|
|
29
|
+
# **Hiding a pane is {#remove}, not `Fixed[0]`** (see {Fixed} for why an
|
|
30
|
+
# empty rect is not hiding). {#add}'s `at:` is what makes it reversible —
|
|
31
|
+
# keep the index and the constraints on your side:
|
|
32
|
+
#
|
|
33
|
+
# remove(@sidebar) # hide: siblings reclaim the space
|
|
34
|
+
# add(@sidebar, Expand[1], at: 0) # show: back where it was
|
|
35
|
+
#
|
|
29
36
|
# == Implementation details
|
|
30
37
|
#
|
|
31
38
|
# Every child-list mutation re-runs the whole pass, because in a box the
|
|
@@ -98,6 +105,7 @@ module Tuile
|
|
|
98
105
|
#
|
|
99
106
|
# add(field, Fixed[1], cross: Fixed[30], align: :center)
|
|
100
107
|
# add([ok, cancel], Fixed[1])
|
|
108
|
+
# add(sidebar, Expand[1], at: 0) # back where it was, after a #remove
|
|
101
109
|
#
|
|
102
110
|
# @param child [Component, Enumerable<Component>]
|
|
103
111
|
# @param main [Fixed, Percent, Expand] extent along the main axis.
|
|
@@ -105,24 +113,58 @@ module Tuile
|
|
|
105
113
|
# @param align [Symbol] one of {ALIGNMENTS} — where a child narrower than
|
|
106
114
|
# the cross extent sits. {Vertical} / {Horizontal} say which edge
|
|
107
115
|
# `:start` is.
|
|
116
|
+
# @param at [Integer, nil] position among the existing children; appends
|
|
117
|
+
# when nil. An Enumerable is inserted in order from there. This is what
|
|
118
|
+
# makes hiding-by-{#remove} reversible — see the class doc.
|
|
108
119
|
# @raise [ArgumentError] on an unknown constraint or alignment, or an
|
|
109
120
|
# {Expand} passed as `cross` (see {Expand}).
|
|
110
121
|
# @raise [TypeError] if `child` is not a {Component}.
|
|
111
122
|
# @return [void]
|
|
112
|
-
def add(child, main = Fixed[1], cross: Percent[100], align: :start)
|
|
123
|
+
def add(child, main = Fixed[1], cross: Percent[100], align: :start, at: nil)
|
|
113
124
|
if child.is_a? Enumerable
|
|
114
|
-
child.
|
|
125
|
+
child.each_with_index { |c, i| add(c, main, cross:, align:, at: at && at + i) }
|
|
115
126
|
return
|
|
116
127
|
end
|
|
117
128
|
|
|
118
129
|
validate_main(main)
|
|
119
130
|
validate_cross(cross)
|
|
120
131
|
validate_align(align)
|
|
121
|
-
add_child(child)
|
|
132
|
+
add_child(child, at:)
|
|
122
133
|
@placements[child] = { main:, cross:, align: }
|
|
123
134
|
relayout
|
|
124
135
|
end
|
|
125
136
|
|
|
137
|
+
# Re-constrains a child already in the layout and re-runs the pass. A
|
|
138
|
+
# `nil` argument keeps what that axis already had, so one can move alone:
|
|
139
|
+
#
|
|
140
|
+
# box.constrain(sidebar, Fixed[0]) # collapse it; cross: and align: stand
|
|
141
|
+
#
|
|
142
|
+
# `Fixed[0]` *collapses* — see {Fixed} for why that is not the same as
|
|
143
|
+
# hiding, and the class doc for what is.
|
|
144
|
+
#
|
|
145
|
+
# @param child [Component] a child of this layout.
|
|
146
|
+
# @param main [Fixed, Percent, Expand, nil] extent along the main axis.
|
|
147
|
+
# @param cross [Fixed, Percent, nil] extent across it.
|
|
148
|
+
# @param align [Symbol, nil] one of {ALIGNMENTS}.
|
|
149
|
+
# @raise [ArgumentError] if `child` is not a child of this layout, or on
|
|
150
|
+
# an unknown constraint or alignment.
|
|
151
|
+
# @return [void]
|
|
152
|
+
def constrain(child, main = nil, cross: nil, align: nil)
|
|
153
|
+
raise ArgumentError, "#{child} is not a child of #{self}" unless children.any? { _1.equal?(child) }
|
|
154
|
+
|
|
155
|
+
validate_main(main) unless main.nil?
|
|
156
|
+
validate_cross(cross) unless cross.nil?
|
|
157
|
+
validate_align(align) unless align.nil?
|
|
158
|
+
|
|
159
|
+
current = placement(child)
|
|
160
|
+
updated = { main: main || current[:main], cross: cross || current[:cross],
|
|
161
|
+
align: align || current[:align] }
|
|
162
|
+
return if current == updated
|
|
163
|
+
|
|
164
|
+
@placements[child] = updated
|
|
165
|
+
relayout
|
|
166
|
+
end
|
|
167
|
+
|
|
126
168
|
# Removes the child, forgets its constraints, and closes the gap it left
|
|
127
169
|
# by re-running the layout.
|
|
128
170
|
# @param child [Component]
|
|
@@ -140,19 +182,34 @@ module Tuile
|
|
|
140
182
|
relayout
|
|
141
183
|
end
|
|
142
184
|
|
|
185
|
+
protected
|
|
186
|
+
|
|
187
|
+
# Re-divides the space: a child that went hidden gives its slot *and*
|
|
188
|
+
# the {#spacing} around it to its siblings, and one that came back takes
|
|
189
|
+
# them again with the constraints it was added with — which is what
|
|
190
|
+
# {Component#visible=} buys over `remove` plus `add(…, at:)`.
|
|
191
|
+
# @param _child [Component]
|
|
192
|
+
# @return [void]
|
|
193
|
+
def on_child_visibility_changed(_child) = relayout
|
|
194
|
+
|
|
143
195
|
private
|
|
144
196
|
|
|
145
|
-
# Recomputes and assigns every child's rect
|
|
146
|
-
#
|
|
147
|
-
#
|
|
197
|
+
# Recomputes and assigns every child's rect, giving each an empty one
|
|
198
|
+
# when this layout's own rect — or {#inner_rect} — is empty.
|
|
199
|
+
#
|
|
200
|
+
# Deliberately *no* `return if rect.empty?` guard: that strands the
|
|
201
|
+
# children at the coordinates they last had, and the next full repaint
|
|
202
|
+
# paints them there (`D_empty_ancestor`). Construction is silent without
|
|
203
|
+
# one anyway — {#add} runs before a parent assigns a rect, so the
|
|
204
|
+
# children are already empty and `invalidate` no-ops while detached.
|
|
148
205
|
# @return [void]
|
|
149
206
|
def relayout
|
|
150
|
-
return if rect.empty?
|
|
151
|
-
|
|
152
207
|
inner = inner_rect
|
|
153
|
-
|
|
154
|
-
|
|
208
|
+
collapsed = Rect.new(rect.left, rect.top, 0, 0)
|
|
209
|
+
if rect.empty? || inner.empty?
|
|
210
|
+
children.each { _1.rect = collapsed }
|
|
155
211
|
else
|
|
212
|
+
children.each { _1.rect = collapsed unless _1.visible? }
|
|
156
213
|
place_children(inner)
|
|
157
214
|
end
|
|
158
215
|
invalidate
|
|
@@ -168,20 +225,30 @@ module Tuile
|
|
|
168
225
|
# @param inner [Rect] {#inner_rect}, known non-empty.
|
|
169
226
|
# @return [void]
|
|
170
227
|
def place_children(inner)
|
|
171
|
-
|
|
228
|
+
kids = shown_children
|
|
229
|
+
sizes = main_sizes(inner, kids)
|
|
172
230
|
available = cross_extent(inner)
|
|
173
231
|
offset = 0
|
|
174
|
-
|
|
232
|
+
kids.each_with_index do |child, i|
|
|
175
233
|
cross_offset, cross_size = cross_placement(child, available)
|
|
176
234
|
child.rect = build_rect(inner, offset, sizes[i], cross_offset, cross_size)
|
|
177
235
|
offset += sizes[i] + spacing
|
|
178
236
|
end
|
|
179
237
|
end
|
|
180
238
|
|
|
239
|
+
# The children this pass divides space between — a hidden one is out of
|
|
240
|
+
# the {#spacing} count as well as the arithmetic, so hiding a middle
|
|
241
|
+
# child closes the row up rather than leaving a double gap. Contrast a
|
|
242
|
+
# `Fixed[0]` child, still a member of the sequence and still paying for
|
|
243
|
+
# its gap (`D_visibility`).
|
|
244
|
+
# @return [Array<Component>]
|
|
245
|
+
def shown_children = children.select(&:visible?)
|
|
246
|
+
|
|
181
247
|
# @param inner [Rect] {#inner_rect}.
|
|
182
|
-
# @
|
|
183
|
-
|
|
184
|
-
|
|
248
|
+
# @param kids [Array<Component>] {#shown_children}.
|
|
249
|
+
# @return [Array<Integer>] main-axis extent per shown child, in order.
|
|
250
|
+
def main_sizes(inner, kids)
|
|
251
|
+
count = kids.size
|
|
185
252
|
return [] if count.zero?
|
|
186
253
|
|
|
187
254
|
available = [main_extent(inner) - (spacing * (count - 1)), 0].max
|
|
@@ -189,7 +256,7 @@ module Tuile
|
|
|
189
256
|
expanding = []
|
|
190
257
|
unassigned = available
|
|
191
258
|
|
|
192
|
-
|
|
259
|
+
kids.each_with_index do |child, i|
|
|
193
260
|
case (constraint = placement(child)[:main])
|
|
194
261
|
when Expand then expanding << i
|
|
195
262
|
when Fixed then unassigned -= (sizes[i] = constraint.cells.clamp(0, unassigned))
|
|
@@ -197,19 +264,20 @@ module Tuile
|
|
|
197
264
|
end
|
|
198
265
|
end
|
|
199
266
|
|
|
200
|
-
distribute_expand(sizes, expanding, unassigned) unless expanding.empty?
|
|
267
|
+
distribute_expand(sizes, kids, expanding, unassigned) unless expanding.empty?
|
|
201
268
|
sizes
|
|
202
269
|
end
|
|
203
270
|
|
|
204
271
|
# Splits `slack` between the {Expand} children by weight, writing the
|
|
205
272
|
# results into `sizes`.
|
|
206
273
|
# @param sizes [Array<Integer>] mutated in place.
|
|
274
|
+
# @param kids [Array<Component>] {#shown_children}, which `indices` index.
|
|
207
275
|
# @param indices [Array<Integer>] child indices carrying an {Expand}.
|
|
208
276
|
# @param slack [Integer] cells left over; a negative value yields zeroes.
|
|
209
277
|
# @return [void]
|
|
210
|
-
def distribute_expand(sizes, indices, slack)
|
|
278
|
+
def distribute_expand(sizes, kids, indices, slack)
|
|
211
279
|
slack = 0 if slack.negative?
|
|
212
|
-
weights = indices.map { placement(
|
|
280
|
+
weights = indices.map { placement(kids[_1])[:main].weight }
|
|
213
281
|
total = weights.sum
|
|
214
282
|
shares = weights.map { slack * _1 / total }
|
|
215
283
|
# Under one cell is lost per floor, so the remainder can't outrun the
|
|
@@ -23,7 +23,14 @@ module Tuile
|
|
|
23
23
|
# add(prompt, Fixed[4]) # 4 rows in a Vertical
|
|
24
24
|
# add(field, Fixed[1], cross: Fixed[30]) # 1 row, 30 columns wide
|
|
25
25
|
#
|
|
26
|
-
# `Fixed[0]`
|
|
26
|
+
# `Fixed[0]` *collapses* the child: it and its whole subtree get an empty
|
|
27
|
+
# rect and paint nothing. That is not the same as hiding it — an empty
|
|
28
|
+
# rect is a paint convention and gates nothing else, so the subtree keeps
|
|
29
|
+
# its tab stops and still takes keys. **To hide a child, set
|
|
30
|
+
# {Component#visible=} false**; it then costs no {Box#spacing} gap either,
|
|
31
|
+
# where a collapsed child still does. The difference is exactly that: a
|
|
32
|
+
# collapsed child is still a member of the sequence, a hidden one is not
|
|
33
|
+
# (`D_visibility`).
|
|
27
34
|
#
|
|
28
35
|
# @!attribute [r] cells
|
|
29
36
|
# @return [Integer] cell count along the axis.
|
|
@@ -190,16 +197,6 @@ module Tuile
|
|
|
190
197
|
invalidate if @children.empty? # nothing left to paint over the gap
|
|
191
198
|
end
|
|
192
199
|
|
|
193
|
-
# Dispatches the event to the child under the mouse cursor.
|
|
194
|
-
# @param event [MouseEvent]
|
|
195
|
-
# @return [void]
|
|
196
|
-
def handle_mouse(event)
|
|
197
|
-
super
|
|
198
|
-
@children.each do |child|
|
|
199
|
-
child.handle_mouse(event) if child.rect.contains?(event.point)
|
|
200
|
-
end
|
|
201
|
-
end
|
|
202
|
-
|
|
203
200
|
# @return [void]
|
|
204
201
|
def on_focus
|
|
205
202
|
super
|
|
@@ -209,12 +206,15 @@ module Tuile
|
|
|
209
206
|
# containers like a {Window} or another {Layout}. Fall back to the
|
|
210
207
|
# first focusable direct child for the rare case where the layout has
|
|
211
208
|
# focusable but non-tab-stop children (e.g. an empty {Window}).
|
|
209
|
+
#
|
|
210
|
+
# Both halves skip hidden subtrees — this is the cascade that would
|
|
211
|
+
# otherwise walk straight back into the pane just hidden.
|
|
212
212
|
first_tab_stop = nil
|
|
213
|
-
|
|
213
|
+
on_shown_tree { |c| first_tab_stop ||= c if !c.equal?(self) && c.tab_stop? }
|
|
214
214
|
if first_tab_stop
|
|
215
215
|
screen.focused = first_tab_stop
|
|
216
216
|
else
|
|
217
|
-
first_focusable = @children.find
|
|
217
|
+
first_focusable = @children.find { _1.visible? && _1.focusable? }
|
|
218
218
|
screen.focused = first_focusable unless first_focusable.nil?
|
|
219
219
|
end
|
|
220
220
|
end
|
data/lib/tuile/component/list.rb
CHANGED
|
@@ -564,7 +564,7 @@ module Tuile
|
|
|
564
564
|
protected
|
|
565
565
|
|
|
566
566
|
# Drops the rendered-row cache when the wrap width changes. The wrap
|
|
567
|
-
# width depends on {#rect}`.width` and the scrollbar
|
|
567
|
+
# width depends on {#rect}`.width` and the scrollbar column, both of
|
|
568
568
|
# which trigger this hook. Also re-evaluates {#auto_scroll}: if items were
|
|
569
569
|
# assigned while the rect was empty (e.g. a {Popup}-wrapped list was
|
|
570
570
|
# populated before the popup was opened), the auto-scroll update
|
|
@@ -766,7 +766,7 @@ module Tuile
|
|
|
766
766
|
end
|
|
767
767
|
|
|
768
768
|
# @return [Integer] column width available for row content (rect width
|
|
769
|
-
# minus the scrollbar
|
|
769
|
+
# minus the scrollbar column, when visible). `0` when {#rect}'s width
|
|
770
770
|
# is non-positive.
|
|
771
771
|
def content_width
|
|
772
772
|
return 0 if rect.width <= 0
|
|
@@ -809,9 +809,11 @@ module Tuile
|
|
|
809
809
|
rendered.lines.first
|
|
810
810
|
end
|
|
811
811
|
|
|
812
|
-
# Pads `row` to one full row of the viewport (scrollbar
|
|
813
|
-
# excluded)
|
|
814
|
-
#
|
|
812
|
+
# Pads `row` to one full row of the viewport (scrollbar column
|
|
813
|
+
# excluded), including the one-column gutter either side — the trailing
|
|
814
|
+
# one is what keeps a row off the bar, as {TextView#scrollbar_columns}
|
|
815
|
+
# reserves separately. Rows wider than the content area are ellipsized
|
|
816
|
+
# via {StyledString#ellipsize} (span styles survive the cut); shorter
|
|
815
817
|
# ones are padded with default-styled spaces.
|
|
816
818
|
# @param row [StyledString]
|
|
817
819
|
# @return [StyledString] exactly {#content_width} display columns wide
|
|
@@ -837,7 +839,10 @@ module Tuile
|
|
|
837
839
|
base = index < @items.size ? padded_row(index) : blank_row
|
|
838
840
|
is_cursor = (active? || @show_cursor_when_inactive) && index < @items.size && @cursor.position == index
|
|
839
841
|
styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
|
|
840
|
-
|
|
842
|
+
if scrollbar
|
|
843
|
+
styled += StyledString.styled(scrollbar.scrollbar_char(row_in_viewport),
|
|
844
|
+
fg: screen.theme.scrollbar_color)
|
|
845
|
+
end
|
|
841
846
|
styled
|
|
842
847
|
end
|
|
843
848
|
end
|
|
@@ -4,7 +4,7 @@ module Tuile
|
|
|
4
4
|
class Component
|
|
5
5
|
# A borderless, tinted, non-focusable floating selection list — the dropdown
|
|
6
6
|
# a *driver* drops open, drives by forwarding movement keys, and commits a
|
|
7
|
-
# pick from:
|
|
7
|
+
# pick from: an {Overlay} wrapping a {List} that never takes focus, so
|
|
8
8
|
# focus stays on the driver while the caller refills the rows, moves the
|
|
9
9
|
# highlight, and reads the pick.
|
|
10
10
|
#
|
|
@@ -34,7 +34,7 @@ module Tuile
|
|
|
34
34
|
# different tint (a `Theme.ref(:token)` keeps the flip-tracking).
|
|
35
35
|
#
|
|
36
36
|
# UI-thread-confined, like every component (see {Screen}).
|
|
37
|
-
class ListDropdown <
|
|
37
|
+
class ListDropdown < Overlay
|
|
38
38
|
# The dropdown's {List}. Non-focusable on purpose: the driver forwards keys
|
|
39
39
|
# while focus stays on it, and a mouse click selects an item without
|
|
40
40
|
# stealing focus — so a driving text input never loses its caret
|
|
@@ -51,6 +51,10 @@ module Tuile
|
|
|
51
51
|
# caret; {Select} would spend a branch on what a second arrow press already
|
|
52
52
|
# does) — and Enter/ESC, which carry driver-specific tails (see the class
|
|
53
53
|
# docs).
|
|
54
|
+
#
|
|
55
|
+
# A driver only ever sees the keys its own children decline, so a
|
|
56
|
+
# {ComboBox} never gets Ctrl+U — its field claims it to clear the query —
|
|
57
|
+
# while {Select}, wrapping no editor, gets every one of these.
|
|
54
58
|
# @return [Array<String>]
|
|
55
59
|
MOVE_KEYS = [Keys::UP_ARROW, Keys::DOWN_ARROW, Keys::PAGE_UP, Keys::PAGE_DOWN,
|
|
56
60
|
Keys::CTRL_U, Keys::CTRL_D].freeze
|
|
@@ -64,7 +68,7 @@ module Tuile
|
|
|
64
68
|
@list = Menu.new
|
|
65
69
|
@list.cursor = List::Cursor.new
|
|
66
70
|
@list.show_cursor_when_inactive = true # highlight the selection though focus stays on the driver
|
|
67
|
-
super(content: @list
|
|
71
|
+
super(content: @list)
|
|
68
72
|
self.bg_color = Theme.ref(:input_bg_color)
|
|
69
73
|
end
|
|
70
74
|
|
|
@@ -124,7 +128,16 @@ module Tuile
|
|
|
124
128
|
# Vertical flips but horizontal slides because covering the driver would
|
|
125
129
|
# hide what is being chosen, while sharing its columns is the point.
|
|
126
130
|
#
|
|
127
|
-
#
|
|
131
|
+
# **`anchor` is the region actually occupied, and may be taller than one
|
|
132
|
+
# row** — "beneath" means the row *after* it, so a multi-row driver (a
|
|
133
|
+
# {Component::TextArea} carrying an autocomplete menu) is cleared entirely
|
|
134
|
+
# rather than overdrawn from its second row down. A widget that paints one
|
|
135
|
+
# row but may be *assigned* more height passes its face, not its rect:
|
|
136
|
+
# {ComboBox} and {Select} both do, since a {Window} content slot hands them
|
|
137
|
+
# the full inner height.
|
|
138
|
+
#
|
|
139
|
+
# @param anchor [Rect] the region the driver occupies, of any height; the
|
|
140
|
+
# dropdown never covers it.
|
|
128
141
|
# @param rows [Integer] how many rows there are to show — the content
|
|
129
142
|
# count, not the height: more than fits turns the scrollbar on. `0`
|
|
130
143
|
# collapses the dropdown to an empty rect (drivers close instead).
|
|
@@ -136,20 +149,20 @@ module Tuile
|
|
|
136
149
|
# @return [void]
|
|
137
150
|
def anchor_to(anchor, rows:, width: anchor.width, max_rows: MAX_VISIBLE_ROWS)
|
|
138
151
|
desired = [rows, max_rows].min
|
|
139
|
-
|
|
152
|
+
beneath = anchor.top + anchor.height
|
|
153
|
+
below = screen.size.height - beneath
|
|
140
154
|
above = anchor.top
|
|
141
155
|
if desired <= below
|
|
142
|
-
top =
|
|
156
|
+
top = beneath
|
|
143
157
|
height = desired
|
|
144
158
|
elsif above >= below
|
|
145
159
|
height = [desired, above].min
|
|
146
160
|
top = anchor.top - height
|
|
147
161
|
else
|
|
148
162
|
height = below
|
|
149
|
-
top =
|
|
163
|
+
top = beneath
|
|
150
164
|
end
|
|
151
165
|
width = [width, screen.size.width].min
|
|
152
|
-
self.size = Size.new(width, height)
|
|
153
166
|
self.rect = Rect.new([anchor.left, screen.size.width - width].min.clamp(0, nil), top, width, height)
|
|
154
167
|
# After the geometry: the setter rebuilds the list's padded rows against
|
|
155
168
|
# the width it can see, and the gutter takes a column off it.
|
|
@@ -182,7 +195,7 @@ module Tuile
|
|
|
182
195
|
# @param width [Integer] the panel's width in columns, clamped to the
|
|
183
196
|
# screen. **Required, with no default:** `anchor.width` is the *parent's*
|
|
184
197
|
# width and would be meaningless here, so the caller measures (see
|
|
185
|
-
# `DECISIONS.md` `
|
|
198
|
+
# `DECISIONS.md` `D_select` on why the width policy stays with the
|
|
186
199
|
# driver).
|
|
187
200
|
# @param max_rows [Integer] rows shown before the list scrolls.
|
|
188
201
|
# @return [void]
|
|
@@ -197,7 +210,6 @@ module Tuile
|
|
|
197
210
|
end
|
|
198
211
|
left = left.clamp(0, [screen.size.width - width, 0].max)
|
|
199
212
|
top = [anchor.top, screen.size.height - height].min.clamp(0, nil)
|
|
200
|
-
self.size = Size.new(width, height)
|
|
201
213
|
self.rect = Rect.new(left, top, width, height)
|
|
202
214
|
# After the geometry, as in {#anchor_to}: the setter rebuilds the list's
|
|
203
215
|
# padded rows against the width it can see.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A {TextView} purpose-built for log output: auto-scroll is on, the
|
|
6
|
+
# scrollbar is visible, and lines arrive via {#log} — from any thread.
|
|
7
|
+
# Point a logger at a {LogTextView::IO} to route its lines here:
|
|
8
|
+
#
|
|
9
|
+
# view = Tuile::Component::LogTextView.new
|
|
10
|
+
# logger = Logger.new(Tuile::Component::LogTextView::IO.new(view))
|
|
11
|
+
# logger.info("started") # appears in the view, from any thread
|
|
12
|
+
#
|
|
13
|
+
# Any logger that writes formatted lines to an IO works the same way —
|
|
14
|
+
# for example `TTY::Logger` configured with the `:console` handler and
|
|
15
|
+
# `output: LogTextView::IO.new(view)`.
|
|
16
|
+
#
|
|
17
|
+
# Add it to a layout as-is for a frameless log pane, or use {LogWindow}
|
|
18
|
+
# for the framed assembly. A {TextView} rather than a {List}: long lines
|
|
19
|
+
# (stacktraces, wide log records) word-wrap rather than ellipsize — a
|
|
20
|
+
# truncated log line hides the very detail you opened the log to read.
|
|
21
|
+
class LogTextView < TextView
|
|
22
|
+
def initialize
|
|
23
|
+
super
|
|
24
|
+
self.auto_scroll = true
|
|
25
|
+
self.scrollbar_visibility = :visible
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Appends the given line to the log. Safe to call from any thread —
|
|
29
|
+
# the one exception to the UI-thread confinement every component
|
|
30
|
+
# carries: the append is marshalled through `event_queue.submit`, so
|
|
31
|
+
# it lands once a loop drains the queue (deferred before the first
|
|
32
|
+
# loop; silently dropped after the loop has returned).
|
|
33
|
+
# @param string [String, nil] the line (or multiple lines) to log;
|
|
34
|
+
# `nil` is a no-op.
|
|
35
|
+
# @return [void]
|
|
36
|
+
def log(string)
|
|
37
|
+
return if string.nil?
|
|
38
|
+
|
|
39
|
+
screen.event_queue.submit { add_line(string) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# IO-shaped adapter that forwards each log line to its sink's `#log`.
|
|
43
|
+
# Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
|
|
44
|
+
# call `output.puts`, e.g. `TTY::Logger`).
|
|
45
|
+
class IO
|
|
46
|
+
# @param sink [LogTextView, LogWindow] anything responding to `#log`.
|
|
47
|
+
def initialize(sink)
|
|
48
|
+
@sink = sink
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param string [String]
|
|
52
|
+
# @return [void]
|
|
53
|
+
def write(string)
|
|
54
|
+
@sink.log(string.chomp)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param string [String]
|
|
58
|
+
# @return [void]
|
|
59
|
+
def puts(string)
|
|
60
|
+
@sink.log(string)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Stdlib `Logger` only treats an object as an IO target when it
|
|
64
|
+
# responds to both {#write} and {#close}; otherwise it tries to
|
|
65
|
+
# interpret it as a filename. This is a no-op.
|
|
66
|
+
# @return [void]
|
|
67
|
+
def close; end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -2,65 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
# A {Window} framing a {LogTextView} — the framed log pane. All the log
|
|
6
|
+
# behavior lives on the view (see {LogTextView} for the adapter and the
|
|
7
|
+
# wrap rationale); the window adds the border, caption and delegation:
|
|
7
8
|
#
|
|
8
9
|
# log_window = Tuile::Component::LogWindow.new
|
|
9
10
|
# logger = Logger.new(Tuile::Component::LogWindow::IO.new(log_window))
|
|
10
|
-
#
|
|
11
|
-
# Any logger that writes formatted lines to an IO works the same way —
|
|
12
|
-
# for example `TTY::Logger` configured with the `:console` handler and
|
|
13
|
-
# `output: LogWindow::IO.new(window)`.
|
|
14
11
|
class LogWindow < Window
|
|
12
|
+
# Alias of {LogTextView::IO}, which accepts the window itself as its sink.
|
|
13
|
+
# @return [Class]
|
|
14
|
+
IO = LogTextView::IO
|
|
15
|
+
|
|
15
16
|
# @param caption [String]
|
|
16
17
|
def initialize(caption = "Log")
|
|
17
18
|
super
|
|
18
|
-
|
|
19
|
-
# Word-wrap long lines (stacktraces, wide log records) rather than
|
|
20
|
-
# ellipsizing them as a {List} would — a truncated log line hides the
|
|
21
|
-
# very detail you opened the log to read.
|
|
22
|
-
view.auto_scroll = true
|
|
23
|
-
self.content = view
|
|
24
|
-
self.scrollbar = true
|
|
19
|
+
self.content = LogTextView.new
|
|
25
20
|
end
|
|
26
21
|
|
|
27
|
-
# Appends given line to the log
|
|
28
|
-
#
|
|
22
|
+
# Appends the given line to the log, from any thread — delegates to
|
|
23
|
+
# {LogTextView#log}.
|
|
24
|
+
# @param string [String, nil] the line (or multiple lines) to log;
|
|
25
|
+
# `nil` is a no-op.
|
|
29
26
|
# @return [void]
|
|
30
27
|
def log(string)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
screen.event_queue.submit do
|
|
34
|
-
content.add_line(string)
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# IO-shaped adapter that forwards each log line to the owning {LogWindow}.
|
|
39
|
-
# Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
|
|
40
|
-
# call `output.puts`, e.g. `TTY::Logger`).
|
|
41
|
-
class IO
|
|
42
|
-
# @param window [LogWindow]
|
|
43
|
-
def initialize(window)
|
|
44
|
-
@window = window
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# @param string [String]
|
|
48
|
-
# @return [void]
|
|
49
|
-
def write(string)
|
|
50
|
-
@window.log(string.chomp)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# @param string [String]
|
|
54
|
-
# @return [void]
|
|
55
|
-
def puts(string)
|
|
56
|
-
@window.log(string)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Stdlib `Logger` only treats an object as an IO target when it
|
|
60
|
-
# responds to both {#write} and {#close}; otherwise it tries to
|
|
61
|
-
# interpret it as a filename. This is a no-op.
|
|
62
|
-
# @return [void]
|
|
63
|
-
def close; end
|
|
28
|
+
content.log(string)
|
|
64
29
|
end
|
|
65
30
|
end
|
|
66
31
|
end
|
|
@@ -19,7 +19,7 @@ module Tuile
|
|
|
19
19
|
#
|
|
20
20
|
# Widths are measured here, per level — the panel is as wide as the level's
|
|
21
21
|
# widest label — because {ListDropdown} deliberately measures nothing
|
|
22
|
-
# itself (`DECISIONS.md` `
|
|
22
|
+
# itself (`DECISIONS.md` `D_select`).
|
|
23
23
|
#
|
|
24
24
|
# == Implementation details
|
|
25
25
|
# While open it consumes **everything** except the two keys that mean
|
|
@@ -33,7 +33,7 @@ module Tuile
|
|
|
33
33
|
# The affordance painted on a row that opens a submenu. U+25B8 rather
|
|
34
34
|
# than the obvious `▶`: like {Component::Select}'s `▾` it is East-Asian
|
|
35
35
|
# **Neutral**, so it measures one column even under ambiguous-as-wide and
|
|
36
|
-
# stays outside `
|
|
36
|
+
# stays outside `D_ambiguous_width`'s bet, where `▶` and `▼` are
|
|
37
37
|
# Ambiguous and would need an ASCII opt-in.
|
|
38
38
|
# @return [String]
|
|
39
39
|
SUBMENU_ARROW = "▸"
|
|
@@ -179,7 +179,7 @@ module Tuile
|
|
|
179
179
|
drop.on_cursor_changed = ->(_index, _child) { truncate(level + 1) }
|
|
180
180
|
# The cascade's own record of what is open is reconciled from the
|
|
181
181
|
# popup's own closure, not maintained alongside it: an outside click
|
|
182
|
-
# closes panels behind our back ({
|
|
182
|
+
# closes panels behind our back ({Overlay#close_on_outside_click?}), and
|
|
183
183
|
# a level left in `@levels` after its panel is gone would have `depth`,
|
|
184
184
|
# `deepest` and `highlighted` all lying. Identity-keyed and idempotent,
|
|
185
185
|
# because the notice also arrives from `truncate` (which has already
|
|
@@ -43,7 +43,7 @@ module Tuile
|
|
|
43
43
|
# {Item} handles are minted by {#add_item} and nest via the *same* method, so
|
|
44
44
|
# depth is unlimited. There is no removal, no reordering and no dynamic
|
|
45
45
|
# rebuilding: a menu is built once, at construction. See `DECISIONS.md`
|
|
46
|
-
# `
|
|
46
|
+
# `D_menu_bar`.
|
|
47
47
|
#
|
|
48
48
|
# == Sizing
|
|
49
49
|
# Assign a {#rect} (typically one {Layout::Fixed}`[1]` row at the top of a
|
|
@@ -244,11 +244,11 @@ module Tuile
|
|
|
244
244
|
# tail — or on a lower row, when the rect is taller than one — opens
|
|
245
245
|
# nothing. It still *focuses*: {Component#handle_mouse}'s click-to-focus is
|
|
246
246
|
# ungated by geometry.
|
|
247
|
-
# @return [
|
|
247
|
+
# @return [Size]
|
|
248
248
|
def extent
|
|
249
|
-
return
|
|
249
|
+
return Size.new(0, 1) if rect.empty?
|
|
250
250
|
|
|
251
|
-
|
|
251
|
+
Size.new([painted_width - @left_column, rect.width].min, 1)
|
|
252
252
|
end
|
|
253
253
|
|
|
254
254
|
# @param new_rect [Rect]
|
|
@@ -470,7 +470,7 @@ module Tuile
|
|
|
470
470
|
# @return [Integer, nil] the index of the item painted at `point`; `nil`
|
|
471
471
|
# for the blank tail or a row the strip doesn't paint.
|
|
472
472
|
def index_at(point)
|
|
473
|
-
return nil unless
|
|
473
|
+
return nil unless extent_rect.contains?(point)
|
|
474
474
|
|
|
475
475
|
column = point.x - rect.left + @left_column
|
|
476
476
|
segments.index { |_item, start, width| column >= start && column < start + width }
|