tuile 0.14.0 → 0.16.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -49
  3. data/README.md +53 -17
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +152 -22
  6. data/book/06-theming.md +105 -25
  7. data/book/07-components.md +531 -50
  8. data/book/08-testing.md +100 -20
  9. data/book/10-locale.md +216 -0
  10. data/book/README.md +19 -9
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +654 -40
  14. data/lib/tuile/component/abstract_string_field.rb +114 -68
  15. data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
  16. data/lib/tuile/component/big_decimal_field.rb +52 -79
  17. data/lib/tuile/component/button.rb +8 -8
  18. data/lib/tuile/component/checkbox.rb +9 -9
  19. data/lib/tuile/component/checkbox_group.rb +38 -21
  20. data/lib/tuile/component/combo_box.rb +102 -59
  21. data/lib/tuile/component/confirm_window.rb +7 -5
  22. data/lib/tuile/component/date_field.rb +347 -0
  23. data/lib/tuile/component/date_time_field.rb +275 -0
  24. data/lib/tuile/component/float_field.rb +57 -82
  25. data/lib/tuile/component/has_bad_input.rb +88 -0
  26. data/lib/tuile/component/has_caption.rb +8 -0
  27. data/lib/tuile/component/has_content.rb +32 -13
  28. data/lib/tuile/component/has_placeholder.rb +62 -0
  29. data/lib/tuile/component/has_validation.rb +115 -0
  30. data/lib/tuile/component/has_value.rb +28 -1
  31. data/lib/tuile/component/integer_field.rb +51 -78
  32. data/lib/tuile/component/label.rb +7 -39
  33. data/lib/tuile/component/layout/box.rb +90 -19
  34. data/lib/tuile/component/layout.rb +15 -5
  35. data/lib/tuile/component/list.rb +53 -38
  36. data/lib/tuile/component/list_dropdown.rb +7 -3
  37. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  38. data/lib/tuile/component/menu_bar.rb +18 -18
  39. data/lib/tuile/component/notification.rb +32 -18
  40. data/lib/tuile/component/overlay.rb +26 -8
  41. data/lib/tuile/component/picker_window.rb +27 -8
  42. data/lib/tuile/component/popup.rb +2 -2
  43. data/lib/tuile/component/progress_bar.rb +10 -4
  44. data/lib/tuile/component/radio_group.rb +41 -23
  45. data/lib/tuile/component/select.rb +23 -16
  46. data/lib/tuile/component/slot.rb +3 -3
  47. data/lib/tuile/component/tab_sheet.rb +6 -6
  48. data/lib/tuile/component/tabs.rb +11 -11
  49. data/lib/tuile/component/text_area.rb +26 -18
  50. data/lib/tuile/component/text_field.rb +55 -26
  51. data/lib/tuile/component/text_view.rb +40 -19
  52. data/lib/tuile/component/time_field.rb +479 -0
  53. data/lib/tuile/component/window.rb +26 -13
  54. data/lib/tuile/component.rb +635 -131
  55. data/lib/tuile/event_queue.rb +4 -4
  56. data/lib/tuile/fake_event_queue.rb +1 -1
  57. data/lib/tuile/fake_screen.rb +95 -3
  58. data/lib/tuile/final.rb +75 -0
  59. data/lib/tuile/locale.rb +851 -0
  60. data/lib/tuile/mouse/router.rb +217 -0
  61. data/lib/tuile/mouse.rb +177 -0
  62. data/lib/tuile/screen.rb +219 -68
  63. data/lib/tuile/screen_pane.rb +51 -42
  64. data/lib/tuile/styled_string.rb +5 -5
  65. data/lib/tuile/testing.rb +198 -0
  66. data/lib/tuile/theme.rb +110 -32
  67. data/lib/tuile/version.rb +1 -1
  68. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  69. data/lib/tuile.rb +1 -0
  70. data/sig/tuile.rbs +4595 -825
  71. metadata +14 -9
  72. data/COMPARISON.md +0 -101
  73. data/DECISIONS.md +0 -5422
  74. data/TERMINOLOGY.md +0 -71
  75. data/ideas/arrow-key-navigation.md +0 -221
  76. data/ideas/modal-backdrop.md +0 -24
  77. data/ideas/new-components.md +0 -124
  78. data/ideas/per-component-buffers.md +0 -55
  79. data/lib/tuile/mouse_event.rb +0 -68
@@ -3,10 +3,11 @@
3
3
  module Tuile
4
4
  class Component
5
5
  # A single-line field whose {#value} is an `Integer` (or `nil` when empty).
