kumiki 0.1.1

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 (62) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +256 -0
  4. data/lib/kumiki/animation/animated_state.rb +83 -0
  5. data/lib/kumiki/animation/easing.rb +62 -0
  6. data/lib/kumiki/animation/value_tween.rb +69 -0
  7. data/lib/kumiki/app.rb +381 -0
  8. data/lib/kumiki/box.rb +40 -0
  9. data/lib/kumiki/chart/area_chart.rb +308 -0
  10. data/lib/kumiki/chart/bar_chart.rb +291 -0
  11. data/lib/kumiki/chart/base_chart.rb +213 -0
  12. data/lib/kumiki/chart/chart_helpers.rb +74 -0
  13. data/lib/kumiki/chart/gauge_chart.rb +174 -0
  14. data/lib/kumiki/chart/heatmap_chart.rb +223 -0
  15. data/lib/kumiki/chart/line_chart.rb +292 -0
  16. data/lib/kumiki/chart/pie_chart.rb +222 -0
  17. data/lib/kumiki/chart/scales.rb +79 -0
  18. data/lib/kumiki/chart/scatter_chart.rb +306 -0
  19. data/lib/kumiki/chart/stacked_bar_chart.rb +279 -0
  20. data/lib/kumiki/column.rb +351 -0
  21. data/lib/kumiki/core.rb +2511 -0
  22. data/lib/kumiki/dsl.rb +408 -0
  23. data/lib/kumiki/frame_ranma.rb +570 -0
  24. data/lib/kumiki/markdown/ast.rb +127 -0
  25. data/lib/kumiki/markdown/mermaid/layout.rb +389 -0
  26. data/lib/kumiki/markdown/mermaid/models.rb +235 -0
  27. data/lib/kumiki/markdown/mermaid/parser.rb +522 -0
  28. data/lib/kumiki/markdown/mermaid/renderer.rb +339 -0
  29. data/lib/kumiki/markdown/parser.rb +808 -0
  30. data/lib/kumiki/markdown/renderer.rb +642 -0
  31. data/lib/kumiki/markdown/theme.rb +168 -0
  32. data/lib/kumiki/render_node.rb +262 -0
  33. data/lib/kumiki/row.rb +288 -0
  34. data/lib/kumiki/spacer.rb +20 -0
  35. data/lib/kumiki/style.rb +799 -0
  36. data/lib/kumiki/theme.rb +567 -0
  37. data/lib/kumiki/themes/material.rb +40 -0
  38. data/lib/kumiki/themes/tokyo_night.rb +11 -0
  39. data/lib/kumiki/version.rb +5 -0
  40. data/lib/kumiki/widgets/button.rb +105 -0
  41. data/lib/kumiki/widgets/calendar.rb +1028 -0
  42. data/lib/kumiki/widgets/checkbox.rb +119 -0
  43. data/lib/kumiki/widgets/container.rb +111 -0
  44. data/lib/kumiki/widgets/data_table.rb +670 -0
  45. data/lib/kumiki/widgets/divider.rb +31 -0
  46. data/lib/kumiki/widgets/image.rb +105 -0
  47. data/lib/kumiki/widgets/input.rb +485 -0
  48. data/lib/kumiki/widgets/markdown.rb +58 -0
  49. data/lib/kumiki/widgets/modal.rb +165 -0
  50. data/lib/kumiki/widgets/multiline_input.rb +970 -0
  51. data/lib/kumiki/widgets/multiline_text.rb +180 -0
  52. data/lib/kumiki/widgets/net_image.rb +100 -0
  53. data/lib/kumiki/widgets/progress_bar.rb +72 -0
  54. data/lib/kumiki/widgets/radio_buttons.rb +93 -0
  55. data/lib/kumiki/widgets/slider.rb +135 -0
  56. data/lib/kumiki/widgets/switch.rb +84 -0
  57. data/lib/kumiki/widgets/tabs.rb +175 -0
  58. data/lib/kumiki/widgets/text.rb +120 -0
  59. data/lib/kumiki/widgets/tree.rb +434 -0
  60. data/lib/kumiki/widgets/webview.rb +87 -0
  61. data/lib/kumiki.rb +130 -0
  62. metadata +113 -0
