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.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +21 -12
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +5 -4
  7. data/book/05-focus.md +12 -9
  8. data/book/06-theming.md +55 -17
  9. data/book/07-components.md +188 -40
  10. data/book/08-testing.md +115 -15
  11. data/book/10-locale.md +1 -1
  12. data/book/README.md +5 -5
  13. data/examples/file_commander.rb +38 -27
  14. data/examples/hello_world.rb +1 -1
  15. data/examples/sampler.rb +225 -169
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +81 -80
  21. data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +19 -11
  24. data/lib/tuile/component/checkbox.rb +12 -10
  25. data/lib/tuile/component/checkbox_group.rb +11 -13
  26. data/lib/tuile/component/combo_box.rb +30 -40
  27. data/lib/tuile/component/confirm_window.rb +27 -22
  28. data/lib/tuile/component/date_field.rb +27 -20
  29. data/lib/tuile/component/date_time_field.rb +75 -31
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +98 -27
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +5 -12
  37. data/lib/tuile/component/has_validation.rb +39 -13
  38. data/lib/tuile/component/has_value.rb +70 -16
  39. data/lib/tuile/component/integer_field.rb +7 -6
  40. data/lib/tuile/component/label.rb +8 -15
  41. data/lib/tuile/component/layout/absolute.rb +86 -0
  42. data/lib/tuile/component/layout/box.rb +38 -63
  43. data/lib/tuile/component/layout.rb +124 -10
  44. data/lib/tuile/component/list.rb +197 -94
  45. data/lib/tuile/component/list_dropdown.rb +148 -88
  46. data/lib/tuile/component/menu_bar/cascade.rb +97 -27
  47. data/lib/tuile/component/menu_bar.rb +84 -64
  48. data/lib/tuile/component/notification.rb +44 -31
  49. data/lib/tuile/component/overlay.rb +210 -52
  50. data/lib/tuile/component/password_field.rb +1 -8
  51. data/lib/tuile/component/picker_window.rb +15 -10
  52. data/lib/tuile/component/popup.rb +13 -24
  53. data/lib/tuile/component/progress_bar.rb +7 -7
  54. data/lib/tuile/component/radio_group.rb +10 -12
  55. data/lib/tuile/component/scroller.rb +266 -0
  56. data/lib/tuile/component/select.rb +15 -31
  57. data/lib/tuile/component/slot.rb +1 -2
  58. data/lib/tuile/component/tab_sheet.rb +21 -28
  59. data/lib/tuile/component/tabs.rb +39 -24
  60. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  61. data/lib/tuile/component/text_area.rb +21 -19
  62. data/lib/tuile/component/text_field.rb +55 -39
  63. data/lib/tuile/component/text_view.rb +143 -79
  64. data/lib/tuile/component/time_field.rb +26 -21
  65. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  66. data/lib/tuile/component/window.rb +27 -26
  67. data/lib/tuile/component.rb +481 -259
  68. data/lib/tuile/component_background.rb +177 -0
  69. data/lib/tuile/component_util.rb +43 -0
  70. data/lib/tuile/event.rb +29 -0
  71. data/lib/tuile/event_queue.rb +14 -0
  72. data/lib/tuile/fake_screen.rb +41 -10
  73. data/lib/tuile/keys.rb +15 -6
  74. data/lib/tuile/layout_pass.rb +180 -0
  75. data/lib/tuile/listeners.rb +219 -0
  76. data/lib/tuile/mouse/router.rb +51 -35
  77. data/lib/tuile/mouse.rb +96 -29
  78. data/lib/tuile/point.rb +6 -0
  79. data/lib/tuile/rect.rb +33 -0
  80. data/lib/tuile/screen.rb +419 -84
  81. data/lib/tuile/screen_pane.rb +144 -31
  82. data/lib/tuile/strict_layout.rb +127 -0
  83. data/lib/tuile/styled_string.rb +139 -9
  84. data/lib/tuile/testing/gestures.rb +35 -0
  85. data/lib/tuile/testing.rb +310 -36
  86. data/lib/tuile/theme.rb +170 -19
  87. data/lib/tuile/theme_def.rb +4 -0
  88. data/lib/tuile/version.rb +1 -1
  89. data/lib/tuile.rb +53 -0
  90. data/sig/tuile.rbs +4951 -1158
  91. metadata +16 -2
  92. 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 provides a protected
