tuile 0.9.0 → 0.11.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 +81 -36
- data/DECISIONS.md +2566 -0
- data/README.md +37 -24
- data/book/03-layout.md +153 -8
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +84 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +458 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +22 -14
- data/examples/sampler.rb +632 -67
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +118 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +134 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +263 -0
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout/box.rb +316 -0
- data/lib/tuile/component/layout/horizontal.rb +40 -0
- data/lib/tuile/component/layout/vertical.rb +41 -0
- data/lib/tuile/component/layout.rb +149 -15
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +157 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +125 -86
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +2962 -680
- metadata +25 -7
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# The value seam every input component shares: a settable/gettable {#value}
|
|
6
|
+
# of *any* type, an {#on_value_change} listener, {#empty?}, and {#clear}. A
|
|
7
|
+
# form (a future binder) drives a mix of field types uniformly through it,
|
|
8
|
+
# not caring that a {TextField}'s value is a `String` while another field's
|
|
9
|
+
# is a domain object.
|
|
10
|
+
#
|
|
11
|
+
# field.on_value_change = ->(v) { puts "now: #{v.inspect}" }
|
|
12
|
+
# field.value = "hello" # fires the listener
|
|
13
|
+
# field.clear # value = empty_value, fires again
|
|
14
|
+
#
|
|
15
|
+
# The default {#value=}/{#value} keep the value in `@value` and are enough
|
|
16
|
+
# for a component with nothing more natural — you get a repaint and the
|
|
17
|
+
# listener for free. An includer whose value lives elsewhere overrides both
|
|
18
|
+
# ({AbstractStringField} backs them with its text buffer). Override {#empty_value}
|
|
19
|
+
# when the empty sentinel isn't `nil` (a text field's is `""`).
|
|
20
|
+
#
|
|
21
|
+
# == Implementation details
|
|
22
|
+
# Deliberately smaller than Vaadin's `HasValue`: read-only,
|
|
23
|
+
# required-indicator, the from-client/old-value event payload, and
|
|
24
|
+
# converters all belong to the not-yet-built form layer, not here.
|
|
25
|
+
module HasValue
|
|
26
|
+
# @return [Proc, Method, nil] one-arg callable fired with the new value
|
|
27
|
+
# whenever {#value} actually changes — never on a no-op set.
|
|
28
|
+
attr_accessor :on_value_change
|
|
29
|
+
|
|
30
|
+
# @return [Object] the current value; `nil` until first set.
|
|
31
|
+
def value = @value
|
|
32
|
+
|
|
33
|
+
# No-op (no repaint, no listener) when equal to the current value.
|
|
34
|
+
# @param new_value [Object]
|
|
35
|
+
# @return [void]
|
|
36
|
+
def value=(new_value)
|
|
37
|
+
return if value == new_value
|
|
38
|
+
|
|
39
|
+
@value = new_value
|
|
40
|
+
invalidate
|
|
41
|
+
on_value_change&.call(new_value)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Boolean] true iff {#value} equals {#empty_value}.
|
|
45
|
+
def empty? = value == empty_value
|
|
46
|
+
|
|
47
|
+
# Resets {#value} to {#empty_value}.
|
|
48
|
+
# @return [void]
|
|
49
|
+
def clear = (self.value = empty_value)
|
|
50
|
+
|
|
51
|
+
# @return [Object] the value {#empty?}/{#clear} treat as empty; `nil`
|
|
52
|
+
# unless an includer overrides it.
|
|
53
|
+
def empty_value = nil
|
|
54
|
+
|
|
55
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
56
|
+
# a read-only display field could override back to `false`. Only
|
|
57
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
58
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
59
|
+
# `D-integer-field`).
|
|
60
|
+
# @return [Boolean]
|
|
61
|
+
def focusable? = true
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
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`:
|
|
10
|
+
#
|
|
11
|
+
# field = Component::IntegerField.new
|
|
12
|
+
# field.on_value_change = ->(n) { puts n.inspect } # Integer or nil, per change
|
|
13
|
+
# field.value = 42 # field shows "42"
|
|
14
|
+
# field.value # => 42
|
|
15
|
+
# field.clear # empties it; value => nil
|
|
16
|
+
#
|
|
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}.
|
|
22
|
+
#
|
|
23
|
+
# == The value is a *derived parse* of the buffer
|
|
24
|
+
# {#value} is `Integer(buffer, 10)` (or `nil`), recomputed on read — the
|
|
25
|
+
# buffer is the single source of truth, {#value=} just writes it. So `"-"`
|
|
26
|
+
# alone and `""` both read as `nil`, and `on_value_change` fires eagerly
|
|
27
|
+
# once per real *value* change: typing `0`→`7` in `"07"` shifts the buffer
|
|
28
|
+
# but not the value (`7`), so it does not fire. No normalization — a typed
|
|
29
|
+
# `"007"` stays `"007"` on screen though its value is `7`.
|
|
30
|
+
#
|
|
31
|
+
# `min`/`max`, a `+` sign, and thousands separators are deliberately out of
|
|
32
|
+
# scope (range and formatting are a forms concern).
|
|
33
|
+
#
|
|
34
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
35
|
+
class IntegerField < Component
|
|
36
|
+
include HasContent
|
|
37
|
+
include HasValue
|
|
38
|
+
|
|
39
|
+
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
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Integer, nil] the parsed buffer; `nil` when empty or not a
|
|
49
|
+
# valid integer (e.g. a lone `"-"`).
|
|
50
|
+
def value
|
|
51
|
+
Integer(content.text, 10)
|
|
52
|
+
rescue ArgumentError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Writes `new_value` into the buffer and parks the caret at its end; fires
|
|
57
|
+
# {#on_value_change} only if the value actually changed.
|
|
58
|
+
# @param new_value [Integer, nil] `nil` empties the field.
|
|
59
|
+
# @return [void]
|
|
60
|
+
def value=(new_value)
|
|
61
|
+
content.text = new_value.nil? ? "" : new_value.to_s
|
|
62
|
+
content.caret = content.text.length
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# `nil`, not `""`: an integer field with no parseable number is empty.
|
|
66
|
+
# @return [nil]
|
|
67
|
+
def empty_value = nil
|
|
68
|
+
|
|
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)
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
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
|
+
# Nudges {#value} by `delta`, treating an empty/un-parseable field as `0`.
|
|
109
|
+
# @param delta [Integer]
|
|
110
|
+
# @return [void]
|
|
111
|
+
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
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -25,9 +25,11 @@ module Tuile
|
|
|
25
25
|
# {StyledString}.
|
|
26
26
|
attr_reader :text
|
|
27
27
|
|
|
28
|
-
# @return [Color, nil] background
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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.
|
|
31
33
|
attr_reader :bg
|
|
32
34
|
|
|
33
35
|
# Replaces the text. A `String` is parsed via {StyledString.parse}
|
|
@@ -45,11 +47,11 @@ module Tuile
|
|
|
45
47
|
invalidate
|
|
46
48
|
end
|
|
47
49
|
|
|
48
|
-
# Sets
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
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.
|
|
53
55
|
#
|
|
54
56
|
# @param value [Color, Symbol, Integer, Array<Integer>, nil]
|
|
55
57
|
# @return [void]
|
|
@@ -67,14 +69,15 @@ module Tuile
|
|
|
67
69
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
68
70
|
# painted explicitly (with pre-padded blanks past the last line), so
|
|
69
71
|
# the "fully draw over your rect" contract is met without an upfront
|
|
70
|
-
# wipe.
|
|
72
|
+
# wipe. Rows go through {Component#draw_line}, so the padding and blank
|
|
73
|
+
# rows inherit {Component#effective_bg_color} when {#bg} is unset.
|
|
71
74
|
# @return [void]
|
|
72
75
|
def repaint
|
|
73
76
|
return if rect.empty?
|
|
74
77
|
|
|
75
78
|
(0...rect.height).each do |row|
|
|
76
79
|
line = @clipped_lines[row] || @blank_line
|
|
77
|
-
|
|
80
|
+
draw_line(rect.left, rect.top + row, line)
|
|
78
81
|
end
|
|
79
82
|
end
|
|
80
83
|
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
class Layout
|
|
6
|
+
# Abstract base of the one-dimensional box layouts. Children are stacked
|
|
7
|
+
# along a *main* axis in the order they were added, each getting the extent
|
|
8
|
+
# its constraint asks for; across the *cross* axis they are sized one at a
|
|
9
|
+
# time, since nothing competes with them there. {Vertical} and {Horizontal}
|
|
10
|
+
# pick which axis is which.
|
|
11
|
+
#
|
|
12
|
+
# class LoginForm < Tuile::Component::Layout::Vertical
|
|
13
|
+
# def initialize
|
|
14
|
+
# super(spacing: 1, padding: Insets[top: 1])
|
|
15
|
+
# add(@prompt = Tuile::Component::Label.new, Fixed[4])
|
|
16
|
+
# add(@user = Tuile::Component::TextField.new, Fixed[1], cross: Fixed[30])
|
|
17
|
+
# add(@log = Tuile::Component::TextView.new, Expand[1])
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# The constraint names need no prefix inside a subclass — Ruby finds them on
|
|
22
|
+
# `Layout`, an ancestor. Component classes are not on that chain and still do.
|
|
23
|
+
#
|
|
24
|
+
# Children pack from the start edge, so with no {Expand} among them the
|
|
25
|
+
# slack is simply left at the end: there is no filler component to add.
|
|
26
|
+
# Nest boxes to vary the gap — a `Vertical.new(spacing: 0)` inside a
|
|
27
|
+
# `Vertical.new(spacing: 1)` groups two rows tightly within a looser stack.
|
|
28
|
+
#
|
|
29
|
+
# == Implementation details
|
|
30
|
+
#
|
|
31
|
+
# Every child-list mutation re-runs the whole pass, because in a box the
|
|
32
|
+
# children move: removing one shifts everything after it, and adding one
|
|
33
|
+
# shrinks every {Expand} share. ({Absolute} can skip this — there, siblings
|
|
34
|
+
# are independent.)
|
|
35
|
+
#
|
|
36
|
+
# Main-axis resolution order, against
|
|
37
|
+
# `available = extent - padding - spacing * (children - 1)`:
|
|
38
|
+
#
|
|
39
|
+
# 1. {Fixed} takes its cells, clamped to what is still unassigned.
|
|
40
|
+
# 2. {Percent} takes its share *of `available`*, likewise clamped.
|
|
41
|
+
# 3. {Expand} children split the residue by weight; the integer remainder
|
|
42
|
+
# goes to the earliest of them, one cell each.
|
|
43
|
+
#
|
|
44
|
+
# So over-subscription starves in declaration order rather than raising:
|
|
45
|
+
# a child with nothing left gets an empty rect and paints nothing. Padding
|
|
46
|
+
# wider than the layout does the same to every child.
|
|
47
|
+
class Box < Layout
|
|
48
|
+
# Constraints for a child wired in through `add_child` instead of {#add}.
|
|
49
|
+
# @return [Hash{Symbol => Object}]
|
|
50
|
+
DEFAULT_PLACEMENT = { main: Fixed[1], cross: Percent[100], align: :start }.freeze
|
|
51
|
+
|
|
52
|
+
# Where a child narrower than the cross extent sits within it.
|
|
53
|
+
# @return [Array<Symbol>]
|
|
54
|
+
ALIGNMENTS = %i[start center end].freeze
|
|
55
|
+
|
|
56
|
+
# @param spacing [Integer] blank cells between adjacent children; `>= 0`.
|
|
57
|
+
# @param padding [Insets, Integer] inset from this layout's own rect; an
|
|
58
|
+
# Integer is coerced to a uniform {Insets}.
|
|
59
|
+
# @raise [ArgumentError] on a negative `spacing` or an unusable `padding`.
|
|
60
|
+
def initialize(spacing: 0, padding: 0)
|
|
61
|
+
super()
|
|
62
|
+
@spacing = validate_spacing(spacing)
|
|
63
|
+
@padding = Insets.coerce(padding)
|
|
64
|
+
# Identity-keyed: two == children are still two distinct slots.
|
|
65
|
+
@placements = {}.compare_by_identity
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Integer] blank cells between adjacent children.
|
|
69
|
+
attr_reader :spacing
|
|
70
|
+
|
|
71
|
+
# @return [Insets] inset from this layout's own rect.
|
|
72
|
+
attr_reader :padding
|
|
73
|
+
|
|
74
|
+
# @param cells [Integer] blank cells between adjacent children; `>= 0`.
|
|
75
|
+
# @raise [ArgumentError] on a negative value.
|
|
76
|
+
# @return [void]
|
|
77
|
+
def spacing=(cells)
|
|
78
|
+
cells = validate_spacing(cells)
|
|
79
|
+
return if @spacing == cells
|
|
80
|
+
|
|
81
|
+
@spacing = cells
|
|
82
|
+
relayout
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @param insets [Insets, Integer] an Integer becomes a uniform inset.
|
|
86
|
+
# @raise [ArgumentError] on anything else.
|
|
87
|
+
# @return [void]
|
|
88
|
+
def padding=(insets)
|
|
89
|
+
insets = Insets.coerce(insets)
|
|
90
|
+
return if @padding == insets
|
|
91
|
+
|
|
92
|
+
@padding = insets
|
|
93
|
+
relayout
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Adds a child — or every element of an Enumerable, all with the same
|
|
97
|
+
# constraints — and re-runs the layout.
|
|
98
|
+
#
|
|
99
|
+
# add(field, Fixed[1], cross: Fixed[30], align: :center)
|
|
100
|
+
# add([ok, cancel], Fixed[1])
|
|
101
|
+
#
|
|
102
|
+
# @param child [Component, Enumerable<Component>]
|
|
103
|
+
# @param main [Fixed, Percent, Expand] extent along the main axis.
|
|
104
|
+
# @param cross [Fixed, Percent] extent across it.
|
|
105
|
+
# @param align [Symbol] one of {ALIGNMENTS} — where a child narrower than
|
|
106
|
+
# the cross extent sits. {Vertical} / {Horizontal} say which edge
|
|
107
|
+
# `:start` is.
|
|
108
|
+
# @raise [ArgumentError] on an unknown constraint or alignment, or an
|
|
109
|
+
# {Expand} passed as `cross` (see {Expand}).
|
|
110
|
+
# @raise [TypeError] if `child` is not a {Component}.
|
|
111
|
+
# @return [void]
|
|
112
|
+
def add(child, main = Fixed[1], cross: Percent[100], align: :start)
|
|
113
|
+
if child.is_a? Enumerable
|
|
114
|
+
child.each { add(_1, main, cross:, align:) }
|
|
115
|
+
return
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
validate_main(main)
|
|
119
|
+
validate_cross(cross)
|
|
120
|
+
validate_align(align)
|
|
121
|
+
add_child(child)
|
|
122
|
+
@placements[child] = { main:, cross:, align: }
|
|
123
|
+
relayout
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Removes the child, forgets its constraints, and closes the gap it left
|
|
127
|
+
# by re-running the layout.
|
|
128
|
+
# @param child [Component]
|
|
129
|
+
# @return [void]
|
|
130
|
+
def remove(child)
|
|
131
|
+
super
|
|
132
|
+
@placements.delete(child)
|
|
133
|
+
relayout
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @param new_rect [Rect]
|
|
137
|
+
# @return [void]
|
|
138
|
+
def rect=(new_rect)
|
|
139
|
+
super
|
|
140
|
+
relayout
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
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.
|
|
148
|
+
# @return [void]
|
|
149
|
+
def relayout
|
|
150
|
+
return if rect.empty?
|
|
151
|
+
|
|
152
|
+
inner = inner_rect
|
|
153
|
+
if inner.empty?
|
|
154
|
+
children.each { _1.rect = Rect.new(rect.left, rect.top, 0, 0) }
|
|
155
|
+
else
|
|
156
|
+
place_children(inner)
|
|
157
|
+
end
|
|
158
|
+
invalidate
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# @return [Rect] {#rect} with {#padding} taken off each edge; may be
|
|
162
|
+
# {Rect#empty? empty}.
|
|
163
|
+
def inner_rect
|
|
164
|
+
Rect.new(rect.left + padding.left, rect.top + padding.top,
|
|
165
|
+
rect.width - padding.horizontal, rect.height - padding.vertical)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @param inner [Rect] {#inner_rect}, known non-empty.
|
|
169
|
+
# @return [void]
|
|
170
|
+
def place_children(inner)
|
|
171
|
+
sizes = main_sizes(inner)
|
|
172
|
+
available = cross_extent(inner)
|
|
173
|
+
offset = 0
|
|
174
|
+
children.each_with_index do |child, i|
|
|
175
|
+
cross_offset, cross_size = cross_placement(child, available)
|
|
176
|
+
child.rect = build_rect(inner, offset, sizes[i], cross_offset, cross_size)
|
|
177
|
+
offset += sizes[i] + spacing
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @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
|
|
185
|
+
return [] if count.zero?
|
|
186
|
+
|
|
187
|
+
available = [main_extent(inner) - (spacing * (count - 1)), 0].max
|
|
188
|
+
sizes = Array.new(count, 0)
|
|
189
|
+
expanding = []
|
|
190
|
+
unassigned = available
|
|
191
|
+
|
|
192
|
+
children.each_with_index do |child, i|
|
|
193
|
+
case (constraint = placement(child)[:main])
|
|
194
|
+
when Expand then expanding << i
|
|
195
|
+
when Fixed then unassigned -= (sizes[i] = constraint.cells.clamp(0, unassigned))
|
|
196
|
+
else unassigned -= (sizes[i] = percent_of(available, constraint).clamp(0, unassigned))
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
distribute_expand(sizes, expanding, unassigned) unless expanding.empty?
|
|
201
|
+
sizes
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Splits `slack` between the {Expand} children by weight, writing the
|
|
205
|
+
# results into `sizes`.
|
|
206
|
+
# @param sizes [Array<Integer>] mutated in place.
|
|
207
|
+
# @param indices [Array<Integer>] child indices carrying an {Expand}.
|
|
208
|
+
# @param slack [Integer] cells left over; a negative value yields zeroes.
|
|
209
|
+
# @return [void]
|
|
210
|
+
def distribute_expand(sizes, indices, slack)
|
|
211
|
+
slack = 0 if slack.negative?
|
|
212
|
+
weights = indices.map { placement(children[_1])[:main].weight }
|
|
213
|
+
total = weights.sum
|
|
214
|
+
shares = weights.map { slack * _1 / total }
|
|
215
|
+
# Under one cell is lost per floor, so the remainder can't outrun the
|
|
216
|
+
# share count — the earliest Expand children each take one.
|
|
217
|
+
(slack - shares.sum).times { |i| shares[i] += 1 }
|
|
218
|
+
indices.each_with_index { |child_index, i| sizes[child_index] = shares[i] }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# @param child [Component]
|
|
222
|
+
# @param available [Integer] cross extent of {#inner_rect}.
|
|
223
|
+
# @return [Array(Integer, Integer)] offset from `inner`'s start edge, and
|
|
224
|
+
# extent, along the cross axis.
|
|
225
|
+
def cross_placement(child, available)
|
|
226
|
+
spec = placement(child)
|
|
227
|
+
size = case (constraint = spec[:cross])
|
|
228
|
+
when Fixed then constraint.cells.clamp(0, available)
|
|
229
|
+
else percent_of(available, constraint).clamp(0, available)
|
|
230
|
+
end
|
|
231
|
+
[align_offset(spec[:align], available - size), size]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# @param align [Symbol] one of {ALIGNMENTS}.
|
|
235
|
+
# @param slack [Integer] unused cells across the axis.
|
|
236
|
+
# @return [Integer]
|
|
237
|
+
def align_offset(align, slack)
|
|
238
|
+
case align
|
|
239
|
+
when :center then slack / 2
|
|
240
|
+
when :end then slack
|
|
241
|
+
else 0
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# @param extent [Integer]
|
|
246
|
+
# @param constraint [Percent]
|
|
247
|
+
# @return [Integer]
|
|
248
|
+
def percent_of(extent, constraint) = (extent * constraint.percent / 100.0).round
|
|
249
|
+
|
|
250
|
+
# @param child [Component]
|
|
251
|
+
# @return [Hash{Symbol => Object}] the child's `main`/`cross`/`align`.
|
|
252
|
+
def placement(child) = @placements[child] || DEFAULT_PLACEMENT
|
|
253
|
+
|
|
254
|
+
# @param rect [Rect]
|
|
255
|
+
# @return [Integer] the extent along the main axis.
|
|
256
|
+
def main_extent(rect) = raise(NotImplementedError, "#{self.class} must implement main_extent")
|
|
257
|
+
|
|
258
|
+
# @param rect [Rect]
|
|
259
|
+
# @return [Integer] the extent along the cross axis.
|
|
260
|
+
def cross_extent(rect) = raise(NotImplementedError, "#{self.class} must implement cross_extent")
|
|
261
|
+
|
|
262
|
+
# @param inner [Rect] {#inner_rect}, the origin both offsets are relative to.
|
|
263
|
+
# @param main_offset [Integer] cells along the main axis.
|
|
264
|
+
# @param main_size [Integer] extent along the main axis.
|
|
265
|
+
# @param cross_offset [Integer] cells along the cross axis.
|
|
266
|
+
# @param cross_size [Integer] extent along the cross axis.
|
|
267
|
+
# @return [Rect] absolute screen rect for one child.
|
|
268
|
+
def build_rect(inner, main_offset, main_size, cross_offset, cross_size)
|
|
269
|
+
raise NotImplementedError, "#{self.class} must implement build_rect"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# @param cells [Integer]
|
|
273
|
+
# @raise [ArgumentError] unless `cells` is a non-negative Integer.
|
|
274
|
+
# @return [Integer] `cells`.
|
|
275
|
+
def validate_spacing(cells)
|
|
276
|
+
unless cells.is_a?(Integer) && !cells.negative?
|
|
277
|
+
raise ArgumentError, "spacing expects a non-negative Integer, got #{cells.inspect}"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
cells
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# @param constraint [Object]
|
|
284
|
+
# @raise [ArgumentError] unless it is a {Fixed}, {Percent} or {Expand}.
|
|
285
|
+
# @return [void]
|
|
286
|
+
def validate_main(constraint)
|
|
287
|
+
return if [Fixed, Percent, Expand].any? { constraint.is_a?(_1) }
|
|
288
|
+
|
|
289
|
+
raise ArgumentError, "expected Fixed, Percent or Expand, got #{constraint.inspect}"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# @param constraint [Object]
|
|
293
|
+
# @raise [ArgumentError] unless it is a {Fixed} or {Percent}.
|
|
294
|
+
# @return [void]
|
|
295
|
+
def validate_cross(constraint)
|
|
296
|
+
if constraint.is_a? Expand
|
|
297
|
+
raise ArgumentError, "Expand is main-axis only — a child has no siblings competing " \
|
|
298
|
+
"across the axis; use Fixed or Percent for cross:"
|
|
299
|
+
end
|
|
300
|
+
return if constraint.is_a?(Fixed) || constraint.is_a?(Percent)
|
|
301
|
+
|
|
302
|
+
raise ArgumentError, "expected Fixed or Percent for cross:, got #{constraint.inspect}"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# @param align [Object]
|
|
306
|
+
# @raise [ArgumentError] unless it is one of {ALIGNMENTS}.
|
|
307
|
+
# @return [void]
|
|
308
|
+
def validate_align(align)
|
|
309
|
+
return if ALIGNMENTS.include?(align)
|
|
310
|
+
|
|
311
|
+
raise ArgumentError, "expected one of #{ALIGNMENTS.inspect}, got #{align.inspect}"
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
class Layout
|
|
6
|
+
# Lays children out left to right. The main axis is horizontal, so a
|
|
7
|
+
# child's positional constraint is its **width** and `cross:` is its
|
|
8
|
+
# **height**; `align: :start` is the top edge, `:end` the bottom.
|
|
9
|
+
#
|
|
10
|
+
# split = Component::Layout::Horizontal.new
|
|
11
|
+
# split.add(sidebar, Component::Layout::Fixed[30])
|
|
12
|
+
# split.add(main, Component::Layout::Expand[1]) # takes the rest of the row
|
|
13
|
+
#
|
|
14
|
+
# Inside a subclass the constraints need no prefix at all — see {Box}.
|
|
15
|
+
#
|
|
16
|
+
# See {Box} for the constraint vocabulary and how the space is divided.
|
|
17
|
+
class Horizontal < Box
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
# @param rect [Rect]
|
|
21
|
+
# @return [Integer]
|
|
22
|
+
def main_extent(rect) = rect.width
|
|
23
|
+
|
|
24
|
+
# @param rect [Rect]
|
|
25
|
+
# @return [Integer]
|
|
26
|
+
def cross_extent(rect) = rect.height
|
|
27
|
+
|
|
28
|
+
# @param inner [Rect]
|
|
29
|
+
# @param main_offset [Integer] columns right of `inner`'s left.
|
|
30
|
+
# @param main_size [Integer] width.
|
|
31
|
+
# @param cross_offset [Integer] rows down from `inner`'s top.
|
|
32
|
+
# @param cross_size [Integer] height.
|
|
33
|
+
# @return [Rect]
|
|
34
|
+
def build_rect(inner, main_offset, main_size, cross_offset, cross_size)
|
|
35
|
+
Rect.new(inner.left + main_offset, inner.top + cross_offset, main_size, cross_size)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
class Layout
|
|
6
|
+
# Stacks children top to bottom. The main axis is vertical, so a child's
|
|
7
|
+
# positional constraint is its **height** and `cross:` is its **width**;
|
|
8
|
+
# `align: :start` is the left edge, `:end` the right.
|
|
9
|
+
#
|
|
10
|
+
# form = Component::Layout::Vertical.new(spacing: 1)
|
|
11
|
+
# form.add(caption, Component::Layout::Fixed[1])
|
|
12
|
+
# form.add(field, Component::Layout::Fixed[1], cross: Component::Layout::Fixed[30])
|
|
13
|
+
# form.add(log, Component::Layout::Expand[1]) # takes whatever is left below
|
|
14
|
+
#
|
|
15
|
+
# Inside a subclass the constraints need no prefix at all — see {Box}.
|
|
16
|
+
#
|
|
17
|
+
# See {Box} for the constraint vocabulary and how the space is divided.
|
|
18
|
+
class Vertical < Box
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# @param rect [Rect]
|
|
22
|
+
# @return [Integer]
|
|
23
|
+
def main_extent(rect) = rect.height
|
|
24
|
+
|
|
25
|
+
# @param rect [Rect]
|
|
26
|
+
# @return [Integer]
|
|
27
|
+
def cross_extent(rect) = rect.width
|
|
28
|
+
|
|
29
|
+
# @param inner [Rect]
|
|
30
|
+
# @param main_offset [Integer] rows down from `inner`'s top.
|
|
31
|
+
# @param main_size [Integer] height.
|
|
32
|
+
# @param cross_offset [Integer] columns right of `inner`'s left.
|
|
33
|
+
# @param cross_size [Integer] width.
|
|
34
|
+
# @return [Rect]
|
|
35
|
+
def build_rect(inner, main_offset, main_size, cross_offset, cross_size)
|
|
36
|
+
Rect.new(inner.left + cross_offset, inner.top + main_offset, cross_size, main_size)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|