6
- # The user may type only `0`–`9` and a single leading `-`; anything else is
7
- # silently rejected without moving the caret. Up/Down step the value by one
8
- # (an empty field counting as `0`). An empty or otherwise un-parseable
9
- # buffer reads back as `nil`:
6
+ # The buffer only ever holds `0`–`9` and a single leading `-`: a key that
7
+ # would break that is dropped without moving the caret, and so is a *paste*
8
+ # that would (`"12abc34"` lands nothing, rather than sieving through as
9
+ # `"1234"`). Up/Down step the value by one (an empty field counting as `0`).
10
+ # An empty or otherwise un-parseable buffer reads back as `nil`:
10
11
  #
11
12
  # field = Component::IntegerField.new
12
13
  # field.on_value_change = ->(n) { puts n.inspect } # Integer or nil, per change
@@ -14,11 +15,10 @@ module Tuile
14
15
  # field.value # => 42
15
16
  # field.clear # empties it; value => nil
16
17
  #
17
- # Like {ComboBox}, it *composes* a {TextField} (its single {HasContent}
18
- # child) rather than subclassing one — its face carries only the typed
19
- # {HasValue} value seam, never the widget's `String`-typed `text`. It's the
20
- # same wrapper shape as {ComboBox} minus the dropdown: a digit-filtered text
21
- # field re-exposed as a typed input. Give it a single-row {#rect}.
18
+ # It *wraps* a {TextField} rather than subclassing one, so its face carries
19
+ # only the typed {HasValue} value seam and never the widget's `String`-typed
20
+ # `text`; {AbstractWrappingField} supplies the wrapping. Give it a
21
+ # single-row {#rect}.
22
22
  #
23
23
  # == The value is a *derived parse* of the buffer
24
24
  # {#value} is `Integer(buffer, 10)` (or `nil`), recomputed on read — the
@@ -32,23 +32,49 @@ module Tuile
32
32
  # scope (range and formatting are a forms concern).
33
33
  #
34
34
  # UI-thread-confined, like every component (see {Screen}).
35
- class IntegerField < Component
36
- include HasContent
37
- include HasValue
35
+ class IntegerField < AbstractWrappingField
36
+ include HasBadInput
37
+
38
+ # @return [String] what {#bad_input_message} reports for a buffer that is
39
+ # typeable but not an integer.
40
+ BAD_INPUT_MESSAGE = "not a whole number"
41
+ private_constant :BAD_INPUT_MESSAGE
42
+
43
+ # The face: a {TextField} that admits only the buffers an integer can be
44
+ # typed through, however the characters arrive.
45
+ class Field < TextField
46
+ # Buffers reachable by typing an integer: an optional leading `-`, then
47
+ # digits. Looser than the parse on purpose — the half-typed `""` and
48
+ # `"-"` are members, or no value could be typed at all, and
49
+ # {IntegerField#value} reads both back as nil.
50
+ # @return [Regexp]
51
+ TYPEABLE = /\A-?\d*\z/
52
+ private_constant :TYPEABLE
53
+
54
+ protected
55
+
56
+ # Accepts the insertion only if the whole resulting buffer is still
57
+ # typeable, so `"12abc34"` is dropped rather than sieved into `"1234"`.
58
+ # @param str [String]
59
+ # @return [Boolean] true if the text changed.
60
+ def insert_text(str)
61
+ return false unless TYPEABLE.match?(@text.dup.insert(@caret, str))
62
+
63
+ super
64
+ end
65
+ end
38
66
 
39
67
  def initialize
40
- super()
41
- @last_value = nil
42
- field = TextField.new
43
- field.on_change = ->(_text) { fire_if_changed }
44
- field.on_key = method(:field_key)
45
- self.content = field
68
+ super(Field.new)
69
+ # Not the general on_key interceptor: that slot stays free for the app.
70
+ editor.on_key_up = -> { step(1) }
71
+ editor.on_key_down = -> { step(-1) }
46
72
  end
47
73
 
48
74
  # @return [Integer, nil] the parsed buffer; `nil` when empty or not a
49
75
  # valid integer (e.g. a lone `"-"`).
50
76
  def value
51
- Integer(content.text, 10)
77
+ Integer(editor.text, 10)
52
78
  rescue ArgumentError
53
79
  nil
54
80
  end
@@ -58,78 +84,25 @@ module Tuile
58
84
  # @param new_value [Integer, nil] `nil` empties the field.
59
85
  # @return [void]
60
86
  def value=(new_value)
