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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +159 -49
- data/README.md +53 -17
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +152 -22
- data/book/06-theming.md +105 -25
- data/book/07-components.md +531 -50
- data/book/08-testing.md +100 -20
- data/book/10-locale.md +216 -0
- data/book/README.md +19 -9
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +654 -40
- data/lib/tuile/component/abstract_string_field.rb +114 -68
- data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +38 -21
- data/lib/tuile/component/combo_box.rb +102 -59
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +347 -0
- data/lib/tuile/component/date_time_field.rb +275 -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 +32 -13
- 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/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +7 -39
- data/lib/tuile/component/layout/box.rb +90 -19
- data/lib/tuile/component/layout.rb +15 -5
- data/lib/tuile/component/list.rb +53 -38
- data/lib/tuile/component/list_dropdown.rb +7 -3
- data/lib/tuile/component/menu_bar/cascade.rb +5 -5
- data/lib/tuile/component/menu_bar.rb +18 -18
- data/lib/tuile/component/notification.rb +32 -18
- data/lib/tuile/component/overlay.rb +26 -8
- data/lib/tuile/component/picker_window.rb +27 -8
- data/lib/tuile/component/popup.rb +2 -2
- data/lib/tuile/component/progress_bar.rb +10 -4
- data/lib/tuile/component/radio_group.rb +41 -23
- data/lib/tuile/component/select.rb +23 -16
- data/lib/tuile/component/slot.rb +3 -3
- data/lib/tuile/component/tab_sheet.rb +6 -6
- data/lib/tuile/component/tabs.rb +11 -11
- data/lib/tuile/component/text_area.rb +26 -18
- data/lib/tuile/component/text_field.rb +55 -26
- data/lib/tuile/component/text_view.rb +40 -19
- data/lib/tuile/component/time_field.rb +479 -0
- data/lib/tuile/component/window.rb +26 -13
- data/lib/tuile/component.rb +635 -131
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +95 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +219 -68
- data/lib/tuile/screen_pane.rb +51 -42
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +110 -32
- 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 +4595 -825
- metadata +14 -9
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -5422
- data/TERMINOLOGY.md +0 -71
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -124
- data/ideas/per-component-buffers.md +0 -55
- 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
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# {
|
|
20
|
-
#
|
|
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 <
|
|
36
|
-
include
|
|
37
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
#
|
|
70
|
-
#
|
|
71
|
-
|
|
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
|
|
73
|
-
# rows
|
|
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
|
|
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.
|
|
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 =
|
|
104
|
-
@rows = @text.lines.map { |line|
|
|
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.
|
|
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
|
|
146
|
-
#
|
|
147
|
-
#
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
231
|
+
kids = shown_children
|
|
232
|
+
sizes = main_sizes(inner, kids)
|
|
172
233
|
available = cross_extent(inner)
|
|
173
234
|
offset = 0
|
|
174
|
-
|
|
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
|
-
# @
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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(
|
|
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]`
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|