tuile 0.16.0 → 0.17.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 +108 -0
- data/README.md +21 -12
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +5 -4
- data/book/05-focus.md +12 -9
- data/book/06-theming.md +55 -17
- data/book/07-components.md +188 -40
- data/book/08-testing.md +115 -15
- data/book/10-locale.md +1 -1
- data/book/README.md +5 -5
- data/examples/file_commander.rb +38 -27
- data/examples/hello_world.rb +1 -1
- data/examples/sampler.rb +225 -169
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +81 -80
- data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +19 -11
- data/lib/tuile/component/checkbox.rb +12 -10
- data/lib/tuile/component/checkbox_group.rb +11 -13
- data/lib/tuile/component/combo_box.rb +30 -40
- data/lib/tuile/component/confirm_window.rb +27 -22
- data/lib/tuile/component/date_field.rb +27 -20
- data/lib/tuile/component/date_time_field.rb +75 -31
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +98 -27
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +5 -12
- data/lib/tuile/component/has_validation.rb +39 -13
- data/lib/tuile/component/has_value.rb +70 -16
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -63
- data/lib/tuile/component/layout.rb +124 -10
- data/lib/tuile/component/list.rb +197 -94
- data/lib/tuile/component/list_dropdown.rb +148 -88
- data/lib/tuile/component/menu_bar/cascade.rb +97 -27
- data/lib/tuile/component/menu_bar.rb +84 -64
- data/lib/tuile/component/notification.rb +44 -31
- data/lib/tuile/component/overlay.rb +210 -52
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +15 -10
- data/lib/tuile/component/popup.rb +13 -24
- data/lib/tuile/component/progress_bar.rb +7 -7
- data/lib/tuile/component/radio_group.rb +10 -12
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +15 -31
- data/lib/tuile/component/slot.rb +1 -2
- data/lib/tuile/component/tab_sheet.rb +21 -28
- data/lib/tuile/component/tabs.rb +39 -24
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +21 -19
- data/lib/tuile/component/text_field.rb +55 -39
- data/lib/tuile/component/text_view.rb +143 -79
- data/lib/tuile/component/time_field.rb +26 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +481 -259
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +14 -0
- data/lib/tuile/fake_screen.rb +41 -10
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +51 -35
- data/lib/tuile/mouse.rb +96 -29
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +419 -84
- data/lib/tuile/screen_pane.rb +144 -31
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +139 -9
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +310 -36
- data/lib/tuile/theme.rb +170 -19
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +4951 -1158
- metadata +16 -2
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
|
@@ -14,8 +14,9 @@ module Tuile
|
|
|
14
14
|
# populatable regions gives each one a {Slot} rather than including this
|
|
15
15
|
# twice.
|
|
16
16
|
#
|
|
17
|
-
# The includer initializes `@content` to nil and
|
|
18
|
-
#
|
|
17
|
+
# The includer initializes `@content` to nil and positions the child from
|
|
18
|
+
# its own {Component#relayout}; the mixin owns the swap, and the swap marks,
|
|
19
|
+
# so nothing here places anything itself:
|
|
19
20
|
#
|
|
20
21
|
# class Slot < Component
|
|
21
22
|
# include Component::HasContent
|
|
@@ -24,7 +25,7 @@ module Tuile
|
|
|
24
25
|
#
|
|
25
26
|
# protected
|
|
26
27
|
#
|
|
27
|
-
# def
|
|
28
|
+
# def relayout = content&.rect = local_rect
|
|
28
29
|
# end
|
|
29
30
|
#
|
|
30
31
|
# **A child that is private machinery stays out**, because {#content=} ships
|
|
@@ -61,26 +62,18 @@ module Tuile
|
|
|
61
62
|
end
|
|
62
63
|
|
|
63
64
|
old = self.content
|
|
65
|
+
@content = content
|
|
64
66
|
# Detached without notifying, and notified at the very end: the focus
|
|
65
67
|
# repair in handle_child_removed cascades into whatever occupies the slot
|
|
66
68
|
# *now*, so it has to see the new content (window_spec pins it).
|
|
67
69
|
detach_child(old) unless old.nil?
|
|
68
|
-
@content = content
|
|
69
70
|
unless content.nil?
|
|
70
71
|
add_child(content, at: 0) # content paints beneath a Window's footer
|
|
71
72
|
content.invalidate
|
|
72
|
-
layout(content)
|
|
73
73
|
end
|
|
74
74
|
handle_child_removed(old) unless old.nil?
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
-
# @param rect [Rect]
|
|
78
|
-
# @return [void]
|
|
79
|
-
def rect=(rect)
|
|
80
|
-
super
|
|
81
|
-
layout(content) unless content.nil?
|
|
82
|
-
end
|
|
83
|
-
|
|
84
77
|
# @return [void]
|
|
85
78
|
def handle_focus
|
|
86
79
|
super
|
|
@@ -7,7 +7,7 @@ module Tuile
|
|
|
7
7
|
# {Theme#error_active_bg_color} while focused.
|
|
8
8
|
#
|
|
9
9
|
# login = Component::Button.new(caption: "Log in")
|
|
10
|
-
# login.on_click
|
|
10
|
+
# login.on_click do
|
|
11
11
|
# username.error_message = username.empty? ? "Required" : nil
|
|
12
12
|
# password.error_message = password.empty? ? "Required" : nil
|
|
13
13
|
# next if [username, password].any?(&:error_message)
|
|
@@ -17,8 +17,8 @@ module Tuile
|
|
|
17
17
|
#
|
|
18
18
|
# The field turns red on its own; the *message* needs cells the field
|
|
19
19
|
# doesn't own, so whoever has them — a `FormLayout`, or an app's own
|
|
20
|
-
# {Label} — subscribes to {#on_error_message_change} and paints
|
|
21
|
-
# {Theme#error_color}.
|
|
20
|
+
# {Label} — subscribes to {#on_error_message_change} and paints
|
|
21
|
+
# {#shown_message} in {Theme#error_color}.
|
|
22
22
|
#
|
|
23
23
|
# Included by {HasValue}, so every field has it. Include it directly in a
|
|
24
24
|
# component that can be invalid without being a field (a form section
|
|
@@ -33,7 +33,7 @@ module Tuile
|
|
|
33
33
|
# still has to look focused (`design/decisions.md` `D_has_validation`).
|
|
34
34
|
#
|
|
35
35
|
# The well reaches the whole widget with nothing forwarding it: a composed
|
|
36
|
-
# field's inner face is marked {
|
|
36
|
+
# field's inner face is marked {ComponentBackground::INHERIT} and a group's {List}
|
|
37
37
|
# declares no background, so both walk up the ordinary background chain and
|
|
38
38
|
# land on the composer's answer.
|
|
39
39
|
#
|
|
@@ -52,15 +52,29 @@ module Tuile
|
|
|
52
52
|
# a non-nil message. Assign `""` for a verdict with nothing to say.
|
|
53
53
|
#
|
|
54
54
|
# Unlike `bad_input?`, this fact is *discrete* — asserted at a click or a
|
|
55
|
-
# binder pass, not recomputed per keystroke —
|
|
56
|
-
#
|
|
57
|
-
# `D_bad_input`,
|
|
55
|
+
# binder pass, not recomputed per keystroke — so its notice fires straight
|
|
56
|
+
# off the write, where {HasBadInput#on_bad_input_change} has a continuous
|
|
57
|
+
# fact to settle first (`design/decisions.md` `D_bad_input`,
|
|
58
|
+
# `D_has_validation`).
|
|
58
59
|
module HasValidation
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
|
|
60
|
+
extend Listeners::Declare
|
|
61
|
+
|
|
62
|
+
# What {#on_error_message_change} fires.
|
|
63
|
+
#
|
|
64
|
+
# @!attribute [r] source
|
|
65
|
+
# @return [Component] the field whose verdict changed.
|
|
66
|
+
# @!attribute [r] error_message
|
|
67
|
+
# @return [StyledString, nil] the new message, `nil` when the field just
|
|
68
|
+
# became valid.
|
|
69
|
+
ErrorMessageChangeEvent = Data.define(:source, :error_message) { include Tuile::Event }
|
|
70
|
+
|
|
71
|
+
# @!method on_error_message_change
|
|
72
|
+
# Fired with an {ErrorMessageChangeEvent} whenever {#error_message}
|
|
73
|
+
# actually changes — never on a no-op set. The container that paints the
|
|
74
|
+
# message registers here, and so may an app painting its own: the list
|
|
75
|
+
# takes both, which a single slot could not.
|
|
76
|
+
# @return [Listeners]
|
|
77
|
+
listener :on_error_message_change
|
|
64
78
|
|
|
65
79
|
# @return [StyledString, nil] why the field is invalid, or `nil` when it
|
|
66
80
|
# is not; `nil` until something sets it.
|
|
@@ -80,9 +94,21 @@ module Tuile
|
|
|
80
94
|
|
|
81
95
|
@error_message = new_message
|
|
82
96
|
invalidate
|
|
83
|
-
on_error_message_change
|
|
97
|
+
on_error_message_change.fire(ErrorMessageChangeEvent.new(source: self, error_message: new_message))
|
|
84
98
|
end
|
|
85
99
|
|
|
100
|
+
# The message a consumer with cells of its own paints beside the field:
|
|
101
|
+
# the verdict here, and {HasBadInput} widens it to prefer the field's own
|
|
102
|
+
# report, which outranks a verdict computed a pass ago.
|
|
103
|
+
#
|
|
104
|
+
# field.on_error_message_change { message_label.caption = field.shown_message.to_s }
|
|
105
|
+
# field.on_bad_input_change { message_label.caption = field.shown_message.to_s }
|
|
106
|
+
#
|
|
107
|
+
# Register on both: two channels with two writers, either of which moves it.
|
|
108
|
+
# @return [StyledString, String, nil] `nil` when there is nothing to show;
|
|
109
|
+
# a plain `String` when it is the field's own bad-input report.
|
|
110
|
+
def shown_message = error_message
|
|
111
|
+
|
|
86
112
|
protected
|
|
87
113
|
|
|
88
114
|
# The invalid well, picked up by everything this component paints —
|
|
@@ -8,42 +8,95 @@ module Tuile
|
|
|
8
8
|
# not caring that a {TextField}'s value is a `String` while another field's
|
|
9
9
|
# is a domain object.
|
|
10
10
|
#
|
|
11
|
-
# field.on_value_change
|
|
12
|
-
# field.value = "hello" # fires the listener
|
|
11
|
+
# field.on_value_change { |e| puts "now: #{e.value.inspect}" }
|
|
12
|
+
# field.value = "hello" # fires the listener, e.from_user? false
|
|
13
13
|
# field.clear # value = empty_value, fires again
|
|
14
14
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
15
|
+
# == Who made the change
|
|
16
|
+
# Every event tells typing from a programmatic write, so a listener keeps
|
|
17
|
+
# no guard flag of its own:
|
|
18
|
+
#
|
|
19
|
+
# field.on_value_change { |e| open_palette if e.from_user? }
|
|
20
|
+
# field.value = "x" # e.from_user? is false
|
|
21
|
+
# field.set_value("x", from_user: true) # an app writing for the user
|
|
22
|
+
#
|
|
23
|
+
# The default {#set_value}/{#value} keep the value in `@value` and are
|
|
24
|
+
# enough for a component with nothing more natural — you get a repaint and
|
|
25
|
+
# the listener for free. An includer whose value lives elsewhere overrides
|
|
26
|
+
# both ({AbstractStringField} backs them with its text buffer); **the
|
|
27
|
+
# override point is {#set_value}, never {#value=}**, which is only its
|
|
28
|
+
# `from_user: false` spelling. Override {#empty_value} when the empty
|
|
29
|
+
# sentinel isn't `nil` (a text field's is `""`).
|
|
20
30
|
#
|
|
21
31
|
# {HasValidation} comes with it, so every field carries the
|
|
22
32
|
# `error_message` a validator writes and paints its own error ink.
|
|
23
33
|
#
|
|
24
34
|
# == Implementation details
|
|
25
35
|
# Deliberately smaller than Vaadin's `HasValue`: read-only,
|
|
26
|
-
# required-indicator
|
|
27
|
-
#
|
|
36
|
+
# required-indicator and converters belong to the not-yet-built form layer,
|
|
37
|
+
# not here. Of Vaadin's event payload, `isFromClient` is carried (as
|
|
38
|
+
# {ValueChangeEvent#from_user?}) and `getOldValue` is not, until something
|
|
39
|
+
# reads it (`D_from_user`).
|
|
28
40
|
module HasValue
|
|
29
41
|
include HasValidation
|
|
42
|
+
extend Listeners::Declare
|
|
43
|
+
|
|
44
|
+
# What {#on_value_change} fires.
|
|
45
|
+
#
|
|
46
|
+
# @!attribute [r] source
|
|
47
|
+
# @return [Component] the field whose value changed.
|
|
48
|
+
# @!attribute [r] value
|
|
49
|
+
# @return [Object] the new value.
|
|
50
|
+
# @!attribute [r] from_user
|
|
51
|
+
# @return [Boolean] what the writer declared; read it as {#from_user?}.
|
|
52
|
+
ValueChangeEvent = Data.define(:source, :value, :from_user) do
|
|
53
|
+
include Tuile::Event
|
|
30
54
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
55
|
+
# @return [Boolean] whether the change came from the user — typing, a
|
|
56
|
+
# paste, a click, a key — or from an app writing on the user's
|
|
57
|
+
# behalf through {HasValue#set_value}; `false` for every {HasValue#value=}.
|
|
58
|
+
def from_user? = from_user
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @!method on_value_change
|
|
62
|
+
# Fired with a {ValueChangeEvent} whenever {#value} actually changes —
|
|
63
|
+
# never on a no-op set.
|
|
64
|
+
# @return [Listeners]
|
|
65
|
+
listener :on_value_change
|
|
34
66
|
|
|
35
67
|
# @return [Object] the current value; `nil` until first set.
|
|
36
68
|
def value = @value
|
|
37
69
|
|
|
38
|
-
#
|
|
70
|
+
# A programmatic write: {#set_value} with `from_user: false`. Defined
|
|
71
|
+
# here once and never overridden — override {#set_value} instead, since a
|
|
72
|
+
# setter has no call syntax for the keyword.
|
|
39
73
|
# @param new_value [Object]
|
|
40
74
|
# @return [void]
|
|
41
75
|
def value=(new_value)
|
|
76
|
+
set_value(new_value, from_user: false)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Writes the value and fires {#on_value_change} carrying `from_user`.
|
|
80
|
+
# No-op (no repaint, no listener) when equal to the current value.
|
|
81
|
+
#
|
|
82
|
+
# field.set_value(Date.today, from_user: true) # a "Today" button's click
|
|
83
|
+
#
|
|
84
|
+
# The flag is the writer's claim, never derived from key dispatch and
|
|
85
|
+
# never checked: pass `true` when the write *is* a user's gesture — the
|
|
86
|
+
# gem's own key and mouse handlers do, as does {Testing.set_value} — and
|
|
87
|
+
# `false` for what the app decides alone. A wrong flag is silent, and
|
|
88
|
+
# `false` is the side that fails safe (`D_from_user`).
|
|
89
|
+
#
|
|
90
|
+
# The override point for an includer: call `super` with `from_user:`.
|
|
91
|
+
# @param new_value [Object]
|
|
92
|
+
# @param from_user [Boolean]
|
|
93
|
+
# @return [void]
|
|
94
|
+
def set_value(new_value, from_user:)
|
|
42
95
|
return if value == new_value
|
|
43
96
|
|
|
44
97
|
@value = new_value
|
|
45
98
|
invalidate
|
|
46
|
-
on_value_change
|
|
99
|
+
on_value_change.fire(ValueChangeEvent.new(source: self, value: new_value, from_user:))
|
|
47
100
|
end
|
|
48
101
|
|
|
49
102
|
# Empty of *value*: a field whose parse is partial reports `true` while the
|
|
@@ -56,8 +109,9 @@ module Tuile
|
|
|
56
109
|
#
|
|
57
110
|
# An includer whose input can outrun its value ({HasBadInput}) must clear
|
|
58
111
|
# the *input*: a field holding bad input already reads `empty_value`, so
|
|
59
|
-
# inheriting this default — over a {#
|
|
60
|
-
# set — is a `clear` that leaves the garbage on screen.
|
|
112
|
+
# inheriting this default — over a {#set_value} that returns early on a
|
|
113
|
+
# no-op set — is a `clear` that leaves the garbage on screen. A `clear` is
|
|
114
|
+
# programmatic: its event says `from_user? == false`.
|
|
61
115
|
# @return [void]
|
|
62
116
|
def clear = (self.value = empty_value)
|
|
63
117
|
|
|
@@ -10,7 +10,7 @@ module Tuile
|
|
|
10
10
|
# An empty or otherwise un-parseable buffer reads back as `nil`:
|
|
11
11
|
#
|
|
12
12
|
# field = Component::IntegerField.new
|
|
13
|
-
# field.on_value_change
|
|
13
|
+
# field.on_value_change { |e| puts e.value.inspect } # Integer or nil, per change
|
|
14
14
|
# field.value = 42 # field shows "42"
|
|
15
15
|
# field.value # => 42
|
|
16
16
|
# field.clear # empties it; value => nil
|
|
@@ -67,8 +67,8 @@ module Tuile
|
|
|
67
67
|
def initialize
|
|
68
68
|
super(Field.new)
|
|
69
69
|
# Not the general on_key interceptor: that slot stays free for the app.
|
|
70
|
-
editor.on_key_up
|
|
71
|
-
editor.on_key_down
|
|
70
|
+
editor.on_key_up { step(1) }
|
|
71
|
+
editor.on_key_down { step(-1) }
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
# @return [Integer, nil] the parsed buffer; `nil` when empty or not a
|
|
@@ -82,9 +82,10 @@ module Tuile
|
|
|
82
82
|
# Writes `new_value` into the buffer and parks the caret at its end; fires
|
|
83
83
|
# {#on_value_change} only if the value actually changed.
|
|
84
84
|
# @param new_value [Integer, nil] `nil` empties the field.
|
|
85
|
+
# @param from_user [Boolean] see {HasValue#set_value}.
|
|
85
86
|
# @return [void]
|
|
86
|
-
def
|
|
87
|
-
editor.
|
|
87
|
+
def set_value(new_value, from_user:)
|
|
88
|
+
editor.set_value(new_value.nil? ? "" : new_value.to_s, from_user:)
|
|
88
89
|
editor.caret = editor.text.length
|
|
89
90
|
end
|
|
90
91
|
|
|
@@ -102,7 +103,7 @@ module Tuile
|
|
|
102
103
|
# Nudges {#value} by `delta`, treating an empty/un-parseable field as `0`.
|
|
103
104
|
# @param delta [Integer]
|
|
104
105
|
# @return [void]
|
|
105
|
-
def step(delta) = (
|
|
106
|
+
def step(delta) = set_value((value || 0) + delta, from_user: true)
|
|
106
107
|
end
|
|
107
108
|
end
|
|
108
109
|
end
|
|
@@ -44,23 +44,26 @@ module Tuile
|
|
|
44
44
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
45
45
|
# painted explicitly (with pre-padded blanks past the last line), so
|
|
46
46
|
# the "fully draw over your rect" contract is met without an upfront
|
|
47
|
-
# wipe. Rows go through {
|
|
47
|
+
# wipe. Rows go through {Canvas#set_text}, so the text, the trailing
|
|
48
48
|
# padding and the blank rows all take {Component#bg_color}, and a span
|
|
49
49
|
# that carries its own background keeps it.
|
|
50
|
+
# @param canvas [Canvas] see {Component#repaint}.
|
|
50
51
|
# @return [void]
|
|
51
|
-
def repaint
|
|
52
|
+
def repaint(canvas)
|
|
52
53
|
return if rect.empty?
|
|
53
54
|
|
|
54
55
|
(0...rect.height).each do |row|
|
|
55
56
|
line = @rows[row] || @blank_row
|
|
56
|
-
|
|
57
|
+
canvas.set_text(0, row, line)
|
|
57
58
|
end
|
|
58
59
|
end
|
|
59
60
|
|
|
60
61
|
protected
|
|
61
62
|
|
|
63
|
+
# Re-pads the rows to the new width; a height change costs a cheap
|
|
64
|
+
# redundant pass, which beats keying the cache.
|
|
62
65
|
# @return [void]
|
|
63
|
-
def
|
|
66
|
+
def relayout
|
|
64
67
|
super
|
|
65
68
|
update_rows
|
|
66
69
|
end
|
|
@@ -75,17 +78,7 @@ module Tuile
|
|
|
75
78
|
def update_rows
|
|
76
79
|
width = rect.width.clamp(0, nil)
|
|
77
80
|
@blank_row = StyledString.plain(" " * width)
|
|
78
|
-
@rows = @text.lines.map { |line|
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# @param line [StyledString]
|
|
82
|
-
# @param width [Integer]
|
|
83
|
-
# @return [StyledString]
|
|
84
|
-
def pad_to(line, width)
|
|
85
|
-
diff = width - line.display_width
|
|
86
|
-
return line if diff <= 0
|
|
87
|
-
|
|
88
|
-
line + StyledString.plain(" " * diff)
|
|
81
|
+
@rows = @text.lines.map { |line| line.ellipsize(width).ljust(width) }
|
|
89
82
|
end
|
|
90
83
|
end
|
|
91
84
|
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
class Layout
|
|
6
|
+
# Places each child at the {Rect} it was added with, in this layout's own
|
|
7
|
+
# coordinates, and does no arithmetic of its own:
|
|
8
|
+
#
|
|
9
|
+
# layout = Component::Layout::Absolute.new
|
|
10
|
+
# layout.add(title, Rect.new(0, 0, 40, 1))
|
|
11
|
+
# layout.add(body, Rect.new(0, 2, 40, 10))
|
|
12
|
+
# layout.constrain(body, Rect.new(0, 2, 40, 20)) # moves it on the next settle
|
|
13
|
+
#
|
|
14
|
+
# The rect is where the child wants to be, and this layout's
|
|
15
|
+
# {Component#relayout} is what assigns it, so moving a child means
|
|
16
|
+
# {#constrain}ing it rather than writing its `rect`. A rect that sticks out
|
|
17
|
+
# of this layout is clipped at paint time, and this layout's own size
|
|
18
|
+
# doesn't matter: a detached `Absolute` with no rect still places its
|
|
19
|
+
# children, which is how a spec measures a tree that has no screen.
|
|
20
|
+
#
|
|
21
|
+
# When the rects depend on this layout's size, subclass {Layout} and write
|
|
22
|
+
# the `relayout` instead.
|
|
23
|
+
class Absolute < Layout
|
|
24
|
+
def initialize
|
|
25
|
+
super
|
|
26
|
+
# Identity-keyed: two == children are still two distinct slots.
|
|
27
|
+
@rects = {}.compare_by_identity
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param child [Component, Enumerable<Component>] one child, or several
|
|
31
|
+
# sharing the rect.
|
|
32
|
+
# @param rect [Rect, nil] where the child sits, in this layout's
|
|
33
|
+
# coordinates; `nil` keeps the rect the child already has, which for a
|
|
34
|
+
# new component is empty.
|
|
35
|
+
# @raise [TypeError] if `child` is not a {Component} or `rect` not a {Rect}.
|
|
36
|
+
# @return [void]
|
|
37
|
+
def add(child, rect = nil)
|
|
38
|
+
return child.each { add(_1, rect) } if child.is_a?(Enumerable)
|
|
39
|
+
|
|
40
|
+
rect ||= child.rect if child.is_a?(Component)
|
|
41
|
+
validate_rect(rect)
|
|
42
|
+
add_child(child)
|
|
43
|
+
@rects[child] = rect
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Moves a child already in the layout; it takes the new rect on the next
|
|
47
|
+
# settle.
|
|
48
|
+
# @param child [Component] a child of this layout.
|
|
49
|
+
# @param rect [Rect]
|
|
50
|
+
# @raise [ArgumentError] if `child` is not a child of this layout.
|
|
51
|
+
# @raise [TypeError] if `rect` is not a {Rect}.
|
|
52
|
+
# @return [void]
|
|
53
|
+
def constrain(child, rect)
|
|
54
|
+
raise ArgumentError, "#{child} is not a child of #{self}" unless children.any? { _1.equal?(child) }
|
|
55
|
+
|
|
56
|
+
validate_rect(rect)
|
|
57
|
+
return if @rects[child] == rect
|
|
58
|
+
|
|
59
|
+
@rects[child] = rect
|
|
60
|
+
invalidate_layout
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param child [Component]
|
|
64
|
+
# @return [void]
|
|
65
|
+
def remove(child)
|
|
66
|
+
super
|
|
67
|
+
@rects.delete(child)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# @return [void]
|
|
73
|
+
def relayout
|
|
74
|
+
children.each { _1.rect = @rects.fetch(_1) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param rect [Object]
|
|
78
|
+
# @raise [TypeError] unless `rect` is a {Rect}.
|
|
79
|
+
# @return [void]
|
|
80
|
+
def validate_rect(rect)
|
|
81
|
+
raise TypeError, "expected Rect, got #{rect.inspect}" unless rect.is_a?(Rect)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -37,15 +37,15 @@ module Tuile
|
|
|
37
37
|
#
|
|
38
38
|
# Every child-list mutation re-runs the whole pass, because in a box the
|
|
39
39
|
# children move: removing one shifts everything after it, and adding one
|
|
40
|
-
# shrinks every {Expand} share.
|
|
41
|
-
# are independent.)
|
|
40
|
+
# shrinks every {Expand} share.
|
|
42
41
|
#
|
|
43
42
|
# Main-axis resolution order, against
|
|
44
43
|
# `available = extent - padding - spacing * (children - 1)`:
|
|
45
44
|
#
|
|
46
|
-
# 1. {Fixed}
|
|
47
|
-
#
|
|
48
|
-
#
|
|
45
|
+
# 1. In declaration order, each {Fixed}, {Percent} or {Clamp} takes the
|
|
46
|
+
# cells it resolves to — a {Percent} its share *of `available`*, a
|
|
47
|
+
# {Clamp} that share bounded — clamped to what is still unassigned.
|
|
48
|
+
# 2. {Expand} children split the residue by weight; the integer remainder
|
|
49
49
|
# goes to the earliest of them, one cell each.
|
|
50
50
|
#
|
|
51
51
|
# So over-subscription starves in declaration order rather than raising:
|
|
@@ -86,7 +86,7 @@ module Tuile
|
|
|
86
86
|
return if @spacing == cells
|
|
87
87
|
|
|
88
88
|
@spacing = cells
|
|
89
|
-
|
|
89
|
+
invalidate_layout
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
# @param insets [Insets, Integer] an Integer becomes a uniform inset.
|
|
@@ -97,7 +97,7 @@ module Tuile
|
|
|
97
97
|
return if @padding == insets
|
|
98
98
|
|
|
99
99
|
@padding = insets
|
|
100
|
-
|
|
100
|
+
invalidate_layout
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
# Adds a child — or every element of an Enumerable, all with the same
|
|
@@ -108,8 +108,8 @@ module Tuile
|
|
|
108
108
|
# add(sidebar, Expand[1], at: 0) # back where it was, after a #remove
|
|
109
109
|
#
|
|
110
110
|
# @param child [Component, Enumerable<Component>]
|
|
111
|
-
# @param main [
|
|
112
|
-
# @param cross [
|
|
111
|
+
# @param main [Constraint] extent along the main axis.
|
|
112
|
+
# @param cross [Constraint] extent across it; not an {Expand}.
|
|
113
113
|
# @param align [Symbol] one of {ALIGNMENTS} — where a child narrower than
|
|
114
114
|
# the cross extent sits. {Vertical} / {Horizontal} say which edge
|
|
115
115
|
# `:start` is.
|
|
@@ -131,7 +131,7 @@ module Tuile
|
|
|
131
131
|
validate_align(align)
|
|
132
132
|
add_child(child, at:)
|
|
133
133
|
@placements[child] = { main:, cross:, align: }
|
|
134
|
-
|
|
134
|
+
invalidate_layout
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
# Re-constrains a child already in the layout and re-runs the pass. A
|
|
@@ -143,8 +143,8 @@ module Tuile
|
|
|
143
143
|
# hiding, and the class doc for what is.
|
|
144
144
|
#
|
|
145
145
|
# @param child [Component] a child of this layout.
|
|
146
|
-
# @param main [
|
|
147
|
-
# @param cross [
|
|
146
|
+
# @param main [Constraint, nil] extent along the main axis.
|
|
147
|
+
# @param cross [Constraint, nil] extent across it; not an {Expand}.
|
|
148
148
|
# @param align [Symbol, nil] one of {ALIGNMENTS}.
|
|
149
149
|
# @raise [ArgumentError] if `child` is not a child of this layout, or on
|
|
150
150
|
# an unknown constraint or alignment.
|
|
@@ -162,7 +162,7 @@ module Tuile
|
|
|
162
162
|
return if current == updated
|
|
163
163
|
|
|
164
164
|
@placements[child] = updated
|
|
165
|
-
|
|
165
|
+
invalidate_layout
|
|
166
166
|
end
|
|
167
167
|
|
|
168
168
|
# Removes the child, forgets its constraints, and closes the gap it left
|
|
@@ -172,56 +172,38 @@ module Tuile
|
|
|
172
172
|
def remove(child)
|
|
173
173
|
super
|
|
174
174
|
@placements.delete(child)
|
|
175
|
-
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
# @param new_rect [Rect]
|
|
179
|
-
# @return [void]
|
|
180
|
-
def rect=(new_rect)
|
|
181
|
-
super
|
|
182
|
-
relayout
|
|
183
|
-
end
|
|
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
|
|
175
|
+
invalidate_layout
|
|
196
176
|
end
|
|
197
177
|
|
|
198
178
|
private
|
|
199
179
|
|
|
200
|
-
#
|
|
201
|
-
#
|
|
180
|
+
# Re-divides the space, giving each child an empty rect when this
|
|
181
|
+
# layout's own — or {#inner_rect} — is empty. A child that went hidden
|
|
182
|
+
# gives its slot *and* the {#spacing} around it to its siblings, and one
|
|
183
|
+
# that came back takes them again with the constraints it was added
|
|
184
|
+
# with; that is what {Component#visible=} buys over `remove` plus
|
|
185
|
+
# `add(…, at:)`.
|
|
202
186
|
#
|
|
203
187
|
# Deliberately *no* `return if rect.empty?` guard: that strands the
|
|
204
188
|
# children at the coordinates they last had, and the next full repaint
|
|
205
|
-
# paints them there (`D_empty_ancestor`).
|
|
206
|
-
# one anyway — {#add} runs before a parent assigns a rect, so the
|
|
207
|
-
# children are already empty and `invalidate` no-ops while detached.
|
|
189
|
+
# paints them there (`D_empty_ancestor`).
|
|
208
190
|
# @return [void]
|
|
209
191
|
def relayout
|
|
210
192
|
inner = inner_rect
|
|
211
|
-
collapsed = Rect.new(
|
|
193
|
+
collapsed = Rect.new(0, 0, 0, 0)
|
|
212
194
|
if rect.empty? || inner.empty?
|
|
213
195
|
children.each { _1.rect = collapsed }
|
|
214
196
|
else
|
|
215
197
|
children.each { _1.rect = collapsed unless _1.visible? }
|
|
216
198
|
place_children(inner)
|
|
217
199
|
end
|
|
218
|
-
invalidate
|
|
219
200
|
end
|
|
220
201
|
|
|
221
|
-
# @return [Rect] {#
|
|
222
|
-
#
|
|
202
|
+
# @return [Rect] {Component#local_rect} with {#padding} taken off each
|
|
203
|
+
# edge — this layout's own coordinates, which are its children's too;
|
|
204
|
+
# may be {Rect#empty? empty}.
|
|
223
205
|
def inner_rect
|
|
224
|
-
Rect.new(
|
|
206
|
+
Rect.new(padding.left, padding.top,
|
|
225
207
|
rect.width - padding.horizontal, rect.height - padding.vertical)
|
|
226
208
|
end
|
|
227
209
|
|
|
@@ -260,10 +242,11 @@ module Tuile
|
|
|
260
242
|
unassigned = available
|
|
261
243
|
|
|
262
244
|
kids.each_with_index do |child, i|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
else
|
|
245
|
+
cells = placement(child)[:main].resolve(available)
|
|
246
|
+
if cells.nil?
|
|
247
|
+
expanding << i
|
|
248
|
+
else
|
|
249
|
+
unassigned -= (sizes[i] = cells.clamp(0, unassigned))
|
|
267
250
|
end
|
|
268
251
|
end
|
|
269
252
|
|
|
@@ -295,10 +278,7 @@ module Tuile
|
|
|
295
278
|
# extent, along the cross axis.
|
|
296
279
|
def cross_placement(child, available)
|
|
297
280
|
spec = placement(child)
|
|
298
|
-
size =
|
|
299
|
-
when Fixed then constraint.cells.clamp(0, available)
|
|
300
|
-
else percent_of(available, constraint).clamp(0, available)
|
|
301
|
-
end
|
|
281
|
+
size = spec[:cross].resolve(available).clamp(0, available)
|
|
302
282
|
[align_offset(spec[:align], available - size), size]
|
|
303
283
|
end
|
|
304
284
|
|
|
@@ -313,11 +293,6 @@ module Tuile
|
|
|
313
293
|
end
|
|
314
294
|
end
|
|
315
295
|
|
|
316
|
-
# @param extent [Integer]
|
|
317
|
-
# @param constraint [Percent]
|
|
318
|
-
# @return [Integer]
|
|
319
|
-
def percent_of(extent, constraint) = (extent * constraint.percent / 100.0).round
|
|
320
|
-
|
|
321
296
|
# @param child [Component]
|
|
322
297
|
# @return [Hash{Symbol => Object}] the child's `main`/`cross`/`align`.
|
|
323
298
|
def placement(child) = @placements[child] || DEFAULT_PLACEMENT
|
|
@@ -352,25 +327,25 @@ module Tuile
|
|
|
352
327
|
end
|
|
353
328
|
|
|
354
329
|
# @param constraint [Object]
|
|
355
|
-
# @raise [ArgumentError] unless it is a {
|
|
330
|
+
# @raise [ArgumentError] unless it is a {Constraint}.
|
|
356
331
|
# @return [void]
|
|
357
332
|
def validate_main(constraint)
|
|
358
|
-
return if
|
|
333
|
+
return if constraint.is_a?(Constraint)
|
|
359
334
|
|
|
360
|
-
raise ArgumentError, "expected Fixed, Percent or
|
|
335
|
+
raise ArgumentError, "expected Fixed, Percent, Expand or Clamp, got #{constraint.inspect}"
|
|
361
336
|
end
|
|
362
337
|
|
|
363
338
|
# @param constraint [Object]
|
|
364
|
-
# @raise [ArgumentError] unless it is a {
|
|
339
|
+
# @raise [ArgumentError] unless it is a {Constraint} other than {Expand}.
|
|
365
340
|
# @return [void]
|
|
366
341
|
def validate_cross(constraint)
|
|
367
342
|
if constraint.is_a? Expand
|
|
368
343
|
raise ArgumentError, "Expand is main-axis only — a child has no siblings competing " \
|
|
369
344
|
"across the axis; use Fixed or Percent for cross:"
|
|
370
345
|
end
|
|
371
|
-
return if constraint.is_a?(
|
|
346
|
+
return if constraint.is_a?(Constraint)
|
|
372
347
|
|
|
373
|
-
raise ArgumentError, "expected Fixed or
|
|
348
|
+
raise ArgumentError, "expected Fixed, Percent or Clamp for cross:, got #{constraint.inspect}"
|
|
374
349
|
end
|
|
375
350
|
|
|
376
351
|
# @param align [Object]
|