61
- content.text = new_value.nil? ? "" : new_value.to_s
62
- content.caret = content.text.length
87
+ editor.text = new_value.nil? ? "" : new_value.to_s
88
+ editor.caret = editor.text.length
63
89
  end
64
90
 
65
91
  # `nil`, not `""`: an integer field with no parseable number is empty.
66
92
  # @return [nil]
67
93
  def empty_value = nil
68
94
 
69
- # @return [Point, nil] the field's caret (the hardware cursor is delegated
70
- # to the inner field).
71
- def cursor_position = content.cursor_position
72
-
73
- # Fired when ENTER is pressed in the field; see {TextField#on_enter}.
74
- # @return [Proc, Method, nil] no-arg callable, or nil.
75
- def on_enter = content.on_enter
76
-
77
- # @param callback [Proc, Method, nil]
78
- # @return [void]
79
- def on_enter=(callback)
80
- content.on_enter = callback
81
- end
82
-
83
- protected
84
-
85
- # Places the wrapped field across the whole rect ({HasContent} hook).
86
- # @param field [Component]
87
- # @return [void]
88
- def layout(field) = (field.rect = rect)
95
+ # The lone `"-"` the filter has to admit is the field's whole bad-input
96
+ # residue; an *empty* buffer is empty, not bad ({HasBadInput}).
97
+ # @return [String, nil]
98
+ def bad_input_message = value.nil? && !editor.text.empty? ? BAD_INPUT_MESSAGE : nil
89
99
 
90
100
  private
91
101
 
92
- # The field's key interceptor, consulted *before* the field acts on the
93
- # key: Up/Down step the value; a printable key the field mustn't accept is
94
- # swallowed (so a rejected key never moves the caret); everything else —
95
- # digits, the leading sign, and all editing/navigation keys — falls
96
- # through.
97
- # @param key [String]
98
- # @return [Boolean] true to consume the key.
99
- def field_key(key)
100
- case key
101
- when Keys::UP_ARROW then step(1)
102
- when Keys::DOWN_ARROW then step(-1)
103
- else return Keys.printable?(key) && !accepts?(key)
104
- end
105
- true
106
- end
107
-
108
102
  # Nudges {#value} by `delta`, treating an empty/un-parseable field as `0`.
109
103
  # @param delta [Integer]
110
104
  # @return [void]
111
105
  def step(delta) = (self.value = (value || 0) + delta)
112
-
113
- # A digit anywhere, or a `-` only as the very first character.
114
- # @param char [String] a single printable character.
115
- # @return [Boolean]
116
- def accepts?(char)
117
- return true if char.match?(/\A[0-9]\z/)
118
-
119
- char == "-" && content.caret.zero? && !content.text.start_with?("-")
120
- end
121
-
122
- # Re-emits {#on_value_change} with the freshly-parsed {#value}, but only
123
- # when it differs from the last one fired — so a buffer edit that leaves
124
- # the value unchanged (`"7"`→`"07"`) stays silent.
125
- # @return [void]
126
- def fire_if_changed
127
- v = value
128
- return if v == @last_value
129
-
130
- @last_value = v
131
- on_value_change&.call(v)
132
- end
133
106
  end
134
107
  end
135
108
  end
@@ -15,7 +15,6 @@ module Tuile
15
15
  def initialize(text = nil)
16
16
  super()
17
17
  @text = StyledString::EMPTY
18
- @bg = nil
19
18
  @rows = []
20
19
  @blank_row = ""
21
20
  self.text = text unless text.nil?
@@ -25,13 +24,6 @@ module Tuile
25
24
  # {StyledString}.
26
25
  attr_reader :text
27
26
 
28
- # @return [Color, nil] a local background laid over *every* span and the
29
- # row padding (via {StyledString#with_bg}), overriding the text's own
30
- # span bgs — stronger than the inherited {#bg_color}. `nil` (default)
31
- # keeps each span's bg and lets the inherited {#effective_bg_color}
32
- # fill the rest.
33
- attr_reader :bg
34
-
35
27
  # Replaces the text. A `String` is parsed via {StyledString.parse}
36
28
  # (embedded ANSI is honored); a `StyledString` is used as-is; `nil` is
37
29
  # coerced to an empty {StyledString}. Lines wider than {#rect} are
@@ -47,30 +39,14 @@ module Tuile
47
39
  invalidate
48
40
  end
49
41
 
