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,279 @@
1
+ module Kumiki
2
+ # StackedBarChart - stacked bar chart widget
3
+ # Supports multiple series stacked on top of each other, hover highlight
4
+
5
+ class StackedBarChart < BaseChart
6
+ def initialize(categories, series_data, series_names)
7
+ super()
8
+ @categories = categories
9
+ @series_data = series_data
10
+ @series_names = series_names
11
+ @show_values = false
12
+ @bar_radius = 0.0
13
+ @data_min = 0.0
14
+ @data_max = 0.0
15
+ @y_scale = nil
16
+ @y_ticks = nil
17
+ @band = nil
18
+ @bar_w = 2.0
19
+ @baseline_y = 0.0
20
+ @num_series = 0
21
+ @num_cats = 0
22
+ @y_range_min = 0.0
23
+ @y_range_max = 1.0
24
+ @chart_px = 0.0
25
+ @chart_py = 0.0
26
+ @chart_pw = 0.0
27
+ @chart_ph = 0.0
28
+ compute_stacked_range
29
+ end
30
+
31
+ def show_values(v)
32
+ @show_values = v
33
+ self
34
+ end
35
+
36
+ def bar_radius(r)
37
+ @bar_radius = r
38
+ self
39
+ end
40
+
41
+ def set_data(categories, series_data, series_names)
42
+ @categories = categories
43
+ @series_data = series_data
44
+ @series_names = series_names
45
+ compute_stacked_range
46
+ mark_dirty
47
+ update
48
+ end
49
+
50
+ def render_chart(painter, px, py, pw, ph)
51
+ return if @categories.length == 0
52
+ return if @series_data.length == 0
53
+ @chart_px = px
54
+ @chart_py = py
55
+ @chart_pw = pw
56
+ @chart_ph = ph
57
+ @num_series = @series_data.length
58
+ @num_cats = @categories.length
59
+ setup_stacked_ticks
60
+ setup_stacked_y_scale
61
+ setup_stacked_band
62
+ @baseline_y = @y_scale.map(0.0)
63
+ bmax = @chart_py + @chart_ph
64
+ if @baseline_y > bmax
65
+ @baseline_y = bmax
66
+ end
67
+ draw_y_axis(painter, px, py, pw, ph, @y_ticks, @y_scale)
68
+ draw_x_axis_line(painter, px, py, pw, ph)
69
+ draw_stacked_cat_labels(painter)
70
+ draw_all_stacked_bars(painter)
71
+ draw_stacked_legend(painter, px, py)
72
+ end
73
+
74
+ def setup_stacked_ticks
75
+ @y_ticks = compute_ticks(@data_min, @data_max, 5)
76
+ @y_range_min = @data_min
77
+ @y_range_max = @data_max
78
+ if @y_ticks.length > 0
79
+ @y_range_min = @y_ticks[0]
80
+ @y_range_max = @y_ticks[@y_ticks.length - 1]
81
+ end
82
+ end
83
+
84
+ def setup_stacked_y_scale
85
+ bottom = @chart_py + @chart_ph
86
+ @y_scale = LinearScale.new(@y_range_min, @y_range_max, bottom, @chart_py)
87
+ end
88
+
89
+ def setup_stacked_band
90
+ right = @chart_px + @chart_pw
91
+ nc = @num_cats * 1.0
92
+ @band = BandScale.new(nc, @chart_px, right, 12.0)
93
+ @bar_w = @band.band_width
94
+ end
95
+
96
+ def draw_stacked_cat_labels(painter)
97
+ lc = Kumiki.theme.text_secondary
98
+ i = 0
99
+ while i < @num_cats
100
+ i_f = i * 1.0
101
+ bx = @band.map(i_f)
102
+ label = @categories[i]
103
+ lw = painter.measure_text_width(label, Kumiki.theme.font_family, 11.0)
104
+ ascent = painter.get_text_ascent(Kumiki.theme.font_family, 11.0)
105
+ label_x = bx + @bar_w / 2.0 - lw / 2.0
106
+ label_y = @chart_py + @chart_ph + 14.0 + ascent
107
+ painter.draw_text(label, label_x, label_y, Kumiki.theme.font_family, 11.0, lc)
108
+ i = i + 1
109
+ end
110
+ end
111
+
112
+ def draw_all_stacked_bars(painter)
113
+ ci = 0
114
+ while ci < @num_cats
115
+ draw_stacked_column(painter, ci)
116
+ ci = ci + 1
117
+ end
118
+ end
119
+
120
+ def draw_stacked_column(painter, ci)
121
+ ci_f = ci * 1.0
122
+ bx = @band.map(ci_f)
123
+ cumulative = 0.0
124
+ si = 0
125
+ while si < @num_series
126
+ val = @series_data[si][ci]
127
+ top_val = cumulative + val
128
+ bar_bottom = @y_scale.map(cumulative)
129
+ bar_top = @y_scale.map(top_val)
130
+ bar_h = bar_bottom - bar_top
131
+ if bar_h < 0.0
132
+ bar_h = 0.0 - bar_h
133
+ bar_top = bar_bottom
134
+ end
135
+ c = series_color(si)
136
+ if @hover_index == ci
137
+ if @hover_series == si
138
+ c = painter.lighten_color(c, 0.3)
139
+ end
140
+ end
141
+ painter.fill_round_rect(bx, bar_top, @bar_w, bar_h, @bar_radius, c)
142
+ if @show_values
143
+ if val > 0.0
144
+ vl = painter.number_to_string(val)
145
+ vw = painter.measure_text_width(vl, Kumiki.theme.font_family, 9.0)
146
+ ascent = painter.get_text_ascent(Kumiki.theme.font_family, 9.0)
147
+ vx = bx + @bar_w / 2.0 - vw / 2.0
148
+ vy = bar_top + bar_h / 2.0 + ascent / 2.0
149
+ painter.draw_text(vl, vx, vy, Kumiki.theme.font_family, 9.0, 4294967295)
150
+ end
151
+ end
152
+ cumulative = top_val
153
+ si = si + 1
154
+ end
155
+ end
156
+
157
+ def draw_stacked_legend(painter, px, py)
158
+ if @show_legend
159
+ if @series_names.length > 1
160
+ colors = []
161
+ si = 0
162
+ while si < @series_names.length
163
+ colors << series_color(si)
164
+ si = si + 1
165
+ end
166
+ draw_legend(painter, @series_names, colors, px + 8.0, py - 20.0)
167
+ end
168
+ end
169
+ end
170
+
171
+ def update_hover
172
+ return if @band == nil
173
+ mx = @mouse_x
174
+ my = @mouse_y
175
+ px = plot_x
176
+ py = plot_y
177
+ pw = plot_w
178
+ ph = plot_h
179
+ if mx < px
180
+ @hover_index = -1
181
+ @hover_series = -1
182
+ return
183
+ end
184
+ if mx > px + pw
185
+ @hover_index = -1
186
+ @hover_series = -1
187
+ return
188
+ end
189
+ if my < py
190
+ @hover_index = -1
191
+ @hover_series = -1
192
+ return
193
+ end
194
+ if my > py + ph
195
+ @hover_index = -1
196
+ @hover_series = -1
197
+ return
198
+ end
199
+ find_stacked_hover(mx, my)
200
+ end
201
+
202
+ def find_stacked_hover(mx, my)
203
+ @hover_index = -1
204
+ @hover_series = -1
205
+ ci = 0
206
+ while ci < @num_cats
207
+ ci_f = ci * 1.0
208
+ bx = @band.map(ci_f)
209
+ if mx >= bx
210
+ if mx <= bx + @bar_w
211
+ # Found the category, now find which series segment
212
+ cumulative = 0.0
213
+ si = 0
214
+ while si < @num_series
215
+ val = @series_data[si][ci]
216
+ top_val = cumulative + val
217
+ bar_bottom = @y_scale.map(cumulative)
218
+ bar_top = @y_scale.map(top_val)
219
+ if bar_top > bar_bottom
220
+ tmp = bar_top
221
+ bar_top = bar_bottom
222
+ bar_bottom = tmp
223
+ end
224
+ if my >= bar_top
225
+ if my <= bar_bottom
226
+ @hover_index = ci
227
+ @hover_series = si
228
+ return
229
+ end
230
+ end
231
+ cumulative = top_val
232
+ si = si + 1
233
+ end
234
+ @hover_index = ci
235
+ return
236
+ end
237
+ end
238
+ ci = ci + 1
239
+ end
240
+ end
241
+
242
+ private
243
+
244
+ def compute_stacked_range
245
+ @data_min = 0.0
246
+ @data_max = 1.0
247
+ return if @series_data.length == 0
248
+ return if @categories.length == 0
249
+ max_stack = 0.0
250
+ ci = 0
251
+ while ci < @categories.length
252
+ stack_total = 0.0
253
+ si = 0
254
+ while si < @series_data.length
255
+ if ci < @series_data[si].length
256
+ stack_total = stack_total + @series_data[si][ci]
257
+ end
258
+ si = si + 1
259
+ end
260
+ if stack_total > max_stack
261
+ max_stack = stack_total
262
+ end
263
+ ci = ci + 1
264
+ end
265
+ @data_max = max_stack
266
+ range = @data_max - @data_min
267
+ if range > 0.0
268
+ @data_max = @data_max + range * 0.1
269
+ else
270
+ @data_max = @data_min + 1.0
271
+ end
272
+ end
273
+ end
274
+
275
+ def StackedBarChart(categories, series_data, series_names)
276
+ StackedBarChart.new(categories, series_data, series_names)
277
+ end
278
+
279
+ end
@@ -0,0 +1,351 @@
1
+ module Kumiki
2
+ # rbs_inline: enabled
3
+
4
+ # Column layout - vertical arrangement of children
5
+
6
+ class Column < Layout
7
+ def initialize
8
+ super
9
+ @spacing = 0.0
10
+ @is_scrollable = false
11
+ @scroll_offset = 0.0
12
+ @content_height = 0.0
13
+ @pin_bottom = false
14
+ @sb_dragging = false
15
+ @sb_drag_start_y = 0.0
16
+ @sb_drag_start_offset = 0.0
17
+ @external_scroll_state = nil
18
+ end
19
+
20
+ #: (Float s) -> Column
21
+ def spacing(s)
22
+ @spacing = s
23
+ self
24
+ end
25
+
26
+ #: (ScrollState ss) -> Column
27
+ def scroll_state(ss)
28
+ @external_scroll_state = ss
29
+ @scroll_offset = ss.y
30
+ self
31
+ end
32
+
33
+ #: () -> Column
34
+ def scrollable
35
+ @is_scrollable = true
36
+ # Retroactively downgrade existing EXPANDING children to CONTENT
37
+ i = 0
38
+ while i < @children.length
39
+ if @children[i].get_height_policy == EXPANDING
40
+ @children[i].set_height_policy(CONTENT)
41
+ end
42
+ i = i + 1
43
+ end
44
+ self
45
+ end
46
+
47
+ #: () -> Column
48
+ def pin_to_bottom
49
+ @pin_bottom = true
50
+ self
51
+ end
52
+
53
+ #: () -> bool
54
+ def is_scrollable
55
+ @is_scrollable
56
+ end
57
+
58
+ #: (bool is_direction_x) -> bool
59
+ def has_scrollbar(is_direction_x)
60
+ if is_direction_x
61
+ false
62
+ else
63
+ @is_scrollable
64
+ end
65
+ end
66
+
67
+ #: () -> Float
68
+ def get_scroll_offset
69
+ @scroll_offset
70
+ end
71
+
72
+ #: (Float v) -> void
73
+ def set_scroll_offset(v)
74
+ @scroll_offset = v
75
+ @external_scroll_state&.set_y(v)
76
+ mark_dirty
77
+ update
78
+ end
79
+
80
+ # Override add: auto-downgrade EXPANDING height to CONTENT in scrollable Column
81
+ #: (untyped w) -> Column
82
+ def add(w)
83
+ if w == nil
84
+ return self
85
+ end
86
+ if @is_scrollable && w.get_height_policy == EXPANDING
87
+ w.set_height_policy(CONTENT)
88
+ end
89
+ super(w)
90
+ self
91
+ end
92
+
93
+ #: (untyped painter) -> Size
94
+ def measure(painter)
95
+ total_h = 0.0
96
+ max_w = 0.0
97
+ i = 0
98
+ while i < @children.length
99
+ c = @children[i]
100
+ cs = c.measure(painter)
101
+ if c.get_height_policy == FIXED
102
+ child_h = c.get_height
103
+ else
104
+ child_h = cs.height
105
+ end
106
+ total_h = total_h + child_h
107
+ total_h = total_h + @spacing if i > 0
108
+ max_w = cs.width if cs.width > max_w
109
+ i = i + 1
110
+ end
111
+ Size.new(max_w + @pad_left + @pad_right, total_h + @pad_top + @pad_bottom)
112
+ end
113
+
114
+ # Unified layout: two-pass flex distribution + scroll offset.
115
+ # With approach C (auto-downgrade), scrollable containers have no EXPANDING
116
+ # children, so flex distribution is a no-op and content stacks sequentially.
117
+ #: (untyped painter) -> void
118
+ def relocate_children(painter)
119
+ # Account for padding
120
+ inner_w = @width - @pad_left - @pad_right
121
+ inner_h = @height - @pad_top - @pad_bottom
122
+ if inner_w < 0.0
123
+ inner_w = 0.0
124
+ end
125
+ if inner_h < 0.0
126
+ inner_h = 0.0
127
+ end
128
+
129
+ remaining = inner_h
130
+ expanding_total_flex = 0
131
+
132
+ # First pass: measure CONTENT/FIXED children, collect EXPANDING flex totals
133
+ i = 0
134
+ while i < @children.length
135
+ c = @children[i]
136
+ if c.get_height_policy != EXPANDING
137
+ # Set width before measure (for word-wrap, centering, etc.)
138
+ if c.get_width_policy != FIXED
139
+ c.resize_wh(inner_w, c.get_height)
140
+ end
141
+ cs = c.measure(painter)
142
+ # Use explicit height for FIXED, measured height for CONTENT
143
+ if c.get_height_policy == FIXED
144
+ child_h = c.get_height
145
+ else
146
+ child_h = cs.height
147
+ end
148
+ if c.get_width_policy == FIXED
149
+ c.resize_wh(cs.width, child_h)
150
+ else
151
+ c.resize_wh(inner_w, child_h)
152
+ end
153
+ remaining = remaining - child_h
154
+ else
155
+ expanding_total_flex = expanding_total_flex + c.get_flex
156
+ end
157
+ remaining = remaining - @spacing if i > 0
158
+ i = i + 1
159
+ end
160
+
161
+ if remaining < 0.0
162
+ remaining = 0.0
163
+ end
164
+
165
+ # Second pass: distribute remaining space to EXPANDING, position all
166
+ cy = @y + @pad_top
167
+ if @is_scrollable
168
+ cy = cy - @scroll_offset
169
+ end
170
+ total_content_h = 0.0
171
+ i = 0
172
+ while i < @children.length
173
+ c = @children[i]
174
+ if c.get_height_policy == EXPANDING
175
+ h = 0.0
176
+ if expanding_total_flex > 0 && remaining > 0.0
177
+ h = remaining * c.get_flex / expanding_total_flex
178
+ end
179
+ c.resize_wh(inner_w, h)
180
+ else
181
+ # In a Column, non-FIXED children fill the column width
182
+ if c.get_width_policy != FIXED
183
+ c.resize_wh(inner_w, c.get_height)
184
+ end
185
+ end
186
+ c.move_xy(@x + @pad_left, cy)
187
+ cy = cy + c.get_height + @spacing
188
+ total_content_h = total_content_h + c.get_height
189
+ total_content_h = total_content_h + @spacing if i > 0
190
+ i = i + 1
191
+ end
192
+ @content_height = total_content_h
193
+
194
+ # Auto-scroll to bottom when pinned
195
+ if @pin_bottom && @is_scrollable
196
+ max_scroll = @content_height - inner_h
197
+ if max_scroll > 0.0
198
+ @scroll_offset = max_scroll
199
+ @external_scroll_state&.set_y(@scroll_offset)
200
+ end
201
+ end
202
+ end
203
+
204
+ #: (untyped painter, bool completely) -> void
205
+ def redraw(painter, completely)
206
+ saved_bg = Kumiki._bg_clear_color
207
+ # When this layout has a custom background and is dirty, we handle clearing
208
+ # ourselves to preserve rounded corners. Clear dirty flag so redraw_children
209
+ # won't overwrite with a solid fill_rect.
210
+ if @custom_bg && is_dirty
211
+ parent_bg = saved_bg
212
+ if parent_bg == nil || parent_bg == 0
213
+ parent_bg = Kumiki.theme.bg_canvas
214
+ end
215
+ painter.fill_rect(0.0, 0.0, @width, @height, parent_bg)
216
+ set_dirty(false)
217
+ completely = true
218
+ end
219
+ draw_visual_background(painter)
220
+ relocate_children(painter)
221
+ redraw_children(painter, completely)
222
+ draw_scrollbar(painter) if @is_scrollable
223
+ Kumiki._bg_clear_color = saved_bg
224
+ end
225
+
226
+ #: (untyped painter) -> void
227
+ def draw_scrollbar(painter)
228
+ viewport_h = @height
229
+ content_h = @content_height
230
+ return if content_h <= viewport_h
231
+
232
+ bar_width = 8.0
233
+ thumb_color = 0xC0AAAAAA
234
+
235
+ # Thumb
236
+ thumb_h = viewport_h * viewport_h / content_h
237
+ if thumb_h < 20.0
238
+ thumb_h = 20.0
239
+ end
240
+ thumb_y = (@scroll_offset / content_h) * viewport_h
241
+ if thumb_y + thumb_h > viewport_h
242
+ thumb_y = viewport_h - thumb_h
243
+ end
244
+ painter.fill_round_rect(@width - bar_width + 2.0, thumb_y, bar_width - 4.0, thumb_h, 2.0, thumb_color)
245
+ end
246
+
247
+ # Intercept clicks on the scrollbar area before dispatching to children
248
+ #: (Point p) -> Array
249
+ def dispatch(p)
250
+ if @is_scrollable && @content_height > @height && contain(p)
251
+ local_x = p.x - @x
252
+ if local_x >= @width - 8.0
253
+ local_p = Point.new(local_x, p.y - @y)
254
+ return [self, local_p]
255
+ end
256
+ end
257
+ super(p)
258
+ end
259
+
260
+ #: (MouseEvent ev) -> void
261
+ def mouse_down(ev)
262
+ if @is_scrollable && @content_height > @height
263
+ @sb_dragging = true
264
+ @sb_drag_start_y = ev.pos.y
265
+ @sb_drag_start_offset = @scroll_offset
266
+ # Jump scroll to clicked position
267
+ viewport_h = @height
268
+ content_h = @content_height
269
+ max_scroll = content_h - viewport_h
270
+ @scroll_offset = (ev.pos.y / viewport_h) * max_scroll
271
+ if @scroll_offset < 0.0
272
+ @scroll_offset = 0.0
273
+ end
274
+ if @scroll_offset > max_scroll
275
+ @scroll_offset = max_scroll
276
+ end
277
+ @external_scroll_state&.set_y(@scroll_offset)
278
+ mark_dirty
279
+ update
280
+ end
281
+ end
282
+
283
+ #: (MouseEvent ev) -> void
284
+ def mouse_drag(ev)
285
+ if @sb_dragging
286
+ viewport_h = @height
287
+ content_h = @content_height
288
+ if content_h > viewport_h
289
+ max_scroll = content_h - viewport_h
290
+ @scroll_offset = (ev.pos.y / viewport_h) * max_scroll
291
+ if @scroll_offset < 0.0
292
+ @scroll_offset = 0.0
293
+ end
294
+ if @scroll_offset > max_scroll
295
+ @scroll_offset = max_scroll
296
+ end
297
+ @external_scroll_state&.set_y(@scroll_offset)
298
+ mark_dirty
299
+ update
300
+ end
301
+ end
302
+ end
303
+
304
+ #: (MouseEvent ev) -> void
305
+ def mouse_up(ev)
306
+ @sb_dragging = false
307
+ end
308
+
309
+ #: (WheelEvent ev) -> void
310
+ def mouse_wheel(ev)
311
+ if @is_scrollable
312
+ scroll_speed = 30.0
313
+ @scroll_offset = @scroll_offset - ev.delta_y * scroll_speed
314
+ # Clamp scroll offset
315
+ max_scroll = @content_height - @height
316
+ if max_scroll < 0.0
317
+ max_scroll = 0.0
318
+ end
319
+ if @scroll_offset < 0.0
320
+ @scroll_offset = 0.0
321
+ end
322
+ if @scroll_offset > max_scroll
323
+ @scroll_offset = max_scroll
324
+ end
325
+ # Toggle pin_to_bottom: disable on scroll up, re-enable at bottom
326
+ if ev.delta_y > 0.0
327
+ @pin_bottom = false
328
+ end
329
+ if max_scroll > 0.0 && @scroll_offset >= max_scroll
330
+ @pin_bottom = true
331
+ end
332
+ @external_scroll_state&.set_y(@scroll_offset)
333
+ mark_dirty
334
+ update
335
+ end
336
+ end
337
+ end
338
+
339
+ # Top-level helper
340
+ #: (*untyped children) -> Column
341
+ def Column(*children)
342
+ col = Column.new
343
+ i = 0
344
+ while i < children.length
345
+ col.add(children[i])
346
+ i = i + 1
347
+ end
348
+ col
349
+ end
350
+
351
+ end