rubytui 1.2.3

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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +574 -0
  4. data/lib/rubytui/app.rb +174 -0
  5. data/lib/rubytui/backend.rb +158 -0
  6. data/lib/rubytui/backends/ansi_backend.rb +391 -0
  7. data/lib/rubytui/backends/test_backend.rb +223 -0
  8. data/lib/rubytui/buffer.rb +400 -0
  9. data/lib/rubytui/cell.rb +55 -0
  10. data/lib/rubytui/color.rb +153 -0
  11. data/lib/rubytui/color_mode.rb +203 -0
  12. data/lib/rubytui/errors.rb +13 -0
  13. data/lib/rubytui/event.rb +161 -0
  14. data/lib/rubytui/frame.rb +70 -0
  15. data/lib/rubytui/input/key.rb +93 -0
  16. data/lib/rubytui/input/parser.rb +231 -0
  17. data/lib/rubytui/input/reader.rb +119 -0
  18. data/lib/rubytui/layout/constraint.rb +83 -0
  19. data/lib/rubytui/layout/flex.rb +16 -0
  20. data/lib/rubytui/layout/layout.rb +205 -0
  21. data/lib/rubytui/modifier.rb +67 -0
  22. data/lib/rubytui/rect.rb +126 -0
  23. data/lib/rubytui/stateful_widget.rb +20 -0
  24. data/lib/rubytui/style.rb +143 -0
  25. data/lib/rubytui/symbols.rb +88 -0
  26. data/lib/rubytui/terminal.rb +218 -0
  27. data/lib/rubytui/text/line.rb +67 -0
  28. data/lib/rubytui/text/span.rb +34 -0
  29. data/lib/rubytui/text/text.rb +82 -0
  30. data/lib/rubytui/unicode.rb +162 -0
  31. data/lib/rubytui/version.rb +6 -0
  32. data/lib/rubytui/widget.rb +20 -0
  33. data/lib/rubytui/widgets/async_image.rb +248 -0
  34. data/lib/rubytui/widgets/block.rb +260 -0
  35. data/lib/rubytui/widgets/canvas.rb +248 -0
  36. data/lib/rubytui/widgets/chart.rb +224 -0
  37. data/lib/rubytui/widgets/gauge.rb +139 -0
  38. data/lib/rubytui/widgets/image.rb +330 -0
  39. data/lib/rubytui/widgets/input_field.rb +245 -0
  40. data/lib/rubytui/widgets/list.rb +186 -0
  41. data/lib/rubytui/widgets/paragraph.rb +181 -0
  42. data/lib/rubytui/widgets/popup.rb +140 -0
  43. data/lib/rubytui/widgets/scrollbar.rb +175 -0
  44. data/lib/rubytui/widgets/sparkline.rb +86 -0
  45. data/lib/rubytui/widgets/table.rb +231 -0
  46. data/lib/rubytui/widgets/tabs.rb +90 -0
  47. data/lib/rubytui.rb +389 -0
  48. metadata +89 -0
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ module Input
5
+ # Decodes raw terminal input into {KeyEvent} and {MouseEvent} objects.
6
+ #
7
+ # Recognised input: printable ASCII, Enter, Tab, Backspace, Ctrl+letter,
8
+ # multi-byte UTF-8 characters given as raw bytes (as read by {Reader}), ESC,
9
+ # Alt+character (ESC followed by one character),
10
+ # CSI and SS3 key sequences (arrows, Home/End, Insert/Delete, Page Up/Down,
11
+ # Back-tab, F1-F12, xterm modified arrows such as <tt>ESC [ 1 ; 5 A</tt>),
12
+ # and SGR and legacy mouse reports.
13
+ #
14
+ # @example
15
+ # Input::Parser.new.parse("\e[A") # => KeyEvent(up)
16
+ class Parser
17
+ # Parse raw bytes into an Event.
18
+ #
19
+ # @param bytes [String, nil] raw bytes of one key press or mouse report
20
+ # @return [KeyEvent, MouseEvent, nil] the decoded event, or nil if +bytes+
21
+ # is nil, empty or not a recognised sequence
22
+ def parse(bytes)
23
+ return nil if bytes.nil? || bytes.empty?
24
+
25
+ # ESC sequence
26
+ if bytes.start_with?("\e")
27
+ parse_escape(bytes)
28
+ elsif bytes.length == 1
29
+ parse_single(bytes)
30
+ else
31
+ # Multi-byte UTF-8 character
32
+ KeyEvent.new(code: Key::CHAR, char: bytes)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def parse_single(byte)
39
+ ord = byte.ord
40
+
41
+ case ord
42
+ when 13 # CR
43
+ KeyEvent.new(code: Key::ENTER)
44
+ when 9 # TAB
45
+ KeyEvent.new(code: Key::TAB)
46
+ when 127 # DEL (backspace on most terminals)
47
+ KeyEvent.new(code: Key::BACKSPACE)
48
+ when 8 # BS (backspace on some terminals)
49
+ KeyEvent.new(code: Key::BACKSPACE)
50
+ when 1..26
51
+ # Ctrl+A through Ctrl+Z
52
+ char = (ord + 96).chr # Convert to lowercase letter
53
+ KeyEvent.new(code: Key::CHAR, char: char, modifiers: Key::CTRL)
54
+ when 32..126
55
+ # Printable ASCII
56
+ KeyEvent.new(code: Key::CHAR, char: byte)
57
+ else
58
+ nil
59
+ end
60
+ end
61
+
62
+ def parse_escape(bytes)
63
+ return KeyEvent.new(code: Key::ESCAPE) if bytes == "\e"
64
+
65
+ case bytes
66
+ # SGR mouse: ESC [ < Cb ; Cx ; Cy M/m
67
+ when /\A\e\[<(\d+);(\d+);(\d+)([Mm])\z/
68
+ parse_sgr_mouse($1.to_i, $2.to_i, $3.to_i, $4)
69
+ # CSI sequences: ESC [ ...
70
+ when /\A\e\[([A-Za-z])\z/
71
+ parse_csi_letter($1)
72
+ when /\A\e\[(\d+)~\z/
73
+ parse_csi_tilde($1.to_i)
74
+ when /\A\e\[1;(\d+)([A-Za-z])\z/
75
+ parse_csi_modified($2, $1.to_i)
76
+ # Normal mouse: ESC [ M Cb Cx Cy (legacy, 3 raw bytes after M)
77
+ when /\A\e\[M(.{3})\z/m
78
+ parse_normal_mouse($1)
79
+ # SS3 sequences: ESC O ...
80
+ when /\A\eO([A-Za-z])\z/
81
+ parse_ss3($1)
82
+ # Alt+letter: ESC + letter
83
+ when /\A\e(.)\z/
84
+ KeyEvent.new(code: Key::CHAR, char: $1, modifiers: Key::ALT)
85
+ else
86
+ nil
87
+ end
88
+ end
89
+
90
+ # CSI single letter: ESC [ X
91
+ def parse_csi_letter(letter)
92
+ case letter
93
+ when "A" then KeyEvent.new(code: Key::UP)
94
+ when "B" then KeyEvent.new(code: Key::DOWN)
95
+ when "C" then KeyEvent.new(code: Key::RIGHT)
96
+ when "D" then KeyEvent.new(code: Key::LEFT)
97
+ when "H" then KeyEvent.new(code: Key::HOME)
98
+ when "F" then KeyEvent.new(code: Key::END_KEY)
99
+ when "Z" then KeyEvent.new(code: Key::BACKTAB)
100
+ else nil
101
+ end
102
+ end
103
+
104
+ # CSI tilde sequences: ESC [ N ~
105
+ def parse_csi_tilde(n)
106
+ case n
107
+ when 2 then KeyEvent.new(code: Key::INSERT)
108
+ when 3 then KeyEvent.new(code: Key::DELETE)
109
+ when 5 then KeyEvent.new(code: Key::PAGE_UP)
110
+ when 6 then KeyEvent.new(code: Key::PAGE_DOWN)
111
+ when 15 then KeyEvent.new(code: Key::F5)
112
+ when 17 then KeyEvent.new(code: Key::F6)
113
+ when 18 then KeyEvent.new(code: Key::F7)
114
+ when 19 then KeyEvent.new(code: Key::F8)
115
+ when 20 then KeyEvent.new(code: Key::F9)
116
+ when 21 then KeyEvent.new(code: Key::F10)
117
+ when 23 then KeyEvent.new(code: Key::F11)
118
+ when 24 then KeyEvent.new(code: Key::F12)
119
+ else nil
120
+ end
121
+ end
122
+
123
+ # CSI modified: ESC [ 1 ; MOD X (e.g., Shift+Up = ESC[1;2A)
124
+ def parse_csi_modified(letter, mod_code)
125
+ modifiers = decode_modifiers(mod_code)
126
+ base = parse_csi_letter(letter)
127
+ return nil unless base
128
+
129
+ KeyEvent.new(code: base.code, char: base.char, modifiers: modifiers)
130
+ end
131
+
132
+ # SS3 sequences: ESC O X (function keys on some terminals)
133
+ def parse_ss3(letter)
134
+ case letter
135
+ when "P" then KeyEvent.new(code: Key::F1)
136
+ when "Q" then KeyEvent.new(code: Key::F2)
137
+ when "R" then KeyEvent.new(code: Key::F3)
138
+ when "S" then KeyEvent.new(code: Key::F4)
139
+ when "H" then KeyEvent.new(code: Key::HOME)
140
+ when "F" then KeyEvent.new(code: Key::END_KEY)
141
+ else nil
142
+ end
143
+ end
144
+
145
+ # Decode xterm modifier parameter:
146
+ # 2=Shift, 3=Alt, 4=Shift+Alt, 5=Ctrl, 6=Shift+Ctrl, 7=Alt+Ctrl, 8=Shift+Alt+Ctrl
147
+ def decode_modifiers(code)
148
+ val = code - 1
149
+ mods = Key::NONE
150
+ mods |= Key::SHIFT if (val & 0b001) != 0
151
+ mods |= Key::ALT if (val & 0b010) != 0
152
+ mods |= Key::CTRL if (val & 0b100) != 0
153
+ mods
154
+ end
155
+
156
+ # SGR extended mouse mode: ESC [ < Cb ; Cx ; Cy M (press) or m (release)
157
+ # Cb encodes button + modifiers + drag flag
158
+ def parse_sgr_mouse(cb, cx, cy, terminator)
159
+ # Coordinates are 1-based in SGR mode
160
+ x = cx - 1
161
+ y = cy - 1
162
+
163
+ # Decode modifiers from cb
164
+ mods = Key::NONE
165
+ mods |= Key::SHIFT if (cb & 4) != 0
166
+ mods |= Key::ALT if (cb & 8) != 0
167
+ mods |= Key::CTRL if (cb & 16) != 0
168
+
169
+ is_drag = (cb & 32) != 0
170
+ is_release = terminator == "m"
171
+ button_bits = cb & 3
172
+ is_scroll = (cb & 64) != 0
173
+
174
+ if is_scroll
175
+ button = button_bits == 0 ? MouseEvent::SCROLL_UP : MouseEvent::SCROLL_DOWN
176
+ action = MouseEvent::PRESS
177
+ else
178
+ button = case button_bits
179
+ when 0 then MouseEvent::LEFT
180
+ when 1 then MouseEvent::MIDDLE
181
+ when 2 then MouseEvent::RIGHT
182
+ else MouseEvent::NONE
183
+ end
184
+ action = if is_release
185
+ MouseEvent::RELEASE
186
+ elsif is_drag
187
+ MouseEvent::DRAG
188
+ else
189
+ MouseEvent::PRESS
190
+ end
191
+ end
192
+
193
+ MouseEvent.new(button: button, action: action, x: x, y: y, modifiers: mods)
194
+ end
195
+
196
+ # Legacy normal mouse: ESC [ M Cb Cx Cy (raw bytes, 32-based encoding)
197
+ def parse_normal_mouse(raw)
198
+ cb = raw.getbyte(0) - 32
199
+ x = raw.getbyte(1) - 33
200
+ y = raw.getbyte(2) - 33
201
+
202
+ mods = Key::NONE
203
+ mods |= Key::SHIFT if (cb & 4) != 0
204
+ mods |= Key::ALT if (cb & 8) != 0
205
+ mods |= Key::CTRL if (cb & 16) != 0
206
+
207
+ is_drag = (cb & 32) != 0
208
+ is_scroll = (cb & 64) != 0
209
+ button_bits = cb & 3
210
+
211
+ if is_scroll
212
+ button = button_bits == 0 ? MouseEvent::SCROLL_UP : MouseEvent::SCROLL_DOWN
213
+ action = MouseEvent::PRESS
214
+ elsif button_bits == 3
215
+ button = MouseEvent::NONE
216
+ action = MouseEvent::RELEASE
217
+ else
218
+ button = case button_bits
219
+ when 0 then MouseEvent::LEFT
220
+ when 1 then MouseEvent::MIDDLE
221
+ when 2 then MouseEvent::RIGHT
222
+ else MouseEvent::NONE
223
+ end
224
+ action = is_drag ? MouseEvent::DRAG : MouseEvent::PRESS
225
+ end
226
+
227
+ MouseEvent.new(button: button, action: action, x: x, y: y, modifiers: mods)
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module RubyTUI
6
+ module Input
7
+ # Reads terminal input without blocking and decodes it with a {Parser}.
8
+ #
9
+ # A lone ESC byte is told apart from the start of an escape sequence by
10
+ # waiting up to {ESC_TIMEOUT_MS} for more bytes.
11
+ #
12
+ # @example
13
+ # reader = Input::Reader.new
14
+ # event = reader.poll(timeout_ms: 50) # => KeyEvent, MouseEvent or nil
15
+ class Reader
16
+ # Timeout for disambiguating standalone ESC from escape sequences (ms)
17
+ ESC_TIMEOUT_MS = 50
18
+
19
+ # @param input [IO] input stream; must support <tt>IO.select</tt> and
20
+ # +read_nonblock+ (default: +$stdin+)
21
+ def initialize(input: $stdin)
22
+ @input = input
23
+ @parser = Parser.new
24
+ end
25
+
26
+ # Poll for an event with the given timeout.
27
+ #
28
+ # @param timeout_ms [Integer] maximum time to wait for input, in milliseconds
29
+ # (default: 100)
30
+ # @return [KeyEvent, MouseEvent, nil] the decoded event, or nil if no input
31
+ # arrived within the timeout or the bytes read were not recognised
32
+ def poll(timeout_ms: 100)
33
+ timeout_sec = timeout_ms / 1000.0
34
+ ready = IO.select([@input], nil, nil, timeout_sec)
35
+ return nil unless ready
36
+
37
+ read_event
38
+ end
39
+
40
+ # Blocking read - waits indefinitely for an event.
41
+ #
42
+ # Calls {#poll} in a loop until it returns an event.
43
+ #
44
+ # @return [KeyEvent, MouseEvent] the next recognised event
45
+ def read
46
+ loop do
47
+ event = poll(timeout_ms: 1000)
48
+ return event if event
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def read_event
55
+ # Read the first byte
56
+ first = read_byte
57
+ return nil unless first
58
+
59
+ if first == "\e"
60
+ # Could be standalone ESC or start of escape sequence.
61
+ # Wait briefly for more bytes.
62
+ if has_more_input?(ESC_TIMEOUT_MS)
63
+ bytes = first + read_available
64
+ @parser.parse(bytes)
65
+ else
66
+ @parser.parse("\e")
67
+ end
68
+ elsif first.ord < 128
69
+ @parser.parse(first)
70
+ else
71
+ # Multi-byte UTF-8: read continuation bytes
72
+ bytes = read_utf8(first)
73
+ @parser.parse(bytes)
74
+ end
75
+ end
76
+
77
+ def read_byte
78
+ @input.read_nonblock(1)
79
+ rescue IO::WaitReadable, EOFError
80
+ nil
81
+ end
82
+
83
+ def read_available
84
+ buf = String.new
85
+ loop do
86
+ byte = @input.read_nonblock(1)
87
+ buf << byte
88
+ rescue IO::WaitReadable, EOFError
89
+ break
90
+ end
91
+ buf
92
+ end
93
+
94
+ def has_more_input?(timeout_ms)
95
+ ready = IO.select([@input], nil, nil, timeout_ms / 1000.0)
96
+ !ready.nil?
97
+ end
98
+
99
+ # Read remaining bytes of a UTF-8 multibyte character
100
+ def read_utf8(first_byte)
101
+ ord = first_byte.ord
102
+ remaining = if ord >= 0xF0 then 3
103
+ elsif ord >= 0xE0 then 2
104
+ elsif ord >= 0xC0 then 1
105
+ else 0
106
+ end
107
+
108
+ bytes = first_byte
109
+ remaining.times do
110
+ b = read_byte
111
+ break unless b
112
+
113
+ bytes << b
114
+ end
115
+ bytes
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ module Layout
5
+ # A sizing rule for one segment of a {Layout} split.
6
+ #
7
+ # Create instances with the factory methods; {Layout#split} resolves them.
8
+ # Fixed constraints ({.length}, {.percentage}, {.ratio}) are allocated
9
+ # first, then the remaining space is shared among {.fill}, {.min} and
10
+ # {.max} constraints.
11
+ #
12
+ # @example Header, body and status line
13
+ # layout = Layout::Layout.vertical([
14
+ # Layout::Constraint.length(3),
15
+ # Layout::Constraint.fill,
16
+ # Layout::Constraint.length(1)
17
+ # ])
18
+ # header, body, status = layout.split(area)
19
+ class Constraint
20
+ attr_reader :kind, :value
21
+
22
+ # @param kind [Symbol] +:length+, +:percentage+, +:min+, +:max+, +:fill+ or +:ratio+
23
+ # @param value [Integer, Array(Integer, Integer)] size parameter; a
24
+ # <tt>[num, den]</tt> pair for +:ratio+
25
+ def initialize(kind, value)
26
+ @kind = kind
27
+ @value = value
28
+ end
29
+
30
+ # Factory methods
31
+ class << self
32
+ # Fixed size, reduced if less space remains.
33
+ #
34
+ # @param n [Integer] size in cells
35
+ # @return [Constraint]
36
+ def length(n) = new(:length, n)
37
+ # Percentage of the available space, rounded; reduced if less space remains.
38
+ #
39
+ # @param n [Numeric] percentage (0-100)
40
+ # @return [Constraint]
41
+ def percentage(n) = new(:percentage, n)
42
+ # At least +n+ cells when space allows, plus a weight-1 share of the
43
+ # space left after fixed constraints.
44
+ #
45
+ # @param n [Integer] minimum size in cells
46
+ # @return [Constraint]
47
+ def min(n) = new(:min, n)
48
+ # A weight-1 share of the space left after fixed constraints, capped
49
+ # at +n+ cells.
50
+ #
51
+ # @param n [Integer] maximum size in cells
52
+ # @return [Constraint]
53
+ def max(n) = new(:max, n)
54
+ # A share of the space left after fixed constraints, proportional to
55
+ # +weight+ (+min+ and +max+ constraints count as weight 1).
56
+ #
57
+ # @param weight [Integer] relative weight (default: 1)
58
+ # @return [Constraint]
59
+ def fill(weight = 1) = new(:fill, weight)
60
+ # Fraction <tt>num / den</tt> of the available space, rounded; reduced if
61
+ # less space remains.
62
+ #
63
+ # @param num [Integer] numerator
64
+ # @param den [Integer] denominator
65
+ # @return [Constraint]
66
+ def ratio(num, den) = new(:ratio, [num, den].freeze)
67
+ end
68
+
69
+ # Value equality: same kind and value.
70
+ #
71
+ # @param other [Object] object to compare
72
+ # @return [Boolean] true if +other+ is a Constraint with equal {#kind} and {#value}
73
+ def ==(other)
74
+ other.is_a?(Constraint) && @kind == other.kind && @value == other.value
75
+ end
76
+
77
+ # @return [String] human-readable form, e.g. <tt>Constraint(length: 3)</tt>
78
+ def to_s = "Constraint(#{@kind}: #{@value})"
79
+ # @return [String] same as {#to_s}
80
+ def inspect = to_s
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ module Layout
5
+ # Flex modes control how spare space is distributed when constraints
6
+ # don't perfectly consume the available area.
7
+ module Flex
8
+ START = :start # Pack items to start, spare space at end
9
+ END_POS = :end # Pack items to end, spare space at start
10
+ CENTER = :center # Center items, spare space split around edges
11
+ SPACE_BETWEEN = :space_between # Distribute spare space between items
12
+ SPACE_AROUND = :space_around # Distribute spare space around items
13
+ STRETCH = :stretch # Stretch fill constraints to consume all space (default)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ # Constraint-based layout: {Layout::Layout} splits a {Rect} into rows or
5
+ # columns sized by {Constraint} objects and positioned by a {Flex} mode.
6
+ module Layout
7
+ # Splits a {Rect} along one axis into sub-rects sized by {Constraint}
8
+ # objects, with optional spacing between them.
9
+ #
10
+ # @example Sidebar and main area
11
+ # sidebar, main = Layout::Layout.horizontal(
12
+ # [Layout::Constraint.length(20), Layout::Constraint.fill]
13
+ # ).split(area)
14
+ class Layout
15
+ # Split along the x axis into side-by-side columns.
16
+ HORIZONTAL = :horizontal
17
+ # Split along the y axis into stacked rows.
18
+ VERTICAL = :vertical
19
+
20
+ attr_reader :direction, :constraints, :spacing, :flex
21
+
22
+ # @param direction [Symbol] {HORIZONTAL} or {VERTICAL}
23
+ # @param constraints [Array<Constraint>] one constraint per segment
24
+ # @param spacing [Integer] gap in cells between adjacent segments (default: 0)
25
+ # @param flex [Symbol] {Flex} mode for placing spare space (default: {Flex::STRETCH})
26
+ def initialize(direction:, constraints:, spacing: 0, flex: Flex::STRETCH)
27
+ @direction = direction
28
+ @constraints = constraints
29
+ @spacing = spacing
30
+ @flex = flex
31
+ end
32
+
33
+ class << self
34
+ # Build a {VERTICAL} layout (segments stacked top to bottom).
35
+ #
36
+ # @param constraints [Array<Constraint>] one constraint per segment
37
+ # @param spacing [Integer] gap in rows between adjacent segments (default: 0)
38
+ # @param flex [Symbol] {Flex} mode for placing spare space (default: {Flex::STRETCH})
39
+ # @return [Layout]
40
+ def vertical(constraints, spacing: 0, flex: Flex::STRETCH)
41
+ new(direction: VERTICAL, constraints: constraints, spacing: spacing, flex: flex)
42
+ end
43
+
44
+ # Build a {HORIZONTAL} layout (segments placed left to right).
45
+ #
46
+ # @param constraints [Array<Constraint>] one constraint per segment
47
+ # @param spacing [Integer] gap in columns between adjacent segments (default: 0)
48
+ # @param flex [Symbol] {Flex} mode for placing spare space (default: {Flex::STRETCH})
49
+ # @return [Layout]
50
+ def horizontal(constraints, spacing: 0, flex: Flex::STRETCH)
51
+ new(direction: HORIZONTAL, constraints: constraints, spacing: spacing, flex: flex)
52
+ end
53
+ end
54
+
55
+ # Split a Rect into sub-Rects according to constraints.
56
+ #
57
+ # Sizes are solved along {#direction} over the area's length minus the
58
+ # total spacing; the other dimension is taken from +area+. Space left
59
+ # unallocated is placed according to {#flex}.
60
+ #
61
+ # @param area [Rect] area to split
62
+ # @return [Array<Rect>] one Rect per constraint, in order (empty if there
63
+ # are no constraints)
64
+ def split(area)
65
+ return [] if @constraints.empty?
66
+
67
+ total = @direction == HORIZONTAL ? area.width : area.height
68
+ total_spacing = @spacing * [@constraints.length - 1, 0].max
69
+ available = [total - total_spacing, 0].max
70
+
71
+ sizes = solve(available)
72
+ allocated = sizes.sum + total_spacing
73
+ spare = [total - allocated, 0].max
74
+
75
+ # Apply flex positioning
76
+ build_rects(area, sizes, spare)
77
+ end
78
+
79
+ private
80
+
81
+ def build_rects(area, sizes, spare)
82
+ n = sizes.length
83
+ rects = []
84
+
85
+ # Calculate gaps based on flex mode
86
+ leading, between, trailing = compute_flex_gaps(spare, n)
87
+
88
+ offset = leading
89
+ sizes.each_with_index do |size, i|
90
+ rect = if @direction == HORIZONTAL
91
+ Rect.new(x: area.x + offset, y: area.y, width: size, height: area.height)
92
+ else
93
+ Rect.new(x: area.x, y: area.y + offset, width: area.width, height: size)
94
+ end
95
+ rects << rect
96
+ offset += size + @spacing + (i < n - 1 ? between : 0)
97
+ end
98
+
99
+ rects
100
+ end
101
+
102
+ # Returns [leading_gap, between_gap, trailing_gap]
103
+ def compute_flex_gaps(spare, n)
104
+ return [0, 0, 0] if spare <= 0 || n <= 0
105
+
106
+ case @flex
107
+ when Flex::START, Flex::STRETCH
108
+ [0, 0, spare]
109
+ when Flex::END_POS
110
+ [spare, 0, 0]
111
+ when Flex::CENTER
112
+ half = spare / 2
113
+ [half, 0, spare - half]
114
+ when Flex::SPACE_BETWEEN
115
+ if n <= 1
116
+ [0, 0, spare]
117
+ else
118
+ gap = spare / (n - 1)
119
+ remainder = spare - gap * (n - 1)
120
+ [0, gap, -remainder] # trailing absorbs rounding
121
+ end
122
+ when Flex::SPACE_AROUND
123
+ if n == 0
124
+ [spare, 0, 0]
125
+ else
126
+ # n items create n+1 gaps (before first, between each, after last)
127
+ # but space_around puts equal space on each side of each item
128
+ gap = spare / (n * 2)
129
+ remainder = spare - gap * n * 2
130
+ [gap + remainder / 2, gap * 2, gap + (remainder + 1) / 2]
131
+ end
132
+ else
133
+ [0, 0, spare]
134
+ end
135
+ end
136
+
137
+ # Two-pass constraint solver:
138
+ # Pass 1: Resolve fixed constraints (Length, Percentage, Ratio)
139
+ # Pass 2: Distribute remaining space to Fill, apply Min/Max clamping
140
+ def solve(total)
141
+ n = @constraints.length
142
+ sizes = Array.new(n, 0)
143
+ remaining = total
144
+ fill_indices = []
145
+
146
+ # Pass 1: Fixed allocations
147
+ @constraints.each_with_index do |c, i|
148
+ case c.kind
149
+ when :length
150
+ sizes[i] = [c.value, remaining].min
151
+ remaining -= sizes[i]
152
+ when :percentage
153
+ sizes[i] = [(total * c.value / 100.0).round, remaining].min
154
+ remaining -= sizes[i]
155
+ when :ratio
156
+ num, den = c.value
157
+ sizes[i] = [(total * num / den.to_f).round, remaining].min
158
+ remaining -= sizes[i]
159
+ when :min
160
+ sizes[i] = [c.value, remaining].min
161
+ remaining -= sizes[i]
162
+ fill_indices << i
163
+ when :max
164
+ fill_indices << i
165
+ when :fill
166
+ fill_indices << i
167
+ end
168
+ end
169
+
170
+ # Pass 2: Distribute remaining space among fill/min/max constraints
171
+ if fill_indices.any? && remaining > 0
172
+ total_weight = fill_indices.sum do |i|
173
+ c = @constraints[i]
174
+ c.kind == :fill ? c.value : 1
175
+ end
176
+
177
+ fill_indices.each do |i|
178
+ c = @constraints[i]
179
+ weight = c.kind == :fill ? c.value : 1
180
+ share = (remaining * weight / total_weight.to_f).round
181
+
182
+ case c.kind
183
+ when :fill
184
+ sizes[i] = share
185
+ when :min
186
+ sizes[i] = [sizes[i] + share, c.value].max
187
+ when :max
188
+ sizes[i] = [share, c.value].min
189
+ end
190
+ end
191
+
192
+ # Adjust rounding errors
193
+ allocated = sizes.sum
194
+ diff = total - allocated
195
+ if diff != 0 && fill_indices.any?
196
+ sizes[fill_indices.last] += diff
197
+ end
198
+ end
199
+
200
+ sizes.map! { |s| [s, 0].max }
201
+ sizes
202
+ end
203
+ end
204
+ end
205
+ end