50
- # Sets a local background painted over every span and the row padding
51
- # (trailing pad and blank rows included), overriding the text's own span
52
- # bgs. Coerced via {Color.coerce} (Symbol, Integer, Array, {Color}, or
53
- # `nil`). `nil` clears the override — spans keep their own bg and the
54
- # inherited {#bg_color} fills the rest.
55
- #
56
- # @param value [Color, Symbol, Integer, Array<Integer>, nil]
57
- # @return [void]
58
- def bg=(value)
59
- new_bg = Color.coerce(value)
60
- return if @bg == new_bg
61
-
62
- @bg = new_bg
63
- update_rows
64
- invalidate
65
- end
66
-
67
42
  # Paints the text into {#rect}.
68
43
  #
69
44
  # Skips the {Component#repaint} default's auto-clear: every row is
70
45
  # painted explicitly (with pre-padded blanks past the last line), so
71
46
  # the "fully draw over your rect" contract is met without an upfront
72
- # wipe. Rows go through {Component#draw_text}, so the padding and blank
73
- # rows inherit {Component#effective_bg_color} when {#bg} is unset.
47
+ # wipe. Rows go through {Component#draw_text}, so the text, the trailing
48
+ # padding and the blank rows all take {Component#bg_color}, and a span
49
+ # that carries its own background keeps it.
74
50
  # @return [void]
75
51
  def repaint
76
52
  return if rect.empty?
@@ -84,7 +60,7 @@ module Tuile
84
60
  protected
85
61
 
86
62
  # @return [void]
87
- def on_width_changed
63
+ def handle_width_changed
88
64
  super
89
65
  update_rows
90
66
  end
@@ -94,20 +70,12 @@ module Tuile
94
70
  # Recomputes {@rows} for the current text and rect width.
95
71
  # Each line is ellipsized to fit and padded with trailing spaces out to
96
72
  # the full width, so {#repaint} is just a lookup + {Buffer#set_text} per
97
- # row. {@blank_row} covers rows past the last text line. When {#bg} is
98
- # set, every produced line (and the blank row) has the bg applied
99
- # uniformly.
73
+ # row. {@blank_row} covers rows past the last text line.
100
74
  # @return [void]
101
75
  def update_rows
102
76
  width = rect.width.clamp(0, nil)
103
- @blank_row = apply_bg(StyledString.plain(" " * width))
104
- @rows = @text.lines.map { |line| apply_bg(pad_to(line.ellipsize(width), width)) }
105
- end
106
-
107
- # @param line [StyledString]
108
- # @return [StyledString]
109
- def apply_bg(line)
110
- @bg ? line.with_bg(@bg) : line
77
+ @blank_row = StyledString.plain(" " * width)
78
+ @rows = @text.lines.map { |line| pad_to(line.ellipsize(width), width) }
111
79
  end
112
80
 
113
81
  # @param line [StyledString]
@@ -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.each { add(_1, main, cross:, align:) }
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,37 @@ 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 handle_child_visibility_changed(_child)
194
+ super
195
+ relayout
196
+ end
197
+
143
198
  private
144
199
 
145
- # Recomputes and assigns every child's rect. Silent until this layout has
146
- # a rect of its own — {#add} runs during construction, long before a
147
- # parent assigns one.
200
+ # Recomputes and assigns every child's rect, giving each an empty one
201
+ # when this layout's own rect — or {#inner_rect} — is empty.
202
+ #
203
+ # Deliberately *no* `return if rect.empty?` guard: that strands the
204
+ # children at the coordinates they last had, and the next full repaint
205
+ # paints them there (`D_empty_ancestor`). Construction is silent without
206
+ # one anyway — {#add} runs before a parent assigns a rect, so the
207
+ # children are already empty and `invalidate` no-ops while detached.
148
208
  # @return [void]
149
209
  def relayout
150
- return if rect.empty?
151
-
152
210
  inner = inner_rect
153
- if inner.empty?
154
- children.each { _1.rect = Rect.new(rect.left, rect.top, 0, 0) }
211
+ collapsed = Rect.new(rect.left, rect.top, 0, 0)
212
+ if rect.empty? || inner.empty?
213
+ children.each { _1.rect = collapsed }
155
214
  else
215
+ children.each { _1.rect = collapsed unless _1.visible? }
156
216
  place_children(inner)
157
217
  end
158
218
  invalidate
@@ -168,20 +228,30 @@ module Tuile
168
228
  # @param inner [Rect] {#inner_rect}, known non-empty.
169
229
  # @return [void]
170
230
  def place_children(inner)
171
- sizes = main_sizes(inner)
231
+ kids = shown_children
232
+ sizes = main_sizes(inner, kids)
172
233
  available = cross_extent(inner)
173
234
  offset = 0
174
- children.each_with_index do |child, i|
235
+ kids.each_with_index do |child, i|
175
236
  cross_offset, cross_size = cross_placement(child, available)
