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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -36
  3. data/DECISIONS.md +2566 -0
  4. data/README.md +37 -24
  5. data/book/03-layout.md +153 -8
  6. data/book/04-event-loop.md +86 -0
  7. data/book/05-focus.md +84 -51
  8. data/book/06-theming.md +53 -1
  9. data/book/07-components.md +458 -9
  10. data/book/08-testing.md +21 -8
  11. data/book/09-styled-text.md +132 -0
  12. data/book/README.md +22 -14
  13. data/examples/sampler.rb +632 -67
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +118 -0
  16. data/ideas/per-component-buffers.md +55 -0
  17. data/lib/tuile/buffer.rb +52 -51
  18. data/lib/tuile/color.rb +4 -10
  19. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  20. data/lib/tuile/component/big_decimal_field.rb +199 -0
  21. data/lib/tuile/component/button.rb +25 -21
  22. data/lib/tuile/component/checkbox.rb +134 -0
  23. data/lib/tuile/component/checkbox_group.rb +188 -0
  24. data/lib/tuile/component/combo_box.rb +263 -0
  25. data/lib/tuile/component/float_field.rb +161 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/integer_field.rb +135 -0
  30. data/lib/tuile/component/label.rb +13 -10
  31. data/lib/tuile/component/layout/box.rb +316 -0
  32. data/lib/tuile/component/layout/horizontal.rb +40 -0
  33. data/lib/tuile/component/layout/vertical.rb +41 -0
  34. data/lib/tuile/component/layout.rb +149 -15
  35. data/lib/tuile/component/list.rb +8 -7
  36. data/lib/tuile/component/list_dropdown.rb +157 -0
  37. data/lib/tuile/component/password_field.rb +105 -0
  38. data/lib/tuile/component/popup.rb +10 -14
  39. data/lib/tuile/component/progress_bar.rb +278 -0
  40. data/lib/tuile/component/radio_group.rb +188 -0
  41. data/lib/tuile/component/select.rb +251 -0
  42. data/lib/tuile/component/text_area.rb +189 -65
  43. data/lib/tuile/component/text_field.rb +170 -32
  44. data/lib/tuile/component/text_view.rb +57 -114
  45. data/lib/tuile/component/window.rb +32 -47
  46. data/lib/tuile/component.rb +250 -87
  47. data/lib/tuile/event_queue.rb +14 -17
  48. data/lib/tuile/fake_event_queue.rb +11 -2
  49. data/lib/tuile/fake_screen.rb +4 -5
  50. data/lib/tuile/fraction.rb +6 -10
  51. data/lib/tuile/screen.rb +202 -104
  52. data/lib/tuile/screen_pane.rb +51 -41
  53. data/lib/tuile/styled_string.rb +125 -86
  54. data/lib/tuile/theme.rb +78 -41
  55. data/lib/tuile/version.rb +1 -1
  56. data/lib/tuile.rb +4 -0
  57. data/sig/tuile.rbs +2962 -680
  58. 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 color applied uniformly across every
29
- # painted row (including padding past the text). `nil` (default)
30
- # leaves whatever bg the text's own styling carries.
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 the background color. Coerced via {Color.coerce}, so a Symbol,
49
- # Integer, Array, {Color}, or `nil` all work. `nil` clears the override
50
- # — the label paints with whatever bg the text's own styling provides.
51
- # Otherwise the bg overlays every span (including the trailing pad and
52
- # blank rows past the last text line).
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
- screen.buffer.set_line(rect.left, rect.top + row, line)
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