ratatat 1.0.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 (43) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +201 -0
  3. data/examples/log_tailer.rb +460 -0
  4. data/lib/ratatat/ansi_backend.rb +175 -0
  5. data/lib/ratatat/app.rb +342 -0
  6. data/lib/ratatat/binding.rb +74 -0
  7. data/lib/ratatat/buffer.rb +238 -0
  8. data/lib/ratatat/cell.rb +166 -0
  9. data/lib/ratatat/color.rb +191 -0
  10. data/lib/ratatat/css_parser.rb +192 -0
  11. data/lib/ratatat/dom_query.rb +124 -0
  12. data/lib/ratatat/driver.rb +200 -0
  13. data/lib/ratatat/input.rb +208 -0
  14. data/lib/ratatat/message.rb +147 -0
  15. data/lib/ratatat/reactive.rb +79 -0
  16. data/lib/ratatat/styles.rb +293 -0
  17. data/lib/ratatat/terminal.rb +168 -0
  18. data/lib/ratatat/version.rb +3 -0
  19. data/lib/ratatat/widget.rb +337 -0
  20. data/lib/ratatat/widgets/button.rb +43 -0
  21. data/lib/ratatat/widgets/checkbox.rb +68 -0
  22. data/lib/ratatat/widgets/container.rb +50 -0
  23. data/lib/ratatat/widgets/data_table.rb +123 -0
  24. data/lib/ratatat/widgets/grid.rb +40 -0
  25. data/lib/ratatat/widgets/horizontal.rb +52 -0
  26. data/lib/ratatat/widgets/log.rb +97 -0
  27. data/lib/ratatat/widgets/modal.rb +161 -0
  28. data/lib/ratatat/widgets/progress_bar.rb +80 -0
  29. data/lib/ratatat/widgets/radio_set.rb +91 -0
  30. data/lib/ratatat/widgets/scrollable_container.rb +88 -0
  31. data/lib/ratatat/widgets/select.rb +100 -0
  32. data/lib/ratatat/widgets/sparkline.rb +61 -0
  33. data/lib/ratatat/widgets/spinner.rb +93 -0
  34. data/lib/ratatat/widgets/static.rb +23 -0
  35. data/lib/ratatat/widgets/tabbed_content.rb +114 -0
  36. data/lib/ratatat/widgets/text_area.rb +183 -0
  37. data/lib/ratatat/widgets/text_input.rb +143 -0
  38. data/lib/ratatat/widgets/toast.rb +55 -0
  39. data/lib/ratatat/widgets/tooltip.rb +66 -0
  40. data/lib/ratatat/widgets/tree.rb +172 -0
  41. data/lib/ratatat/widgets/vertical.rb +52 -0
  42. data/lib/ratatat.rb +51 -0
  43. metadata +142 -0