176
237
  child.rect = build_rect(inner, offset, sizes[i], cross_offset, cross_size)
177
238
  offset += sizes[i] + spacing
178
239
  end
179
240
  end
180
241
 
242
+ # The children this pass divides space between — a hidden one is out of
243
+ # the {#spacing} count as well as the arithmetic, so hiding a middle
244
+ # child closes the row up rather than leaving a double gap. Contrast a
245
+ # `Fixed[0]` child, still a member of the sequence and still paying for
246
+ # its gap (`D_visibility`).
247
+ # @return [Array<Component>]
248
+ def shown_children = children.select(&:visible?)
249
+
181
250
  # @param inner [Rect] {#inner_rect}.
182
- # @return [Array<Integer>] main-axis extent per child, in child order.
183
- def main_sizes(inner)
184
- count = children.size
251
+ # @param kids [Array<Component>] {#shown_children}.
252
+ # @return [Array<Integer>] main-axis extent per shown child, in order.
253
+ def main_sizes(inner, kids)
254
+ count = kids.size
185
255
  return [] if count.zero?
186
256
 
187
257
  available = [main_extent(inner) - (spacing * (count - 1)), 0].max
@@ -189,7 +259,7 @@ module Tuile
189
259
  expanding = []
190
260
  unassigned = available
191
261
 
192
- children.each_with_index do |child, i|
262
+ kids.each_with_index do |child, i|
193
263
  case (constraint = placement(child)[:main])
194
264
  when Expand then expanding << i
195
265
  when Fixed then unassigned -= (sizes[i] = constraint.cells.clamp(0, unassigned))
@@ -197,19 +267,20 @@ module Tuile
197
267
  end
198
268
  end
199
269
 
200
- distribute_expand(sizes, expanding, unassigned) unless expanding.empty?
270
+ distribute_expand(sizes, kids, expanding, unassigned) unless expanding.empty?
201
271
  sizes
202
272
  end
203
273
 
204
274
  # Splits `slack` between the {Expand} children by weight, writing the
205
275
  # results into `sizes`.
206
276
  # @param sizes [Array<Integer>] mutated in place.
277
+ # @param kids [Array<Component>] {#shown_children}, which `indices` index.
207
278
  # @param indices [Array<Integer>] child indices carrying an {Expand}.
208
279
  # @param slack [Integer] cells left over; a negative value yields zeroes.
209
280
  # @return [void]
210
- def distribute_expand(sizes, indices, slack)
281
+ def distribute_expand(sizes, kids, indices, slack)
211
282
  slack = 0 if slack.negative?
212
- weights = indices.map { placement(children[_1])[:main].weight }
283
+ weights = indices.map { placement(kids[_1])[:main].weight }
213
284
  total = weights.sum
214
285
  shares = weights.map { slack * _1 / total }
215
286
  # 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]` hides the child — it gets an empty rect and paints nothing.
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.
@@ -166,7 +173,7 @@ module Tuile
166
173
  # the popup. Layouts don't paint any visible chrome of their own
167
174
  # (the auto-cleared background is just blank space), so this has no
168
175
  # mouse-routing consequences — clicks on a gap area land back on the
169
- # Layout itself and the on_focus cascade forwards to a tab stop.
176
+ # Layout itself and the handle_focus cascade forwards to a tab stop.
170
177
  def focusable? = true
171
178
 
172
179
  # Adds a child component to this layout.
@@ -191,7 +198,7 @@ module Tuile
191
198
  end
192
199
 
193
200
  # @return [void]
194
- def on_focus
201
+ def handle_focus
195
202
  super
196
203
  # Forward focus to the first interactive widget in the subtree so the
197
204
  # user can start typing / cursoring immediately. Prefer a {#tab_stop?}
@@ -199,12 +206,15 @@ module Tuile
199
206
  # containers like a {Window} or another {Layout}. Fall back to the
200
207
  # first focusable direct child for the rare case where the layout has
201
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.
202
212
  first_tab_stop = nil
203
- on_tree { |c| first_tab_stop ||= c if !c.equal?(self) && c.tab_stop? }
213
+ walk_shown_tree { |c| first_tab_stop ||= c if !c.equal?(self) && c.tab_stop? }
204
214
  if first_tab_stop
205
215
  screen.focused = first_tab_stop
206
216
  else
207
- first_focusable = @children.find(&:focusable?)
217
+ first_focusable = @children.find { _1.visible? && _1.focusable? }
208
218
  screen.focused = first_focusable unless first_focusable.nil?
209
219
  end
210
220
  end