@@ -0,0 +1,168 @@
1
+ module Kumiki
2
+ # Markdown theme - color and size tokens for markdown rendering
3
+ # Integrates with the global Kumiki.theme
4
+
5
+ class MarkdownTheme
6
+ def initialize
7
+ # Text colors - derive from global theme
8
+ @text_color = Kumiki.theme.text_primary
9
+ @heading_color = Kumiki.theme.accent
10
+ @link_color = Kumiki.theme.accent
11
+ @code_color = Kumiki.theme.warning
12
+ @emphasis_color = 0xFF9AA5CE # Slightly muted blue for italic
13
+ @strikethrough_color = Kumiki.theme.text_secondary
14
+
15
+ # Background colors
16
+ @code_bg_color = Kumiki.theme.bg_secondary
17
+ @code_inline_bg = Kumiki.theme.bg_secondary
18
+ @blockquote_bg = Kumiki.theme.accent
19
+
20
+ # Heading sizes
21
+ @h1_size = 28.0
22
+ @h2_size = 24.0
23
+ @h3_size = 20.0
24
+ @h4_size = 18.0
25
+ @h5_size = 16.0
26
+ @h6_size = 14.0
27
+ @base_font_size = 14.0
28
+
29
+ # Table colors
30
+ @table_header_bg = Kumiki.theme.bg_secondary
31
+ @table_border_color = Kumiki.theme.text_secondary
32
+ @checkbox_checked_color = Kumiki.theme.success
33
+ @checkbox_unchecked_color = Kumiki.theme.text_secondary
34
+
35
+ # Mermaid colors
36
+ @mermaid_node_fill = 0xFF4A90D9
37
+ @mermaid_node_stroke = 0xFF2C5F8A
38
+ @mermaid_node_text = 0xFFFFFFFF
39
+ @mermaid_edge_color = Kumiki.theme.text_secondary
40
+ @mermaid_subgraph_bg = 0x20FFFFFF
41
+ @mermaid_subgraph_border = Kumiki.theme.text_secondary
42
+ @mermaid_font_size = 12.0
43
+
44
+ # Spacing
45
+ @paragraph_spacing = 8.0
46
+ @block_spacing = 12.0
47
+ @list_indent = 24.0
48
+ @blockquote_indent = 20.0
49
+ end
50
+
51
+ def text_color
52
+ @text_color
53
+ end
54
+
55
+ def heading_color
56
+ @heading_color
57
+ end
58
+
59
+ def link_color
60
+ @link_color
61
+ end
62
+
63
+ def code_color
64
+ @code_color
65
+ end
66
+
67
+ def emphasis_color
68
+ @emphasis_color
69
+ end
70
+
71
+ def strikethrough_color
72
+ @strikethrough_color
73
+ end
74
+
75
+ def code_bg_color
76
+ @code_bg_color
77
+ end
78
+
79
+ def code_inline_bg
80
+ @code_inline_bg
81
+ end
82
+
83
+ def blockquote_bg
84
+ @blockquote_bg
85
+ end
86
+
87
+ def base_font_size
88
+ @base_font_size
89
+ end
90
+
91
+ def paragraph_spacing
92
+ @paragraph_spacing
93
+ end
94
+
95
+ def block_spacing
96
+ @block_spacing
97
+ end
98
+
99
+ def list_indent
100
+ @list_indent
101
+ end
102
+
103
+ def blockquote_indent
104
+ @blockquote_indent
105
+ end
106
+
107
+ def table_header_bg
108
+ @table_header_bg
109
+ end
110
+
111
+ def table_border_color
112
+ @table_border_color
113
+ end
114
+
115
+ def checkbox_checked_color
116
+ @checkbox_checked_color
117
+ end
118
+
119
+ def checkbox_unchecked_color
120
+ @checkbox_unchecked_color
121
+ end
122
+
123
+ def mermaid_node_fill
124
+ @mermaid_node_fill
125
+ end
126
+
127
+ def mermaid_node_stroke
128
+ @mermaid_node_stroke
129
+ end
130
+
131
+ def mermaid_node_text
132
+ @mermaid_node_text
133
+ end
134
+
135
+ def mermaid_edge_color
136
+ @mermaid_edge_color
137
+ end
138
+
139
+ def mermaid_subgraph_bg
140
+ @mermaid_subgraph_bg
141
+ end
142
+
143
+ def mermaid_subgraph_border
144
+ @mermaid_subgraph_border
145
+ end
146
+
147
+ def mermaid_font_size
148
+ @mermaid_font_size
149
+ end
150
+
151
+ def heading_size(level)
152
+ if level == 1
153
+ @h1_size
154
+ elsif level == 2
155
+ @h2_size
156
+ elsif level == 3
157
+ @h3_size
158
+ elsif level == 4
159
+ @h4_size
160
+ elsif level == 5
161
+ @h5_size
162
+ else
163
+ @h6_size
164
+ end
165
+ end
166
+ end
167
+
168
+ end
@@ -0,0 +1,262 @@
1
+ module Kumiki
2
+ # rbs_inline: enabled
3
+
4
+ # RenderNode system - layout/paint dirty tracking and z-order caching
5
+
6
+ # Base render node with fine-grained dirty tracking
7
+ class RenderNodeBase
8
+ #: (untyped widget) -> void
9
+ def initialize(widget)
10
+ @widget = widget
11
+ @layout_dirty = true
12
+ @paint_dirty = true
13
+ @subtree_dirty = false
14
+ @measured_size = nil
15
+ end
16
+
17
+ #: () -> untyped
18
+ def get_widget
19
+ @widget
20
+ end
21
+
22
+ # ===== Dirty Tracking =====
23
+
24
+ #: () -> bool
25
+ def is_layout_dirty
26
+ @layout_dirty
27
+ end
28
+
29
+ #: () -> bool
30
+ def is_paint_dirty
31
+ @paint_dirty
32
+ end
33
+
34
+ #: () -> void
35
+ def mark_layout_dirty
36
+ @layout_dirty = true
37
+ @paint_dirty = true
38
+ @measured_size = nil
39
+ end
40
+
41
+ #: () -> void
42
+ def mark_paint_dirty
43
+ @paint_dirty = true
44
+ end
45
+
46
+ #: () -> bool
47
+ def is_subtree_dirty
48
+ @subtree_dirty
49
+ end
50
+
51
+ #: () -> void
52
+ def mark_subtree_dirty
53
+ @subtree_dirty = true
54
+ end
55
+
56
+ #: () -> void
57
+ def clear_dirty
58
+ @layout_dirty = false
59
+ @paint_dirty = false
60
+ @subtree_dirty = false
61
+ end
62
+
63
+ # ===== Layout =====
64
+
65
+ #: (untyped painter) -> Size
66
+ def cached_measure(painter)
67
+ if @measured_size == nil || @layout_dirty
68
+ @measured_size = @widget.measure(painter)
69
+ end
70
+ @measured_size
71
+ end
72
+
73
+ # ===== Hit Testing =====
74
+
75
+ #: (Point point) -> bool
76
+ def hit_test(point)
77
+ @widget.contain(point)
78
+ end
79
+ end
80
+
81
+ # Layout render node with z-order caching and child management
82
+ class LayoutRenderNode < RenderNodeBase
83
+ #: (untyped widget) -> void
84
+ def initialize(widget)
85
+ super(widget)
86
+ @children = []
87
+ @sorted_children = nil
88
+ @z_order_dirty = true
89
+ end
90
+
91
+ # ===== Child Management =====
92
+
93
+ #: (untyped child) -> void
94
+ def add_child(child)
95
+ @children << child
96
+ @z_order_dirty = true
97
+ mark_layout_dirty
98
+ end
99
+
100
+ #: (untyped child) -> void
101
+ def remove_child(child)
102
+ i = 0
103
+ while i < @children.length
104
+ if @children[i] == child
105
+ @children.delete_at(i)
106
+ @z_order_dirty = true
107
+ mark_layout_dirty
108
+ return
109
+ end
110
+ i = i + 1
111
+ end
112
+ end
113
+
114
+ #: () -> void
115
+ def clear_children
116
+ @children = []
117
+ @sorted_children = nil
118
+ @z_order_dirty = true
119
+ mark_layout_dirty
120
+ end
121
+
122
+ #: () -> Integer
123
+ def child_count
124
+ @children.length
125
+ end
126
+
127
+ #: () -> Array
128
+ def get_children
129
+ @children
130
+ end
131
+
132
+ # ===== Z-Order Management =====
133
+
134
+ #: () -> void
135
+ def invalidate_z_order
136
+ @z_order_dirty = true
137
+ @sorted_children = nil
138
+ end
139
+
140
+ #: () -> Array
141
+ def get_sorted_children
142
+ if @z_order_dirty || @sorted_children == nil
143
+ # Copy children list
144
+ @sorted_children = []
145
+ i = 0
146
+ while i < @children.length
147
+ @sorted_children << @children[i]
148
+ i = i + 1
149
+ end
150
+ # Sort by z_index (bubble sort - children count is typically small)
151
+ changed = true
152
+ while changed
153
+ changed = false
154
+ j = 0
155
+ while j < @sorted_children.length - 1
156
+ if @sorted_children[j].get_z_index > @sorted_children[j + 1].get_z_index
157
+ tmp = @sorted_children[j]
158
+ @sorted_children[j] = @sorted_children[j + 1]
159
+ @sorted_children[j + 1] = tmp
160
+ changed = true
161
+ end
162
+ j = j + 1
163
+ end
164
+ end
165
+ @z_order_dirty = false
166
+ end
167
+ @sorted_children
168
+ end
169
+
170
+ # Paint order: lower z-index first (background to foreground)
171
+ #: () -> Array
172
+ def iter_paint_order
173
+ get_sorted_children
174
+ end
175
+
176
+ # Hit test order: higher z-index first (foreground to background)
177
+ #: () -> Array
178
+ def iter_hit_test_order
179
+ sorted = get_sorted_children
180
+ result = []
181
+ i = sorted.length - 1
182
+ while i >= 0
183
+ result << sorted[i]
184
+ i = i - 1
185
+ end
186
+ result
187
+ end
188
+
189
+ # ===== Dirty Propagation =====
190
+
191
+ #: () -> bool
192
+ def is_any_child_dirty
193
+ i = 0
194
+ while i < @children.length
195
+ if @children[i].is_dirty
196
+ return true
197
+ end
198
+ i = i + 1
199
+ end
200
+ false
201
+ end
202
+ end
203
+
204
+ # Scrollable layout render node with viewport culling
205
+ class ScrollableLayoutRenderNode < LayoutRenderNode
206
+ #: (untyped widget) -> void
207
+ def initialize(widget)
208
+ super(widget)
209
+ @scroll_x = 0.0
210
+ @scroll_y = 0.0
211
+ @viewport_width = 0.0
212
+ @viewport_height = 0.0
213
+ @viewport_set = false
214
+ end
215
+
216
+ #: () -> Float
217
+ def get_scroll_x
218
+ @scroll_x
219
+ end
220
+
221
+ #: (Float v) -> void
222
+ def set_scroll_x(v)
223
+ if @scroll_x != v
224
+ @scroll_x = v
225
+ mark_paint_dirty
226
+ end
227
+ end
228
+
229
+ #: () -> Float
230
+ def get_scroll_y
231
+ @scroll_y
232
+ end
233
+
234
+ #: (Float v) -> void
235
+ def set_scroll_y(v)
236
+ if @scroll_y != v
237
+ @scroll_y = v
238
+ mark_paint_dirty
239
+ end
240
+ end
241
+
242
+ #: (Float w, Float h) -> void
243
+ def set_viewport_size(w, h)
244
+ @viewport_width = w
245
+ @viewport_height = h
246
+ @viewport_set = true
247
+ end
248
+
249
+ #: (untyped child) -> bool
250
+ def is_child_visible(child)
251
+ if !@viewport_set
252
+ return true
253
+ end
254
+ cx = child.get_x - @scroll_x
255
+ cy = child.get_y - @scroll_y
256
+ cw = child.get_width
257
+ ch = child.get_height
258
+ !(cx + cw < 0.0 || cx > @viewport_width || cy + ch < 0.0 || cy > @viewport_height)
259
+ end
260
+ end
261
+
262
+ end
data/lib/kumiki/row.rb ADDED
@@ -0,0 +1,288 @@
1
+ module Kumiki
2
+ # rbs_inline: enabled
3
+
4
+ # Row layout - horizontal arrangement of children
5
+
6
+ class Row < Layout
7
+ def initialize
8
+ super
9
+ @spacing = 0.0
10
+ @is_scrollable = false
11
+ @scroll_offset = 0.0
12
+ @content_width = 0.0
13
+ @pin_right = false
14
+ @external_scroll_state = nil
15
+ end
16
+
17
+ #: (Float s) -> Row
18
+ def spacing(s)
19
+ @spacing = s
20
+ self
21
+ end
22
+
23
+ #: (ScrollState ss) -> Row
24
+ def scroll_state(ss)
25
+ @external_scroll_state = ss
26
+ @scroll_offset = ss.x
27
+ self
28
+ end
29
+
30
+ #: () -> Row
31
+ def scrollable
32
+ @is_scrollable = true
33
+ # Retroactively downgrade existing EXPANDING children to CONTENT
34
+ i = 0
35
+ while i < @children.length
36
+ if @children[i].get_width_policy == EXPANDING
37
+ @children[i].set_width_policy(CONTENT)
38
+ end
39
+ i = i + 1
40
+ end
41
+ self
42
+ end
43
+
44
+ #: () -> Row
45
+ def pin_to_end
46
+ @pin_right = true
47
+ self
48
+ end
49
+
50
+ #: () -> bool
51
+ def is_scrollable
52
+ @is_scrollable
53
+ end
54
+
55
+ #: (bool is_direction_x) -> bool
56
+ def has_scrollbar(is_direction_x)
57
+ if is_direction_x
58
+ @is_scrollable
59
+ else
60
+ false
61
+ end
62
+ end
63
+
64
+ #: () -> Float
65
+ def get_scroll_offset
66
+ @scroll_offset
67
+ end
68
+
69
+ #: (Float v) -> void
70
+ def set_scroll_offset(v)
71
+ @scroll_offset = v
72
+ @external_scroll_state&.set_x(v)
73
+ mark_dirty
74
+ update
75
+ end
76
+
77
+ # Override add: auto-downgrade EXPANDING width to CONTENT in scrollable Row
78
+ #: (untyped w) -> Row
79
+ def add(w)
80
+ if w == nil
81
+ return self
82
+ end
83
+ if @is_scrollable && w.get_width_policy == EXPANDING
84
+ w.set_width_policy(CONTENT)
85
+ end
86
+ super(w)
87
+ self
88
+ end
89
+
90
+ #: (untyped painter) -> Size
91
+ def measure(painter)
92
+ total_w = 0.0
93
+ max_h = 0.0
94
+ i = 0
95
+ while i < @children.length
96
+ c = @children[i]
97
+ cs = c.measure(painter)
98
+ if c.get_width_policy == FIXED
99
+ child_w = c.get_width
100
+ else
101
+ child_w = cs.width
102
+ end
103
+ total_w = total_w + child_w
104
+ total_w = total_w + @spacing if i > 0
105
+ if c.get_height_policy == FIXED
106
+ child_h = c.get_height
107
+ else
108
+ child_h = cs.height
109
+ end
110
+ max_h = child_h if child_h > max_h
111
+ i = i + 1
112
+ end
113
+ Size.new(total_w + @pad_left + @pad_right, max_h + @pad_top + @pad_bottom)
114
+ end
115
+
116
+ # Unified layout: two-pass flex distribution + scroll offset.
117
+ # With approach C (auto-downgrade), scrollable containers have no EXPANDING
118
+ # width children, so flex distribution is a no-op and content stacks sequentially.
119
+ #: (untyped painter) -> void
120
+ def relocate_children(painter)
121
+ # Account for padding
122
+ inner_w = @width - @pad_left - @pad_right
123
+ inner_h = @height - @pad_top - @pad_bottom
124
+ if inner_w < 0.0
125
+ inner_w = 0.0
126
+ end
127
+ if inner_h < 0.0
128
+ inner_h = 0.0
129
+ end
130
+
131
+ remaining = inner_w
132
+ expanding_total_flex = 0
133
+
134
+ # First pass: measure CONTENT/FIXED children, collect EXPANDING flex totals
135
+ i = 0
136
+ while i < @children.length
137
+ c = @children[i]
138
+ if c.get_width_policy != EXPANDING
139
+ # Set height before measure so height-dependent layouts work
140
+ if c.get_height_policy != FIXED
141
+ c.resize_wh(c.get_width, inner_h)
142
+ end
143
+ cs = c.measure(painter)
144
+ # Use explicit width for FIXED, measured width for CONTENT
145
+ if c.get_width_policy == FIXED
146
+ child_w = c.get_width
147
+ else
148
+ child_w = cs.width
149
+ end
150
+ if c.get_height_policy == FIXED
151
+ c.resize_wh(child_w, c.get_height)
152
+ else
153
+ c.resize_wh(child_w, inner_h)
154
+ end
155
+ remaining = remaining - child_w
156
+ else
157
+ expanding_total_flex = expanding_total_flex + c.get_flex
158
+ end
159
+ remaining = remaining - @spacing if i > 0
160
+ i = i + 1
161
+ end
162
+
163
+ if remaining < 0.0
164
+ remaining = 0.0
165
+ end
166
+
167
+ # Second pass: distribute remaining space to EXPANDING, position all
168
+ cx = @x + @pad_left
169
+ if @is_scrollable
170
+ cx = cx - @scroll_offset
171
+ end
172
+ total_content_w = 0.0
173
+ i = 0
174
+ while i < @children.length
175
+ c = @children[i]
176
+ if c.get_width_policy == EXPANDING
177
+ w = 0.0
178
+ if expanding_total_flex > 0 && remaining > 0.0
179
+ w = remaining * c.get_flex / expanding_total_flex
180
+ end
181
+ c.resize_wh(w, inner_h)
182
+ else
183
+ # In a Row, non-FIXED height children fill the row height
184
+ if c.get_height_policy != FIXED
185
+ c.resize_wh(c.get_width, inner_h)
186
+ end
187
+ end
188
+ c.move_xy(cx, @y + @pad_top)
189
+ cx = cx + c.get_width + @spacing
190
+ total_content_w = total_content_w + c.get_width
191
+ total_content_w = total_content_w + @spacing if i > 0
192
+ i = i + 1
193
+ end
194
+ @content_width = total_content_w
195
+
196
+ # Auto-scroll to end when pinned
197
+ if @pin_right && @is_scrollable
198
+ max_scroll = @content_width - inner_w
199
+ if max_scroll > 0.0
200
+ @scroll_offset = max_scroll
201
+ @external_scroll_state&.set_x(@scroll_offset)
202
+ end
203
+ end
204
+ end
205
+
206
+ #: (untyped painter, bool completely) -> void
207
+ def redraw(painter, completely)
208
+ saved_bg = Kumiki._bg_clear_color
209
+ if @custom_bg && is_dirty
210
+ parent_bg = saved_bg
211
+ if parent_bg == nil || parent_bg == 0
212
+ parent_bg = Kumiki.theme.bg_canvas
213
+ end
214
+ painter.fill_rect(0.0, 0.0, @width, @height, parent_bg)
215
+ set_dirty(false)
216
+ completely = true
217
+ end
218
+ draw_visual_background(painter)
219
+ relocate_children(painter)
220
+ redraw_children(painter, completely)
221
+ draw_scrollbar(painter) if @is_scrollable
222
+ Kumiki._bg_clear_color = saved_bg
223
+ end
224
+
225
+ #: (untyped painter) -> void
226
+ def draw_scrollbar(painter)
227
+ viewport_w = @width
228
+ content_w = @content_width
229
+ return if content_w <= viewport_w
230
+
231
+ bar_height = 8.0
232
+ thumb_color = 0xC0AAAAAA
233
+
234
+ # Thumb
235
+ thumb_w = viewport_w * viewport_w / content_w
236
+ if thumb_w < 20.0
237
+ thumb_w = 20.0
238
+ end
239
+ thumb_x = (@scroll_offset / content_w) * viewport_w
240
+ if thumb_x + thumb_w > viewport_w
241
+ thumb_x = viewport_w - thumb_w
242
+ end
243
+ painter.fill_round_rect(thumb_x, @height - bar_height + 2.0, thumb_w, bar_height - 4.0, 2.0, thumb_color)
244
+ end
245
+
246
+ #: (WheelEvent ev) -> void
247
+ def mouse_wheel(ev)
248
+ if @is_scrollable
249
+ scroll_speed = 30.0
250
+ @scroll_offset = @scroll_offset - ev.delta_y * scroll_speed
251
+ # Clamp scroll offset
252
+ max_scroll = @content_width - @width
253
+ if max_scroll < 0.0
254
+ max_scroll = 0.0
255
+ end
256
+ if @scroll_offset < 0.0
257
+ @scroll_offset = 0.0
258
+ end
259
+ if @scroll_offset > max_scroll
260
+ @scroll_offset = max_scroll
261
+ end
262
+ # Toggle pin_to_end: disable on scroll left, re-enable at end
263
+ if ev.delta_y > 0.0
264
+ @pin_right = false
265
+ end
266
+ if max_scroll > 0.0 && @scroll_offset >= max_scroll
267
+ @pin_right = true
268
+ end
269
+ @external_scroll_state&.set_x(@scroll_offset)
270
+ mark_dirty
271
+ update
272
+ end
273
+ end
274
+ end
275
+
276
+ # Top-level helper
277
+ #: (*untyped children) -> Row
278
+ def Row(*children)
279
+ row = Row.new
280
+ i = 0
281
+ while i < children.length
282
+ row.add(children[i])
283
+ i = i + 1
284
+ end
285
+ row
286
+ end
287
+
288
+ end