@@ -0,0 +1,337 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module Ratatat
7
+ class QueryError < StandardError; end
8
+
9
+ # Base class for all widgets in the UI tree.
10
+ class Widget
11
+ extend T::Sig
12
+ extend Reactive
13
+
14
+ CAN_FOCUS = false
15
+
16
+ sig { returns(T.nilable(String)) }
17
+ attr_reader :id
18
+
19
+ sig { returns(T::Set[String]) }
20
+ attr_reader :classes
21
+
22
+ sig { returns(T.nilable(Widget)) }
23
+ attr_reader :parent
24
+
25
+ sig { returns(T::Array[Widget]) }
26
+ attr_reader :children
27
+
28
+ sig { returns(Styles) }
29
+ attr_reader :styles
30
+
31
+ sig { params(id: T.nilable(String), classes: T::Array[String], styles: T.nilable(T::Hash[Symbol, T.untyped]), kwargs: T.untyped).void }
32
+ def initialize(id: nil, classes: [], styles: nil, **kwargs)
33
+ @id = id
34
+ @classes = T.let(classes.to_set, T::Set[String])
35
+ @parent = T.let(nil, T.nilable(Widget))
36
+ @children = T.let([], T::Array[Widget])
37
+ @has_focus = T.let(false, T::Boolean)
38
+ @styles = T.let(styles ? Styles.new(**styles) : Styles.new, Styles)
39
+
40
+ # Initialize reactive properties from kwargs
41
+ kwargs.each do |key, value|
42
+ setter = :"#{key}="
43
+ send(setter, value) if respond_to?(setter)
44
+ end
45
+ end
46
+
47
+ # Class management
48
+ sig { params(name: String).void }
49
+ def add_class(name)
50
+ @classes.add(name)
51
+ end
52
+
53
+ sig { params(name: String).void }
54
+ def remove_class(name)
55
+ @classes.delete(name)
56
+ end
57
+
58
+ sig { params(name: String).void }
59
+ def toggle_class(name)
60
+ if @classes.include?(name)
61
+ @classes.delete(name)
62
+ else
63
+ @classes.add(name)
64
+ end
65
+ end
66
+
67
+ sig { params(condition: T::Boolean, name: String).void }
68
+ def set_class(condition, name)
69
+ if condition
70
+ @classes.add(name)
71
+ else
72
+ @classes.delete(name)
73
+ end
74
+ end
75
+
76
+ sig { params(name: String).returns(T::Boolean) }
77
+ def has_class?(name)
78
+ @classes.include?(name)
79
+ end
80
+
81
+ # Reactive state for pseudo-classes
82
+ reactive :disabled, default: false, repaint: true
83
+ reactive :hover, default: false, repaint: true
84
+
85
+ # Trigger repaint (override in subclass or App)
86
+ sig { void }
87
+ def refresh
88
+ # Default: no-op, App overrides to trigger render
89
+ end
90
+
91
+ # Override to define children declaratively
92
+ sig { returns(T::Array[Widget]) }
93
+ def compose
94
+ []
95
+ end
96
+
97
+ # Rebuild children from compose
98
+ sig { void }
99
+ def recompose
100
+ # Remove existing children
101
+ @children.each { |c| c.instance_variable_set(:@parent, nil) }
102
+ @children.clear
103
+
104
+ # Reset compose flag and re-compose
105
+ @_composed = nil
106
+ do_compose
107
+ end
108
+
109
+ # Add children to this widget
110
+ sig { params(widgets: Widget).returns(T.self_type) }
111
+ def mount(*widgets)
112
+ widgets.each do |widget|
113
+ widget.instance_variable_set(:@parent, self)
114
+ @children << widget
115
+ trigger_mount(widget)
116
+ end
117
+ self
118
+ end
119
+
120
+ # Remove this widget from its parent
121
+ sig { void }
122
+ def remove
123
+ return unless @parent
124
+
125
+ @parent.children.delete(self)
126
+ @parent = nil
127
+ trigger_unmount(self)
128
+ end
129
+
130
+ # Get ancestor chain (parent, grandparent, ...)
131
+ sig { returns(T::Array[Widget]) }
132
+ def ancestors
133
+ result = []
134
+ current = @parent
135
+ while current
136
+ result << current
137
+ current = current.parent
138
+ end
139
+ result
140
+ end
141
+
142
+ # Focus management
143
+ sig { returns(T::Boolean) }
144
+ def can_focus?
145
+ # Search class hierarchy for CAN_FOCUS
146
+ klass = T.let(self.class, T.nilable(T::Class[T.anything]))
147
+ while klass
148
+ return klass.const_get(:CAN_FOCUS, false) if klass.const_defined?(:CAN_FOCUS, false)
149
+ klass = klass.superclass
150
+ end
151
+ false
152
+ end
153
+
154
+ sig { returns(T::Boolean) }
155
+ def has_focus?
156
+ @has_focus
157
+ end
158
+
159
+ # Request focus for this widget
160
+ sig { void }
161
+ def focus
162
+ return unless can_focus?
163
+ app&.set_focus(self)
164
+ end
165
+
166
+ # Remove focus from this widget
167
+ sig { void }
168
+ def blur
169
+ return unless @has_focus
170
+ app&.set_focus(nil)
171
+ end
172
+
173
+ # Find the root App
174
+ sig { returns(T.nilable(App)) }
175
+ def app
176
+ current = T.let(self, T.nilable(Widget))
177
+ while current
178
+ return T.cast(current, App) if current.is_a?(App)
179
+ current = current.parent
180
+ end
181
+ nil
182
+ end
183
+
184
+ # Dispatch a message to this widget and bubble up
185
+ sig { params(message: Message).void }
186
+ def dispatch(message)
187
+ # Check key bindings first
188
+ if message.is_a?(Key) && handle_binding(message)
189
+ message.stop
190
+ return
191
+ end
192
+
193
+ handler = handler_for(message)
194
+ send(handler, message) if handler && respond_to?(handler)
195
+
196
+ return if message.stopped? || !message.bubble
197
+ @parent&.dispatch(message)
198
+ end
199
+
200
+ # Query descendants by selector, returns chainable DOMQuery
201
+ sig { params(selector: T.any(String, T::Class[Widget])).returns(DOMQuery) }
202
+ def query(selector)
203
+ results = T.let([], T::Array[Widget])
204
+ walk_descendants { |w| results << w if matches?(w, selector) }
205
+ DOMQuery.new(results)
206
+ end
207
+
208
+ sig { params(selector: T.any(String, T::Class[Widget])).returns(T.nilable(Widget)) }
209
+ def query_one(selector)
210
+ query(selector).first
211
+ end
212
+
213
+ sig { params(selector: T.any(String, T::Class[Widget])).returns(Widget) }
214
+ def query_one!(selector)
215
+ query_one(selector) || raise(QueryError, "No widget matching: #{selector}")
216
+ end
217
+
218
+ private
219
+
220
+ sig { params(message: Message).returns(T.nilable(Symbol)) }
221
+ def handler_for(message)
222
+ # Key -> :on_key, Button::Pressed -> :on_button_pressed
223
+ parts = message.class.name&.split("::")
224
+ return nil unless parts
225
+
226
+ # Remove "Ratatat" prefix if present
227
+ parts.shift if parts.first == "Ratatat"
228
+
229
+ # If nested (e.g., Button::Pressed), use widget_message format
230
+ name = if parts.length >= 2
231
+ "#{parts[-2]}_#{parts[-1]}".downcase
232
+ else
233
+ parts.last&.downcase
234
+ end
235
+
236
+ return nil unless name
237
+ :"on_#{name}"
238
+ end
239
+
240
+ sig { params(block: T.proc.params(widget: Widget).void).void }
241
+ def walk_descendants(&block)
242
+ @children.each do |child|
243
+ block.call(child)
244
+ child.send(:walk_descendants, &block)
245
+ end
246
+ end
247
+
248
+ sig { params(widget: Widget, selector: T.any(String, T::Class[Widget])).returns(T::Boolean) }
249
+ def matches?(widget, selector)
250
+ case selector
251
+ when String
252
+ if selector.start_with?("#")
253
+ widget.id == selector[1..]
254
+ elsif selector.start_with?(".")
255
+ widget.classes.include?(selector[1..])
256
+ else
257
+ # Type selector - match class name
258
+ widget.class.name&.split("::")&.last == selector
259
+ end
260
+ when Class
261
+ widget.is_a?(selector)
262
+ else
263
+ false
264
+ end
265
+ end
266
+
267
+ # Check if this widget has BINDINGS and handle the key
268
+ sig { params(message: Key).returns(T::Boolean) }
269
+ def handle_binding(message)
270
+ bindings.each do |binding|
271
+ if binding.matches?(message.key, message.modifiers)
272
+ action_method = :"action_#{binding.action}"
273
+ if respond_to?(action_method)
274
+ send(action_method)
275
+ return true
276
+ end
277
+ end
278
+ end
279
+ false
280
+ end
281
+
282
+ # Get bindings for this widget class
283
+ sig { returns(T::Array[Binding]) }
284
+ def bindings
285
+ return [] unless self.class.const_defined?(:BINDINGS, false)
286
+
287
+ raw = self.class.const_get(:BINDINGS, false)
288
+ raw.map do |b|
289
+ case b
290
+ when Binding then b
291
+ when Array then Binding.new(b[0], b[1], b[2] || "")
292
+ else b
293
+ end
294
+ end
295
+ end
296
+
297
+ sig { params(widget: Widget).void }
298
+ def trigger_mount(widget)
299
+ # Only trigger if we're connected to an App
300
+ return unless widget.app
301
+ # Skip if already mounted
302
+ return if widget.instance_variable_get(:@_mounted)
303
+
304
+ widget.instance_variable_set(:@_mounted, true)
305
+
306
+ # Compose children first (only if not already composed)
307
+ widget.send(:do_compose) unless widget.instance_variable_get(:@_composed)
308
+
309
+ widget.on_mount if widget.respond_to?(:on_mount)
310
+
311
+ # Also trigger mount for any pre-existing children (mounted before joining app)
312
+ widget.children.each { |child| trigger_mount(child) }
313
+ end
314
+
315
+ # Internal: run compose and mount results
316
+ sig { void }
317
+ def do_compose
318
+ return if @_composed
319
+
320
+ @_composed = T.let(true, T.nilable(T::Boolean))
321
+ children = compose
322
+ return if children.empty?
323
+
324
+ children.each do |child|
325
+ child.instance_variable_set(:@parent, self)
326
+ @children << child
327
+ trigger_mount(child) if app
328
+ end
329
+ end
330
+
331
+ sig { params(widget: Widget).void }
332
+ def trigger_unmount(widget)
333
+ widget.children.each { |child| trigger_unmount(child) }
334
+ widget.on_unmount if widget.respond_to?(:on_unmount)
335
+ end
336
+ end
337
+ end
@@ -0,0 +1,43 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Ratatat
5
+ # Clickable button widget
6
+ class Button < Widget
7
+ extend T::Sig
8
+
9
+ CAN_FOCUS = true
10
+
11
+ # Message emitted when button is activated
12
+ class Pressed < Message; end
13
+
14
+ reactive :label, default: "", repaint: true
15
+
16
+ sig { params(label: String, id: T.nilable(String), classes: T::Array[String]).void }
17
+ def initialize(label = "", id: nil, classes: [])
18
+ super(id: id, classes: classes)
19
+ @label = label
20
+ end
21
+
22
+ sig { params(message: Key).void }
23
+ def on_key(message)
24
+ if message.key == "enter" || message.key == " "
25
+ press
26
+ message.stop
27
+ end
28
+ end
29
+
30
+ sig { void }
31
+ def press
32
+ parent&.dispatch(Pressed.new(sender: self))
33
+ end
34
+
35
+ sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
36
+ def render(buffer, x:, y:, width:, height:)
37
+ display = "< #{label} >"
38
+ # Center the button text
39
+ padding = [(width - display.length) / 2, 0].max
40
+ buffer.put_string(x + padding, y, display[0, width])
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,68 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Ratatat
5
+ # Checkbox toggle widget
6
+ class Checkbox < Widget
7
+ extend T::Sig
8
+
9
+ CAN_FOCUS = true
10
+
11
+ # Emitted when checked state changes
12
+ class Changed < Message
13
+ extend T::Sig
14
+
15
+ sig { returns(T::Boolean) }
16
+ attr_reader :checked
17
+
18
+ sig { params(sender: Widget, checked: T::Boolean).void }
19
+ def initialize(sender:, checked:)
20
+ super(sender: sender)
21
+ @checked = checked
22
+ end
23
+ end
24
+
25
+ reactive :checked, default: false, repaint: true
26
+ reactive :label, default: "", repaint: true
27
+
28
+ sig { params(label: String, checked: T::Boolean, id: T.nilable(String), classes: T::Array[String]).void }
29
+ def initialize(label = "", checked: false, id: nil, classes: [])
30
+ super(id: id, classes: classes)
31
+ @label = label
32
+ @checked = checked
33
+ end
34
+
35
+ sig { params(message: Key).void }
36
+ def on_key(message)
37
+ if message.key == " " || message.key == "enter"
38
+ toggle
39
+ message.stop
40
+ end
41
+ end
42
+
43
+ sig { void }
44
+ def toggle
45
+ self.checked = !checked
46
+ parent&.dispatch(Changed.new(sender: self, checked: checked))
47
+ end
48
+
49
+ sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
50
+ def render(buffer, x:, y:, width:, height:)
51
+ indicator = checked ? "[X]" : "[ ]"
52
+ text = "#{indicator} #{label}"
53
+ buffer.put_string(x, y, text[0, width])
54
+ end
55
+ end
56
+
57
+ # Switch toggle widget (alternative visual style)
58
+ class Switch < Checkbox
59
+ extend T::Sig
60
+
61
+ sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
62
+ def render(buffer, x:, y:, width:, height:)
63
+ indicator = checked ? "[ON ]" : "[OFF]"
64
+ text = "#{indicator} #{label}"
65
+ buffer.put_string(x, y, text[0, width])
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,50 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Ratatat
5
+ # Generic container widget
6
+ class Container < Widget
7
+ extend T::Sig
8
+
9
+ sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
10
+ def render(buffer, x:, y:, width:, height:)
11
+ # Default: render first child with full size
12
+ return if children.empty?
13
+
14
+ child = children.first
15
+ child.render(buffer, x: x, y: y, width: width, height: height) if child.respond_to?(:render)
16
+ end
17
+ end
18
+
19
+ # Horizontal layout - children side by side
20
+ class Horizontal < Widget
21
+ extend T::Sig
22
+
23
+ sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
24
+ def render(buffer, x:, y:, width:, height:)
25
+ return if children.empty?
26
+
27
+ child_width = width / children.length
28
+ children.each_with_index do |child, i|
29
+ child_x = x + (i * child_width)
30
+ child.render(buffer, x: child_x, y: y, width: child_width, height: height) if child.respond_to?(:render)
31
+ end
32
+ end
33
+ end
34
+
35
+ # Vertical layout - children stacked
36
+ class Vertical < Widget
37
+ extend T::Sig
38
+
39
+ sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
40
+ def render(buffer, x:, y:, width:, height:)
41
+ return if children.empty?
42
+
43
+ child_height = height / children.length
44
+ children.each_with_index do |child, i|
45
+ child_y = y + (i * child_height)
46
+ child.render(buffer, x: x, y: child_y, width: width, height: child_height) if child.respond_to?(:render)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,123 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Ratatat
5
+ # Tabular data display with navigation
6
+ class DataTable < Widget
7
+ extend T::Sig
8
+
9
+ CAN_FOCUS = true
10
+
11
+ # Emitted when a row is selected (Enter pressed)
12
+ class RowSelected < Message
13
+ extend T::Sig
14
+
15
+ sig { returns(Integer) }
16
+ attr_reader :index
17
+
18
+ sig { returns(T::Array[String]) }
19
+ attr_reader :row
20
+
21
+ sig { params(sender: Widget, index: Integer, row: T::Array[String]).void }
22
+ def initialize(sender:, index:, row:)
23
+ super(sender: sender)
24
+ @index = index
25
+ @row = row
26
+ end
27
+ end
28
+
29
+ sig { returns(T::Array[String]) }
30
+ attr_reader :columns
31
+
32
+ sig { returns(T::Array[T::Array[String]]) }
33
+ attr_reader :rows
34
+
35
+ reactive :cursor_row, default: 0, repaint: true
36
+
37
+ sig do
38
+ params(
39
+ columns: T::Array[String],
40
+ rows: T::Array[T::Array[String]],
41
+ id: T.nilable(String),
42
+ classes: T::Array[String]
43
+ ).void
44
+ end
45
+ def initialize(columns: [], rows: [], id: nil, classes: [])
46
+ super(id: id, classes: classes)
47
+ @columns = columns
48
+ @rows = T.let(rows.dup, T::Array[T::Array[String]])
49
+ @cursor_row = 0
50
+ end
51
+
52
+ sig { params(row: T::Array[String]).void }
53
+ def add_row(row)
54
+ @rows << row
55
+ refresh
56
+ end
57
+
58
+ sig { void }
59
+ def clear_rows
60
+ @rows.clear
61
+ @cursor_row = 0
62
+ refresh
63
+ end
64
+
65
+ sig { params(message: Key).void }
66
+ def on_key(message)
67
+ case message.key
68
+ when "down", "j"
69
+ move_cursor(1)
70
+ message.stop
71
+ when "up", "k"
72
+ move_cursor(-1)
73
+ message.stop
74
+ when "enter"
75
+ select_row
76
+ message.stop
77
+ end
78
+ end
79
+
80
+ sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
81
+ def render(buffer, x:, y:, width:, height:)
82
+ return if @columns.empty?
83
+
84
+ col_width = width / @columns.length
85
+
86
+ # Render header
87
+ @columns.each_with_index do |col, i|
88
+ buffer.put_string(x + (i * col_width), y, col[0, col_width])
89
+ end
90
+
91
+ # Render rows
92
+ @rows.each_with_index do |row, row_idx|
93
+ row_y = y + 1 + row_idx
94
+ break if row_y >= y + height
95
+
96
+ row.each_with_index do |cell, col_idx|
97
+ break if col_idx >= @columns.length
98
+
99
+ prefix = row_idx == @cursor_row ? "> " : " "
100
+ text = col_idx == 0 ? "#{prefix}#{cell}" : cell
101
+ buffer.put_string(x + (col_idx * col_width), row_y, text[0, col_width])
102
+ end
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ sig { params(delta: Integer).void }
109
+ def move_cursor(delta)
110
+ return if @rows.empty?
111
+
112
+ @cursor_row = (@cursor_row + delta).clamp(0, @rows.length - 1)
113
+ end
114
+
115
+ sig { void }
116
+ def select_row
117
+ return if @rows.empty? || @cursor_row >= @rows.length
118
+
119
+ row = @rows[@cursor_row]
120
+ parent&.dispatch(RowSelected.new(sender: self, index: @cursor_row, row: row || []))
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,40 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Ratatat
5
+ # Grid layout widget
6
+ class Grid < Widget
7
+ extend T::Sig
8
+
9
+ sig { returns(Integer) }
10
+ attr_reader :columns, :gap
11
+
12
+ sig { params(columns: Integer, gap: Integer, id: T.nilable(String), classes: T::Array[String]).void }
13
+ def initialize(columns: 2, gap: 0, id: nil, classes: [])
14
+ super(id: id, classes: classes)
15
+ @columns = columns
16
+ @gap = gap
17
+ end
18
+
19
+ sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
20
+ def render(buffer, x:, y:, width:, height:)
21
+ return if children.empty?
22
+
23
+ col_width = (width - (@gap * (@columns - 1))) / @columns
24
+ rows = (children.length.to_f / @columns).ceil
25
+ row_height = rows > 0 ? (height - (@gap * (rows - 1))) / rows : 0
26
+
27
+ children.each_with_index do |child, i|
28
+ col = i % @columns
29
+ row = i / @columns
30
+
31
+ child_x = x + (col * (col_width + @gap))
32
+ child_y = y + (row * (row_height + @gap))
33
+
34
+ break if child_y >= y + height
35
+
36
+ child.render(buffer, x: child_x, y: child_y, width: col_width, height: row_height) if child.respond_to?(:render)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,52 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Ratatat
5
+ # Horizontal flexbox-like layout widget
6
+ class Horizontal < Widget
7
+ extend T::Sig
8
+
9
+ sig { returns(Integer) }
10
+ attr_reader :gap
11
+
12
+ sig { returns(T.nilable(T::Array[Float])) }
13
+ attr_reader :ratios
14
+
15
+ sig { params(gap: Integer, ratios: T.nilable(T::Array[Float]), id: T.nilable(String), classes: T::Array[String]).void }
16
+ def initialize(gap: 0, ratios: nil, id: nil, classes: [])
17
+ super(id: id, classes: classes)
18
+ @gap = gap
19
+ @ratios = ratios
20
+ end
21
+
22
+ sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
23
+ def render(buffer, x:, y:, width:, height:)
24
+ return if children.empty?
25
+
26
+ total_gap = @gap * (children.length - 1)
27
+ available_width = width - total_gap
28
+
29
+ widths = calculate_widths(available_width)
30
+ current_x = x
31
+
32
+ children.each_with_index do |child, i|
33
+ child_width = widths[i] || 0
34
+ child.render(buffer, x: current_x, y: y, width: child_width, height: height) if child.respond_to?(:render)
35
+ current_x += child_width + @gap
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ sig { params(available_width: Integer).returns(T::Array[Integer]) }
42
+ def calculate_widths(available_width)
43
+ if @ratios && @ratios.length == children.length
44
+ @ratios.map { |r| (available_width * r).to_i }
45
+ else
46
+ # Equal distribution
47
+ child_width = available_width / children.length
48
+ Array.new(children.length, child_width)
49
+ end
50
+ end
51
+ end
52
+ end