18
- # `layout(content)` positioning the child; the mixin owns the swap:
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 layout(content) = content.rect = rect
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 = lambda do
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 the text in
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 {Component::BG_INHERIT} and a group's {List}
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 — which is why it carries a
56
- # change notice where `bad_input?` deliberately doesn't (`design/decisions.md`
57
- # `D_bad_input`, `D_has_validation`).
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
- # @return [Proc, Method, nil] one-arg callable fired with the new message
60
- # (or `nil`) whenever {#error_message} actually changes — never on a
61
- # no-op set. Claimed by the container that paints the message; an app
62
- # painting its own takes it instead.
63
- attr_accessor :on_error_message_change
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&.call(new_message)
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 = ->(v) { puts "now: #{v.inspect}" }
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
- # 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 `""`).
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, the from-client/old-value event payload, and
27
- # converters all belong to the not-yet-built form layer, not here.
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
- # @return [Proc, Method, nil] one-arg callable fired with the new value
32
- # whenever {#value} actually changes — never on a no-op set.
33
- attr_accessor :on_value_change
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
- # No-op (no repaint, no listener) when equal to the current value.
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&.call(new_value)
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 {#value=} that returns early on a no-op
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 = ->(n) { puts n.inspect } # Integer or nil, per 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 = -> { step(1) }
71
- editor.on_key_down = -> { step(-1) }
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 value=(new_value)
87
- editor.text = new_value.nil? ? "" : new_value.to_s
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) = (self.value = (value || 0) + 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 {Component#draw_text}, so the text, the trailing
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
- draw_text(rect.left, rect.top + row, line)
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 handle_width_changed
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| pad_to(line.ellipsize(width), width) }
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. ({Absolute} can skip this — there, siblings
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} takes its cells, clamped to what is still unassigned.
47
- # 2. {Percent} takes its share *of `available`*, likewise clamped.
48
- # 3. {Expand} children split the residue by weight; the integer remainder
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
- relayout
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
- relayout
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 [Fixed, Percent, Expand] extent along the main axis.
112
- # @param cross [Fixed, Percent] extent across it.
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
- relayout
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 [Fixed, Percent, Expand, nil] extent along the main axis.
147
- # @param cross [Fixed, Percent, nil] extent across it.
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
- relayout
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
- relayout
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
- # Recomputes and assigns every child's rect, giving each an empty one
201
- # when this layout's own rect — or {#inner_rect} — is empty.
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`). 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.
189
+ # paints them there (`D_empty_ancestor`).
208
190
  # @return [void]
209
191
  def relayout
210
192
  inner = inner_rect
211
- collapsed = Rect.new(rect.left, rect.top, 0, 0)
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] {#rect} with {#padding} taken off each edge; may be
222
- # {Rect#empty? empty}.
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(rect.left + padding.left, rect.top + padding.top,
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
- case (constraint = placement(child)[:main])
264
- when Expand then expanding << i
265
- when Fixed then unassigned -= (sizes[i] = constraint.cells.clamp(0, unassigned))
266
- else unassigned -= (sizes[i] = percent_of(available, constraint).clamp(0, unassigned))
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 = case (constraint = spec[:cross])
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 {Fixed}, {Percent} or {Expand}.
330
+ # @raise [ArgumentError] unless it is a {Constraint}.
356
331
  # @return [void]
357
332
  def validate_main(constraint)
358
- return if [Fixed, Percent, Expand].any? { constraint.is_a?(_1) }
333
+ return if constraint.is_a?(Constraint)
359
334
 
360
- raise ArgumentError, "expected Fixed, Percent or Expand, got #{constraint.inspect}"
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 {Fixed} or {Percent}.
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?(Fixed) || constraint.is_a?(Percent)
346
+ return if constraint.is_a?(Constraint)
372
347
 
373
- raise ArgumentError, "expected Fixed or Percent for cross:, got #{constraint.inspect}"
348
+ raise ArgumentError, "expected Fixed, Percent or Clamp for cross:, got #{constraint.inspect}"
374
349
  end
375
350
 
376
351
  # @param align [Object]