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,2511 @@
1
+ module Kumiki
2
+ # rbs_inline: enabled
3
+
4
+ # Kumiki Core — Widget / State / Layout / Component
5
+
6
+ # ===== Size Policy Constants =====
7
+ FIXED = 0
8
+ EXPANDING = 1
9
+ CONTENT = 2
10
+
11
+ # Propagated clear color from Container to child layouts during rendering.
12
+ # 0 = not set (use Kumiki.theme.bg_canvas). Set by Container.redraw, read by Layout.redraw_children.
13
+ # Initialized via Kumiki._bg_clear_color accessor (default 0)
14
+
15
+ # ===== Geometry =====
16
+
17
+ class Point
18
+ #: (Float x, Float y) -> void
19
+ def initialize(x, y)
20
+ @x = x
21
+ @y = y
22
+ end
23
+
24
+ #: () -> Float
25
+ def x
26
+ @x
27
+ end
28
+
29
+ #: () -> Float
30
+ def y
31
+ @y
32
+ end
33
+
34
+ #: (Float v) -> Float
35
+ def x=(v)
36
+ @x = v
37
+ end
38
+
39
+ #: (Float v) -> Float
40
+ def y=(v)
41
+ @y = v
42
+ end
43
+ end
44
+
45
+ class Size
46
+ #: (Float width, Float height) -> void
47
+ def initialize(width, height)
48
+ @width = width
49
+ @height = height
50
+ end
51
+
52
+ #: () -> Float
53
+ def width
54
+ @width
55
+ end
56
+
57
+ #: () -> Float
58
+ def height
59
+ @height
60
+ end
61
+
62
+ #: (Float v) -> Float
63
+ def width=(v)
64
+ @width = v
65
+ end
66
+
67
+ #: (Float v) -> Float
68
+ def height=(v)
69
+ @height = v
70
+ end
71
+ end
72
+
73
+ class Rect
74
+ #: (Float x, Float y, Float width, Float height) -> void
75
+ def initialize(x, y, width, height)
76
+ @x = x
77
+ @y = y
78
+ @width = width
79
+ @height = height
80
+ end
81
+
82
+ #: () -> Float
83
+ def x
84
+ @x
85
+ end
86
+
87
+ #: () -> Float
88
+ def y
89
+ @y
90
+ end
91
+
92
+ #: () -> Float
93
+ def width
94
+ @width
95
+ end
96
+
97
+ #: () -> Float
98
+ def height
99
+ @height
100
+ end
101
+ end
102
+
103
+ # ===== Mouse Event =====
104
+
105
+ class MouseEvent
106
+ #: (Point pos, Integer button) -> void
107
+ def initialize(pos, button)
108
+ @pos = pos
109
+ @button = button
110
+ end
111
+
112
+ #: () -> Point
113
+ def pos
114
+ @pos
115
+ end
116
+
117
+ #: (Point v) -> Point
118
+ def pos=(v)
119
+ @pos = v
120
+ end
121
+
122
+ #: () -> Integer
123
+ def button
124
+ @button
125
+ end
126
+ end
127
+
128
+ # ===== Wheel Event =====
129
+
130
+ class WheelEvent
131
+ #: (Point pos, Float delta_y) -> void
132
+ def initialize(pos, delta_y)
133
+ @pos = pos
134
+ @delta_y = delta_y
135
+ end
136
+
137
+ #: () -> Point
138
+ def pos
139
+ @pos
140
+ end
141
+
142
+ #: () -> Float
143
+ def delta_y
144
+ @delta_y
145
+ end
146
+ end
147
+
148
+ # ===== Observer/Observable Pattern =====
149
+
150
+ class ObservableBase
151
+ def initialize
152
+ @observers = []
153
+ end
154
+
155
+ #: (untyped observer) -> void
156
+ def attach(observer)
157
+ @observers << observer
158
+ observer.on_attach(self)
159
+ end
160
+
161
+ #: (untyped observer) -> void
162
+ def detach(observer)
163
+ i = 0
164
+ while i < @observers.length
165
+ if @observers[i] == observer
166
+ @observers.delete_at(i)
167
+ observer.on_detach(self)
168
+ return
169
+ end
170
+ i = i + 1
171
+ end
172
+ end
173
+
174
+ #: () -> void
175
+ def notify_observers
176
+ # Iterate over a copy to avoid issues if observers are modified during notification
177
+ copy = []
178
+ i = 0
179
+ while i < @observers.length
180
+ copy << @observers[i]
181
+ i = i + 1
182
+ end
183
+ i = 0
184
+ while i < copy.length
185
+ # Check observer is still attached before notifying
186
+ j = 0
187
+ still_attached = false
188
+ while j < @observers.length
189
+ if @observers[j] == copy[i]
190
+ still_attached = true
191
+ break
192
+ end
193
+ j = j + 1
194
+ end
195
+ copy[i].on_notify if still_attached
196
+ i = i + 1
197
+ end
198
+ end
199
+ end
200
+
201
+ # ===== State =====
202
+
203
+ class State < ObservableBase
204
+ #: (untyped value) -> void
205
+ def initialize(value)
206
+ super()
207
+ @value = value
208
+ end
209
+
210
+ #: () -> untyped
211
+ def value
212
+ @value
213
+ end
214
+
215
+ #: (untyped v) -> void
216
+ def set(v)
217
+ @value = v
218
+ notify_observers
219
+ end
220
+
221
+ # In-place mutation operators (for @count += 1 pattern)
222
+ # Ruby has no __iadd__, so += expands to @count = @count.+(1)
223
+ # These mutate the value, notify observers, and return self.
224
+ #: (untyped other) -> State
225
+ def +(other)
226
+ @value = @value + other
227
+ notify_observers
228
+ self
229
+ end
230
+
231
+ #: (untyped other) -> State
232
+ def -(other)
233
+ @value = @value - other
234
+ notify_observers
235
+ self
236
+ end
237
+
238
+ #: (untyped other) -> State
239
+ def *(other)
240
+ @value = @value * other
241
+ notify_observers
242
+ self
243
+ end
244
+
245
+ #: (untyped other) -> State
246
+ def /(other)
247
+ @value = @value / other
248
+ notify_observers
249
+ self
250
+ end
251
+
252
+ #: () -> String
253
+ def to_s
254
+ @value.to_s
255
+ end
256
+
257
+ #: () -> Integer
258
+ def to_i
259
+ @value.to_i
260
+ end
261
+
262
+ #: () -> Float
263
+ def to_f
264
+ @value.to_f
265
+ end
266
+ end
267
+
268
+ # ===== ListState =====
269
+ # Reactive list that notifies observers on mutation
270
+
271
+ class ListState < ObservableBase
272
+ #: (Array items) -> void
273
+ def initialize(items)
274
+ super()
275
+ @items = []
276
+ i = 0
277
+ while i < items.length
278
+ @items << items[i]
279
+ i = i + 1
280
+ end
281
+ end
282
+
283
+ #: () -> Integer
284
+ def length
285
+ @items.length
286
+ end
287
+
288
+ #: (Integer index) -> untyped
289
+ def [](index)
290
+ @items[index]
291
+ end
292
+
293
+ #: (Integer index, untyped value) -> untyped
294
+ def []=(index, value)
295
+ @items[index] = value
296
+ notify_observers
297
+ end
298
+
299
+ #: (untyped value) -> void
300
+ def push(value)
301
+ @items << value
302
+ notify_observers
303
+ end
304
+
305
+ #: () -> untyped
306
+ def pop
307
+ result = @items.pop
308
+ notify_observers
309
+ result
310
+ end
311
+
312
+ #: (Integer index) -> untyped
313
+ def delete_at(index)
314
+ result = @items.delete_at(index)
315
+ notify_observers
316
+ result
317
+ end
318
+
319
+ #: () -> void
320
+ def clear
321
+ @items = []
322
+ notify_observers
323
+ end
324
+
325
+ #: (Array items) -> void
326
+ def set(items)
327
+ @items = []
328
+ i = 0
329
+ while i < items.length
330
+ @items << items[i]
331
+ i = i + 1
332
+ end
333
+ notify_observers
334
+ end
335
+
336
+ #: () { (untyped) -> void } -> void
337
+ def each(&block)
338
+ i = 0
339
+ while i < @items.length
340
+ block.call(@items[i])
341
+ i = i + 1
342
+ end
343
+ end
344
+ end
345
+
346
+ # ===== ScrollState =====
347
+ # Observable scroll position that persists across view rebuilds
348
+
349
+ class ScrollState < ObservableBase
350
+ def initialize
351
+ super()
352
+ @x = 0.0
353
+ @y = 0.0
354
+ end
355
+
356
+ #: () -> Float
357
+ def x
358
+ @x
359
+ end
360
+
361
+ #: (Float v) -> void
362
+ def set_x(v)
363
+ if @x != v
364
+ @x = v
365
+ notify_observers
366
+ end
367
+ end
368
+
369
+ #: () -> Float
370
+ def y
371
+ @y
372
+ end
373
+
374
+ #: (Float v) -> void
375
+ def set_y(v)
376
+ if @y != v
377
+ @y = v
378
+ notify_observers
379
+ end
380
+ end
381
+
382
+ #: (Float x, Float y) -> void
383
+ def set(x, y)
384
+ changed = false
385
+ if @x != x
386
+ @x = x
387
+ changed = true
388
+ end
389
+ if @y != y
390
+ @y = y
391
+ changed = true
392
+ end
393
+ notify_observers if changed
394
+ end
395
+ end
396
+
397
+ # ===== InputState =====
398
+ # Holds single-line input state (text, cursor, selection, IME preedit).
399
+ # Persists across Component rebuilds when stored in Component#initialize.
400
+
401
+ class InputState
402
+ #: (String placeholder) -> void
403
+ def initialize(placeholder)
404
+ @text = ""
405
+ @placeholder = placeholder
406
+ @cursor = 0
407
+ @selection_start = -1
408
+ @selection_end = -1
409
+ @is_selecting = false
410
+ @preedit_text = ""
411
+ @preedit_cursor = 0
412
+ end
413
+
414
+ # --- Getters ---
415
+
416
+ #: () -> String
417
+ def value
418
+ @text
419
+ end
420
+
421
+ #: () -> Integer
422
+ def get_cursor
423
+ @cursor
424
+ end
425
+
426
+ #: () -> String
427
+ def get_placeholder
428
+ @placeholder
429
+ end
430
+
431
+ # --- Text operations ---
432
+
433
+ #: (String v) -> void
434
+ def set(v)
435
+ @text = v
436
+ @cursor = v.length
437
+ end
438
+
439
+ #: (String text) -> void
440
+ def insert(text)
441
+ before = ""
442
+ if @cursor > 0
443
+ before = @text[0, @cursor]
444
+ end
445
+ rest_len = @text.length - @cursor
446
+ after = @text[@cursor, rest_len]
447
+ @text = before + text + after
448
+ @cursor = @cursor + text.length
449
+ end
450
+
451
+ #: () -> bool
452
+ def delete_prev
453
+ if @cursor > 0
454
+ before = ""
455
+ if @cursor > 1
456
+ before = @text[0, @cursor - 1]
457
+ end
458
+ rest_len = @text.length - @cursor
459
+ after = @text[@cursor, rest_len]
460
+ @text = before + after
461
+ @cursor = @cursor - 1
462
+ return true
463
+ end
464
+ false
465
+ end
466
+
467
+ #: () -> bool
468
+ def delete_next
469
+ if @cursor < @text.length
470
+ before = ""
471
+ if @cursor > 0
472
+ before = @text[0, @cursor]
473
+ end
474
+ rest_start = @cursor + 1
475
+ rest_len = @text.length - rest_start
476
+ after = ""
477
+ if rest_len > 0
478
+ after = @text[rest_start, rest_len]
479
+ end
480
+ @text = before + after
481
+ return true
482
+ end
483
+ false
484
+ end
485
+
486
+ # --- Cursor movement ---
487
+
488
+ #: () -> bool
489
+ def move_prev
490
+ if @cursor > 0
491
+ @cursor = @cursor - 1
492
+ return true
493
+ end
494
+ false
495
+ end
496
+
497
+ #: () -> bool
498
+ def move_next
499
+ if @cursor < @text.length
500
+ @cursor = @cursor + 1
501
+ return true
502
+ end
503
+ false
504
+ end
505
+
506
+ #: () -> bool
507
+ def move_home
508
+ if @cursor > 0
509
+ @cursor = 0
510
+ return true
511
+ end
512
+ false
513
+ end
514
+
515
+ #: () -> bool
516
+ def move_end
517
+ if @cursor < @text.length
518
+ @cursor = @text.length
519
+ return true
520
+ end
521
+ false
522
+ end
523
+
524
+ # --- Selection ---
525
+
526
+ #: () -> bool
527
+ def has_selection
528
+ if @selection_start < 0
529
+ return false
530
+ end
531
+ if @selection_end < 0
532
+ return false
533
+ end
534
+ if @selection_start == @selection_end
535
+ return false
536
+ end
537
+ true
538
+ end
539
+
540
+ #: () -> Array
541
+ def get_selection_range
542
+ result_s = 0
543
+ result_e = 0
544
+ if has_selection
545
+ s = @selection_start
546
+ e = @selection_end
547
+ if s > e
548
+ result_s = e
549
+ result_e = s
550
+ else
551
+ result_s = s
552
+ result_e = e
553
+ end
554
+ end
555
+ [result_s, result_e]
556
+ end
557
+
558
+ #: () -> String
559
+ def get_selected_text
560
+ result = ""
561
+ if has_selection
562
+ range = get_selection_range
563
+ s = range[0]
564
+ e = range[1]
565
+ len = e - s
566
+ result = @text[s, len]
567
+ end
568
+ result
569
+ end
570
+
571
+ #: () -> void
572
+ def delete_selection
573
+ if has_selection
574
+ range = get_selection_range
575
+ s = range[0]
576
+ e = range[1]
577
+ before = ""
578
+ if s > 0
579
+ before = @text[0, s]
580
+ end
581
+ rest_start = e
582
+ rest_len = @text.length - e
583
+ after = ""
584
+ if rest_len > 0
585
+ after = @text[rest_start, rest_len]
586
+ end
587
+ @text = before + after
588
+ @cursor = s
589
+ @selection_start = -1
590
+ @selection_end = -1
591
+ @is_selecting = false
592
+ end
593
+ end
594
+
595
+ #: () -> void
596
+ def clear_selection
597
+ @selection_start = -1
598
+ @selection_end = -1
599
+ @is_selecting = false
600
+ end
601
+
602
+ #: () -> void
603
+ def select_all
604
+ if @text.length > 0
605
+ @selection_start = 0
606
+ @selection_end = @text.length
607
+ @is_selecting = false
608
+ end
609
+ end
610
+
611
+ #: (Integer pos) -> void
612
+ def start_selection(pos)
613
+ clear_selection
614
+ @selection_start = pos
615
+ @selection_end = pos
616
+ @is_selecting = true
617
+ @cursor = pos
618
+ end
619
+
620
+ #: (Integer pos) -> void
621
+ def update_selection(pos)
622
+ @selection_end = pos
623
+ @cursor = pos
624
+ end
625
+
626
+ #: () -> void
627
+ def end_selection
628
+ @is_selecting = false
629
+ end
630
+
631
+ #: () -> bool
632
+ def is_selecting
633
+ @is_selecting
634
+ end
635
+
636
+ #: (Integer pos) -> void
637
+ def set_cursor_by_click(pos)
638
+ @cursor = pos
639
+ end
640
+
641
+ # --- IME ---
642
+
643
+ #: () -> bool
644
+ def has_preedit
645
+ @preedit_text.length > 0
646
+ end
647
+
648
+ #: () -> String
649
+ def get_display_text
650
+ result = @text
651
+ if has_preedit
652
+ before = ""
653
+ if @cursor > 0
654
+ before = @text[0, @cursor]
655
+ end
656
+ rest_len = @text.length - @cursor
657
+ after = @text[@cursor, rest_len]
658
+ result = before + @preedit_text + after
659
+ end
660
+ result
661
+ end
662
+
663
+ #: (String text, Integer cursor) -> void
664
+ def set_preedit(text, cursor)
665
+ @preedit_text = text
666
+ @preedit_cursor = cursor
667
+ end
668
+
669
+ #: () -> void
670
+ def clear_preedit
671
+ @preedit_text = ""
672
+ @preedit_cursor = 0
673
+ end
674
+
675
+ #: () -> String
676
+ def get_preedit_text
677
+ @preedit_text
678
+ end
679
+
680
+ #: () -> Integer
681
+ def get_preedit_cursor
682
+ @preedit_cursor
683
+ end
684
+
685
+ # --- Focus lifecycle ---
686
+
687
+ #: () -> void
688
+ def start_editing
689
+ end
690
+
691
+ #: () -> void
692
+ def finish_editing
693
+ clear_preedit
694
+ clear_selection
695
+ end
696
+ end
697
+
698
+ # ===== MultilineInputState =====
699
+ # Holds multi-line input state (lines, cursor, selection, scroll, IME preedit).
700
+ # Persists across Component rebuilds when stored in Component#initialize.
701
+
702
+ class MultilineInputState
703
+ #: (String text) -> void
704
+ def initialize(text)
705
+ @lines = [""]
706
+ if text != nil && text.length > 0
707
+ @lines = split_lines(text)
708
+ end
709
+ @row = @lines.length - 1
710
+ @col = @lines[@row].length
711
+ @target_col = -1
712
+ @scroll_y = 0.0
713
+ @manual_scroll = false
714
+ @selection_start = [-1, -1]
715
+ @selection_end = [-1, -1]
716
+ @is_selecting = false
717
+ @preedit_text = ""
718
+ @preedit_cursor = 0
719
+ end
720
+
721
+ #: (String text) -> Array
722
+ def split_lines(text)
723
+ result = []
724
+ current = ""
725
+ i = 0
726
+ while i < text.length
727
+ ch = text[i]
728
+ if ch == "\n"
729
+ result << current
730
+ current = ""
731
+ else
732
+ current = current + ch
733
+ end
734
+ i = i + 1
735
+ end
736
+ result << current
737
+ result
738
+ end
739
+
740
+ # --- Getters ---
741
+
742
+ #: () -> String
743
+ def value
744
+ get_text
745
+ end
746
+
747
+ #: () -> String
748
+ def get_text
749
+ result = ""
750
+ i = 0
751
+ while i < @lines.length
752
+ if i > 0
753
+ result = result + "\n"
754
+ end
755
+ result = result + @lines[i]
756
+ i = i + 1
757
+ end
758
+ result
759
+ end
760
+
761
+ #: () -> Array
762
+ def get_lines
763
+ @lines
764
+ end
765
+
766
+ #: () -> Integer
767
+ def get_row
768
+ @row
769
+ end
770
+
771
+ #: () -> Integer
772
+ def get_col
773
+ @col
774
+ end
775
+
776
+ #: () -> Integer
777
+ def get_target_col
778
+ @target_col
779
+ end
780
+
781
+ #: () -> Float
782
+ def get_scroll_y
783
+ @scroll_y
784
+ end
785
+
786
+ #: (Float v) -> void
787
+ def set_scroll_y(v)
788
+ @scroll_y = v
789
+ end
790
+
791
+ #: () -> bool
792
+ def is_manual_scroll
793
+ @manual_scroll
794
+ end
795
+
796
+ #: (bool v) -> void
797
+ def set_manual_scroll(v)
798
+ @manual_scroll = v
799
+ end
800
+
801
+ #: (String t) -> void
802
+ def set_text(t)
803
+ @lines = split_lines(t)
804
+ @row = @lines.length - 1
805
+ @col = @lines[@row].length
806
+ @target_col = -1
807
+ end
808
+
809
+ # --- Text operations ---
810
+
811
+ #: (String text) -> void
812
+ def insert_char(text)
813
+ line = @lines[@row]
814
+ before = ""
815
+ if @col > 0
816
+ before = line[0, @col]
817
+ end
818
+ after_len = line.length - @col
819
+ after = ""
820
+ if after_len > 0
821
+ after = line[@col, after_len]
822
+ end
823
+ @lines[@row] = before + text + after
824
+ @col = @col + text.length
825
+ @target_col = -1
826
+ @manual_scroll = false
827
+ end
828
+
829
+ #: () -> void
830
+ def insert_newline
831
+ line = @lines[@row]
832
+ before = ""
833
+ if @col > 0
834
+ before = line[0, @col]
835
+ end
836
+ after_len = line.length - @col
837
+ after = ""
838
+ if after_len > 0
839
+ after = line[@col, after_len]
840
+ end
841
+ @lines[@row] = before
842
+ insert_line_after_row(@row, after)
843
+ @row = @row + 1
844
+ @col = 0
845
+ @target_col = -1
846
+ end
847
+
848
+ #: (Integer row, String line_text) -> void
849
+ def insert_line_after_row(row, line_text)
850
+ new_lines = []
851
+ j = 0
852
+ while j <= row
853
+ new_lines << @lines[j]
854
+ j = j + 1
855
+ end
856
+ new_lines << line_text
857
+ j = row + 1
858
+ while j < @lines.length
859
+ new_lines << @lines[j]
860
+ j = j + 1
861
+ end
862
+ @lines = new_lines
863
+ end
864
+
865
+ #: () -> bool
866
+ def delete_prev
867
+ if @col > 0
868
+ line = @lines[@row]
869
+ before = ""
870
+ if @col > 1
871
+ before = line[0, @col - 1]
872
+ end
873
+ after_len = line.length - @col
874
+ after = ""
875
+ if after_len > 0
876
+ after = line[@col, after_len]
877
+ end
878
+ @lines[@row] = before + after
879
+ @col = @col - 1
880
+ @target_col = -1
881
+ return true
882
+ elsif @row > 0
883
+ prev_line = @lines[@row - 1]
884
+ curr_line = @lines[@row]
885
+ @lines[@row - 1] = prev_line + curr_line
886
+ @lines.delete_at(@row)
887
+ @row = @row - 1
888
+ @col = prev_line.length
889
+ @target_col = -1
890
+ return true
891
+ end
892
+ false
893
+ end
894
+
895
+ #: () -> bool
896
+ def delete_next
897
+ line = @lines[@row]
898
+ if @col < line.length
899
+ before = ""
900
+ if @col > 0
901
+ before = line[0, @col]
902
+ end
903
+ rest_start = @col + 1
904
+ rest_len = line.length - rest_start
905
+ after = ""
906
+ if rest_len > 0
907
+ after = line[rest_start, rest_len]
908
+ end
909
+ @lines[@row] = before + after
910
+ @target_col = -1
911
+ return true
912
+ elsif @row < @lines.length - 1
913
+ next_line = @lines[@row + 1]
914
+ @lines[@row] = line + next_line
915
+ @lines.delete_at(@row + 1)
916
+ @target_col = -1
917
+ return true
918
+ end
919
+ false
920
+ end
921
+
922
+ # --- Cursor movement ---
923
+
924
+ #: () -> void
925
+ def move_left
926
+ if @col > 0
927
+ @col = @col - 1
928
+ elsif @row > 0
929
+ @row = @row - 1
930
+ @col = @lines[@row].length
931
+ end
932
+ @target_col = -1
933
+ end
934
+
935
+ #: () -> void
936
+ def move_right
937
+ line = @lines[@row]
938
+ if @col < line.length
939
+ @col = @col + 1
940
+ elsif @row < @lines.length - 1
941
+ @row = @row + 1
942
+ @col = 0
943
+ end
944
+ @target_col = -1
945
+ end
946
+
947
+ #: () -> bool
948
+ def move_up
949
+ if @row > 0
950
+ if @target_col < 0
951
+ @target_col = @col
952
+ end
953
+ @row = @row - 1
954
+ line_len = @lines[@row].length
955
+ @col = @target_col
956
+ if @col > line_len
957
+ @col = line_len
958
+ end
959
+ return true
960
+ end
961
+ false
962
+ end
963
+
964
+ #: () -> bool
965
+ def move_down
966
+ if @row < @lines.length - 1
967
+ if @target_col < 0
968
+ @target_col = @col
969
+ end
970
+ @row = @row + 1
971
+ line_len = @lines[@row].length
972
+ @col = @target_col
973
+ if @col > line_len
974
+ @col = line_len
975
+ end
976
+ return true
977
+ end
978
+ false
979
+ end
980
+
981
+ #: () -> bool
982
+ def move_home
983
+ if @col > 0
984
+ @col = 0
985
+ @target_col = -1
986
+ return true
987
+ end
988
+ false
989
+ end
990
+
991
+ #: () -> bool
992
+ def move_end
993
+ line_len = @lines[@row].length
994
+ if @col < line_len
995
+ @col = line_len
996
+ @target_col = -1
997
+ return true
998
+ end
999
+ false
1000
+ end
1001
+
1002
+ # --- Selection ---
1003
+
1004
+ #: () -> bool
1005
+ def has_selection
1006
+ if @selection_start[0] < 0
1007
+ return false
1008
+ end
1009
+ if @selection_end[0] < 0
1010
+ return false
1011
+ end
1012
+ if @selection_start[0] == @selection_end[0] && @selection_start[1] == @selection_end[1]
1013
+ return false
1014
+ end
1015
+ true
1016
+ end
1017
+
1018
+ #: () -> Array
1019
+ def get_selection_range
1020
+ result_sr = 0
1021
+ result_sc = 0
1022
+ result_er = 0
1023
+ result_ec = 0
1024
+ if has_selection
1025
+ sr = @selection_start[0]
1026
+ sc = @selection_start[1]
1027
+ er = @selection_end[0]
1028
+ ec = @selection_end[1]
1029
+ if sr > er
1030
+ result_sr = er
1031
+ result_sc = ec
1032
+ result_er = sr
1033
+ result_ec = sc
1034
+ elsif sr == er && sc > ec
1035
+ result_sr = sr
1036
+ result_sc = ec
1037
+ result_er = er
1038
+ result_ec = sc
1039
+ else
1040
+ result_sr = sr
1041
+ result_sc = sc
1042
+ result_er = er
1043
+ result_ec = ec
1044
+ end
1045
+ end
1046
+ [result_sr, result_sc, result_er, result_ec]
1047
+ end
1048
+
1049
+ #: () -> String
1050
+ def get_selected_text
1051
+ result = ""
1052
+ if has_selection
1053
+ range = get_selection_range
1054
+ sr = range[0]
1055
+ sc = range[1]
1056
+ er = range[2]
1057
+ ec = range[3]
1058
+ if sr == er
1059
+ line = @lines[sr]
1060
+ len = ec - sc
1061
+ result = line[sc, len]
1062
+ else
1063
+ first_line = @lines[sr]
1064
+ first_len = first_line.length - sc
1065
+ result = first_line[sc, first_len]
1066
+ r = sr + 1
1067
+ while r < er
1068
+ result = result + "\n" + @lines[r]
1069
+ r = r + 1
1070
+ end
1071
+ last_line = @lines[er]
1072
+ result = result + "\n" + last_line[0, ec]
1073
+ end
1074
+ end
1075
+ result
1076
+ end
1077
+
1078
+ #: () -> void
1079
+ def delete_selection
1080
+ if !has_selection
1081
+ return
1082
+ end
1083
+ range = get_selection_range
1084
+ sr = range[0]
1085
+ sc = range[1]
1086
+ er = range[2]
1087
+ ec = range[3]
1088
+ if sr == er
1089
+ delete_selection_single_line(sr, sc, ec)
1090
+ else
1091
+ delete_selection_multi_line(sr, sc, er, ec)
1092
+ end
1093
+ @row = sr
1094
+ @col = sc
1095
+ @selection_start = [-1, -1]
1096
+ @selection_end = [-1, -1]
1097
+ @is_selecting = false
1098
+ end
1099
+
1100
+ #: (Integer row, Integer sc, Integer ec) -> void
1101
+ def delete_selection_single_line(row, sc, ec)
1102
+ line = @lines[row]
1103
+ before = ""
1104
+ if sc > 0
1105
+ before = line[0, sc]
1106
+ end
1107
+ after_len = line.length - ec
1108
+ after = ""
1109
+ if after_len > 0
1110
+ after = line[ec, after_len]
1111
+ end
1112
+ @lines[row] = before + after
1113
+ end
1114
+
1115
+ #: (Integer sr, Integer sc, Integer er, Integer ec) -> void
1116
+ def delete_selection_multi_line(sr, sc, er, ec)
1117
+ first_part = ""
1118
+ if sc > 0
1119
+ first_line = @lines[sr]
1120
+ first_part = first_line[0, sc]
1121
+ end
1122
+ last_line = @lines[er]
1123
+ last_part = ""
1124
+ after_len = last_line.length - ec
1125
+ if after_len > 0
1126
+ last_part = last_line[ec, after_len]
1127
+ end
1128
+ @lines[sr] = first_part + last_part
1129
+ count = er - sr
1130
+ while count > 0
1131
+ @lines.delete_at(sr + 1)
1132
+ count = count - 1
1133
+ end
1134
+ end
1135
+
1136
+ #: () -> void
1137
+ def clear_selection
1138
+ @selection_start = [-1, -1]
1139
+ @selection_end = [-1, -1]
1140
+ @is_selecting = false
1141
+ end
1142
+
1143
+ #: () -> void
1144
+ def select_all
1145
+ if @lines.length > 0
1146
+ @selection_start = [0, 0]
1147
+ last_row = @lines.length - 1
1148
+ @selection_end = [last_row, @lines[last_row].length]
1149
+ @is_selecting = false
1150
+ end
1151
+ end
1152
+
1153
+ #: (Integer row, Integer col) -> void
1154
+ def start_selection(row, col)
1155
+ clear_selection
1156
+ @selection_start = [row, col]
1157
+ @selection_end = [row, col]
1158
+ @is_selecting = true
1159
+ @row = row
1160
+ @col = col
1161
+ end
1162
+
1163
+ #: (Integer row, Integer col) -> void
1164
+ def update_selection(row, col)
1165
+ @selection_end = [row, col]
1166
+ @row = row
1167
+ @col = col
1168
+ end
1169
+
1170
+ #: () -> void
1171
+ def end_selection
1172
+ @is_selecting = false
1173
+ end
1174
+
1175
+ #: () -> bool
1176
+ def is_selecting
1177
+ @is_selecting
1178
+ end
1179
+
1180
+ # --- IME ---
1181
+
1182
+ #: () -> bool
1183
+ def has_preedit
1184
+ @preedit_text.length > 0
1185
+ end
1186
+
1187
+ #: (String text, Integer cursor) -> void
1188
+ def set_preedit(text, cursor)
1189
+ @preedit_text = text
1190
+ @preedit_cursor = cursor
1191
+ end
1192
+
1193
+ #: () -> void
1194
+ def clear_preedit
1195
+ @preedit_text = ""
1196
+ @preedit_cursor = 0
1197
+ end
1198
+
1199
+ #: () -> String
1200
+ def get_preedit_text
1201
+ @preedit_text
1202
+ end
1203
+
1204
+ #: () -> Integer
1205
+ def get_preedit_cursor
1206
+ @preedit_cursor
1207
+ end
1208
+
1209
+ # --- Focus lifecycle ---
1210
+
1211
+ #: () -> void
1212
+ def finish_editing
1213
+ clear_preedit
1214
+ clear_selection
1215
+ end
1216
+
1217
+ # --- Paste ---
1218
+
1219
+ #: (String text) -> void
1220
+ def paste_text(text)
1221
+ i = 0
1222
+ while i < text.length
1223
+ ch = text[i]
1224
+ if ch == "\n"
1225
+ insert_newline
1226
+ else
1227
+ paste_single_char(ch)
1228
+ end
1229
+ i = i + 1
1230
+ end
1231
+ @target_col = -1
1232
+ end
1233
+
1234
+ #: (String ch) -> void
1235
+ def paste_single_char(ch)
1236
+ line = @lines[@row]
1237
+ before = ""
1238
+ if @col > 0
1239
+ before = line[0, @col]
1240
+ end
1241
+ after_len = line.length - @col
1242
+ after = ""
1243
+ if after_len > 0
1244
+ after = line[@col, after_len]
1245
+ end
1246
+ @lines[@row] = before + ch + after
1247
+ @col = @col + 1
1248
+ end
1249
+ end
1250
+
1251
+ # ===== Widget =====
1252
+ # Now with RenderNode, lifecycle hooks, z-order, dirty tracking
1253
+
1254
+ class Widget
1255
+ def initialize
1256
+ @x = 0.0
1257
+ @y = 0.0
1258
+ @width = 0.0
1259
+ @height = 0.0
1260
+ @visible = true
1261
+ @dirty = true
1262
+ @parent = nil
1263
+ @width_policy = EXPANDING
1264
+ @height_policy = EXPANDING
1265
+ @flex = 1
1266
+ @z_index = 1
1267
+ @tab_index = 0
1268
+ @focusable = false
1269
+ @mounted = false
1270
+ @cached = false
1271
+ @depth = 0
1272
+ @enable_to_detach = true
1273
+ @render_node = nil
1274
+ @observables = []
1275
+ @pad_top = 0.0
1276
+ @pad_right = 0.0
1277
+ @pad_bottom = 0.0
1278
+ @pad_left = 0.0
1279
+ end
1280
+
1281
+ # --- Size Policy / Style (method chaining) ---
1282
+
1283
+ #: (Float w) -> Widget
1284
+ def fixed_width(w)
1285
+ @width_policy = FIXED
1286
+ @width = w
1287
+ self
1288
+ end
1289
+
1290
+ #: (Float h) -> Widget
1291
+ def fixed_height(h)
1292
+ @height_policy = FIXED
1293
+ @height = h
1294
+ self
1295
+ end
1296
+
1297
+ #: (Float w, Float h) -> Widget
1298
+ def fixed_size(w, h)
1299
+ fixed_width(w)
1300
+ fixed_height(h)
1301
+ end
1302
+
1303
+ #: () -> Widget
1304
+ def fit_content
1305
+ @width_policy = CONTENT
1306
+ @height_policy = CONTENT
1307
+ self
1308
+ end
1309
+
1310
+ #: (Integer f) -> Widget
1311
+ def flex(f)
1312
+ @flex = f
1313
+ self
1314
+ end
1315
+
1316
+ #: (Integer p) -> Widget
1317
+ def set_width_policy(p)
1318
+ @width_policy = p
1319
+ self
1320
+ end
1321
+
1322
+ #: (Integer p) -> Widget
1323
+ def set_height_policy(p)
1324
+ @height_policy = p
1325
+ self
1326
+ end
1327
+
1328
+ #: (Float t, Float r, Float b, Float l) -> Widget
1329
+ def padding(t, r, b, l)
1330
+ @pad_top = t
1331
+ @pad_right = r
1332
+ @pad_bottom = b
1333
+ @pad_left = l
1334
+ self
1335
+ end
1336
+
1337
+ #: (Integer z) -> Widget
1338
+ def z_index(z)
1339
+ @z_index = z
1340
+ # Invalidate parent's z-order cache
1341
+ if @parent != nil
1342
+ rn = @parent.get_render_node
1343
+ if rn != nil
1344
+ rn.invalidate_z_order
1345
+ end
1346
+ end
1347
+ self
1348
+ end
1349
+
1350
+ #: () -> Integer
1351
+ def get_z_index
1352
+ @z_index
1353
+ end
1354
+
1355
+ #: (Integer value) -> Widget
1356
+ def tab_index(value)
1357
+ @tab_index = value
1358
+ self
1359
+ end
1360
+
1361
+ #: () -> Integer
1362
+ def get_tab_index
1363
+ @tab_index
1364
+ end
1365
+
1366
+ #: (bool value) -> Widget
1367
+ def focusable(value)
1368
+ @focusable = value
1369
+ self
1370
+ end
1371
+
1372
+ #: () -> bool
1373
+ def is_focusable
1374
+ @focusable
1375
+ end
1376
+
1377
+ # --- Children (overridden by Layout) ---
1378
+
1379
+ #: () -> Array
1380
+ def get_children
1381
+ []
1382
+ end
1383
+
1384
+ # --- Layout Protocol ---
1385
+
1386
+ #: (untyped painter) -> Size
1387
+ def measure(painter)
1388
+ Size.new(@width, @height)
1389
+ end
1390
+
1391
+ #: (untyped painter) -> void
1392
+ def relocate(painter)
1393
+ end
1394
+
1395
+ #: (untyped painter, bool completely) -> void
1396
+ def redraw(painter, completely)
1397
+ end
1398
+
1399
+ # --- Position / Size ---
1400
+
1401
+ #: () -> Point
1402
+ def get_pos
1403
+ Point.new(@x, @y)
1404
+ end
1405
+
1406
+ #: () -> Size
1407
+ def get_size
1408
+ Size.new(@width, @height)
1409
+ end
1410
+
1411
+ #: () -> Float
1412
+ def get_x
1413
+ @x
1414
+ end
1415
+
1416
+ #: () -> Float
1417
+ def get_y
1418
+ @y
1419
+ end
1420
+
1421
+ #: () -> Float
1422
+ def get_width
1423
+ @width
1424
+ end
1425
+
1426
+ #: () -> Float
1427
+ def get_height
1428
+ @height
1429
+ end
1430
+
1431
+ #: () -> Integer
1432
+ def get_width_policy
1433
+ @width_policy
1434
+ end
1435
+
1436
+ #: () -> Integer
1437
+ def get_height_policy
1438
+ @height_policy
1439
+ end
1440
+
1441
+ #: () -> Integer
1442
+ def get_flex
1443
+ @flex
1444
+ end
1445
+
1446
+ #: (Point p) -> Widget
1447
+ def move(p)
1448
+ new_x = p.x
1449
+ new_y = p.y
1450
+ if new_x != @x || new_y != @y
1451
+ @x = new_x
1452
+ @y = new_y
1453
+ mark_layout_dirty
1454
+ end
1455
+ self
1456
+ end
1457
+
1458
+ #: (Float x, Float y) -> Widget
1459
+ def move_xy(x, y)
1460
+ if x != @x || y != @y
1461
+ @x = x
1462
+ @y = y
1463
+ mark_layout_dirty
1464
+ end
1465
+ self
1466
+ end
1467
+
1468
+ #: (Size s) -> Widget
1469
+ def resize(s)
1470
+ new_w = s.width
1471
+ new_h = s.height
1472
+ if new_w != @width || new_h != @height
1473
+ @width = new_w
1474
+ @height = new_h
1475
+ mark_layout_dirty
1476
+ end
1477
+ self
1478
+ end
1479
+
1480
+ #: (Float w, Float h) -> Widget
1481
+ def resize_wh(w, h)
1482
+ if w != @width || h != @height
1483
+ @width = w
1484
+ @height = h
1485
+ mark_layout_dirty
1486
+ end
1487
+ self
1488
+ end
1489
+
1490
+ # --- Parent / Tree ---
1491
+
1492
+ #: (untyped p) -> void
1493
+ def set_parent(p)
1494
+ do_mount(p)
1495
+ end
1496
+
1497
+ #: () -> untyped
1498
+ def get_parent
1499
+ @parent
1500
+ end
1501
+
1502
+ #: () -> Integer
1503
+ def get_depth
1504
+ @depth
1505
+ end
1506
+
1507
+ # --- RenderNode ---
1508
+
1509
+ #: () -> untyped
1510
+ def get_render_node
1511
+ @render_node
1512
+ end
1513
+
1514
+ #: () -> untyped
1515
+ def ensure_render_node
1516
+ if @render_node == nil
1517
+ @render_node = create_render_node
1518
+ end
1519
+ @render_node
1520
+ end
1521
+
1522
+ #: () -> RenderNodeBase
1523
+ def create_render_node
1524
+ RenderNodeBase.new(self)
1525
+ end
1526
+
1527
+ # --- Dirty Tracking ---
1528
+ # Delegated to RenderNode when available, with fallback to @dirty flag
1529
+
1530
+ #: () -> bool
1531
+ def is_dirty
1532
+ if @render_node != nil
1533
+ return @render_node.is_paint_dirty
1534
+ end
1535
+ @dirty
1536
+ end
1537
+
1538
+ #: () -> bool
1539
+ def is_layout_dirty
1540
+ if @render_node != nil
1541
+ return @render_node.is_layout_dirty
1542
+ end
1543
+ @dirty
1544
+ end
1545
+
1546
+ #: () -> bool
1547
+ def is_subtree_dirty
1548
+ if @render_node != nil
1549
+ return @render_node.is_subtree_dirty
1550
+ end
1551
+ false
1552
+ end
1553
+
1554
+ #: (bool flag) -> void
1555
+ def set_dirty(flag)
1556
+ @dirty = flag
1557
+ if @render_node != nil
1558
+ if flag
1559
+ @render_node.mark_paint_dirty
1560
+ else
1561
+ @render_node.clear_dirty
1562
+ end
1563
+ end
1564
+ end
1565
+
1566
+ #: () -> void
1567
+ def mark_dirty
1568
+ @dirty = true
1569
+ if @render_node != nil
1570
+ @render_node.mark_paint_dirty
1571
+ end
1572
+ propagate_subtree_dirty
1573
+ end
1574
+
1575
+ #: () -> void
1576
+ def mark_layout_dirty
1577
+ @dirty = true
1578
+ if @render_node != nil
1579
+ @render_node.mark_layout_dirty
1580
+ end
1581
+ propagate_subtree_dirty
1582
+ end
1583
+
1584
+ #: () -> void
1585
+ def mark_paint_dirty
1586
+ @dirty = true
1587
+ if @render_node != nil
1588
+ @render_node.mark_paint_dirty
1589
+ end
1590
+ propagate_subtree_dirty
1591
+ end
1592
+
1593
+ # Propagate subtree_dirty up the parent chain
1594
+ #: () -> void
1595
+ def propagate_subtree_dirty
1596
+ p = @parent
1597
+ while p != nil
1598
+ rn = p.get_render_node
1599
+ if rn != nil
1600
+ break if rn.is_subtree_dirty
1601
+ rn.mark_subtree_dirty
1602
+ end
1603
+ p = p.get_parent
1604
+ end
1605
+ end
1606
+
1607
+ # --- Lifecycle ---
1608
+
1609
+ #: () -> void
1610
+ def on_mount
1611
+ end
1612
+
1613
+ #: () -> void
1614
+ def on_unmount
1615
+ end
1616
+
1617
+ #: (untyped parent) -> void
1618
+ def do_mount(parent)
1619
+ if !@mounted
1620
+ @mounted = true
1621
+ @parent = parent
1622
+ @depth = parent != nil ? parent.get_depth + 1 : 0
1623
+ on_mount
1624
+ else
1625
+ # Already mounted - update parent (for cached widgets being re-parented)
1626
+ @parent = parent
1627
+ @depth = parent != nil ? parent.get_depth + 1 : 0
1628
+ end
1629
+ end
1630
+
1631
+ #: () -> void
1632
+ def do_unmount
1633
+ # Skip unmount for cached widgets (they're being reused)
1634
+ if @cached
1635
+ return
1636
+ end
1637
+ if @mounted
1638
+ on_unmount
1639
+ @mounted = false
1640
+ end
1641
+ end
1642
+
1643
+ #: () -> bool
1644
+ def is_mounted
1645
+ @mounted
1646
+ end
1647
+
1648
+ #: () -> void
1649
+ def freeze_widget
1650
+ @enable_to_detach = false
1651
+ end
1652
+
1653
+ #: (bool v) -> void
1654
+ def set_cached(v)
1655
+ @cached = v
1656
+ end
1657
+
1658
+ #: () -> bool
1659
+ def is_cached
1660
+ @cached
1661
+ end
1662
+
1663
+ # --- Observer Protocol ---
1664
+
1665
+ #: (untyped o) -> void
1666
+ def on_attach(o)
1667
+ @observables << o
1668
+ end
1669
+
1670
+ #: (untyped o) -> void
1671
+ def on_detach(o)
1672
+ i = 0
1673
+ while i < @observables.length
1674
+ if @observables[i] == o
1675
+ @observables.delete_at(i)
1676
+ return
1677
+ end
1678
+ i = i + 1
1679
+ end
1680
+ end
1681
+
1682
+ #: () -> void
1683
+ def on_notify
1684
+ mark_paint_dirty
1685
+ end
1686
+
1687
+ # --- Detach ---
1688
+
1689
+ #: () -> void
1690
+ def detach
1691
+ do_unmount
1692
+ if @enable_to_detach
1693
+ # Detach from all observables (copy list for safe iteration)
1694
+ copy = []
1695
+ i = 0
1696
+ while i < @observables.length
1697
+ copy << @observables[i]
1698
+ i = i + 1
1699
+ end
1700
+ i = 0
1701
+ while i < copy.length
1702
+ copy[i].detach(self)
1703
+ i = i + 1
1704
+ end
1705
+ end
1706
+ # Clear App-level references to prevent ghost redraws
1707
+ app = App.current
1708
+ if app != nil
1709
+ app.clear_widget_refs(self)
1710
+ end
1711
+ end
1712
+
1713
+ #: (untyped state) -> void
1714
+ def model(state)
1715
+ # Detach from old state if any
1716
+ if @observables.length > 0
1717
+ copy = []
1718
+ i = 0
1719
+ while i < @observables.length
1720
+ copy << @observables[i]
1721
+ i = i + 1
1722
+ end
1723
+ i = 0
1724
+ while i < copy.length
1725
+ copy[i].detach(self)
1726
+ i = i + 1
1727
+ end
1728
+ end
1729
+ state.attach(self)
1730
+ end
1731
+
1732
+ # --- Hit Test ---
1733
+
1734
+ #: (Point p) -> bool
1735
+ def contain(p)
1736
+ p.x >= @x && p.x < @x + @width && p.y >= @y && p.y < @y + @height
1737
+ end
1738
+
1739
+ #: (Point p) -> Array
1740
+ def dispatch(p)
1741
+ if contain(p)
1742
+ local_p = Point.new(p.x - @x, p.y - @y)
1743
+ [self, local_p]
1744
+ else
1745
+ [nil, nil]
1746
+ end
1747
+ end
1748
+
1749
+ #: (Point p, bool is_direction_x) -> Array
1750
+ def dispatch_to_scrollable(p, is_direction_x)
1751
+ [nil, nil]
1752
+ end
1753
+
1754
+ #: () -> bool
1755
+ def is_scrollable
1756
+ false
1757
+ end
1758
+
1759
+ # --- Events ---
1760
+
1761
+ #: (MouseEvent ev) -> void
1762
+ def mouse_down(ev)
1763
+ end
1764
+
1765
+ #: (MouseEvent ev) -> void
1766
+ def mouse_up(ev)
1767
+ end
1768
+
1769
+ #: (MouseEvent ev) -> void
1770
+ def mouse_drag(ev)
1771
+ end
1772
+
1773
+ #: () -> void
1774
+ def mouse_over
1775
+ end
1776
+
1777
+ #: () -> void
1778
+ def mouse_out
1779
+ end
1780
+
1781
+ #: (WheelEvent ev) -> void
1782
+ def mouse_wheel(ev)
1783
+ end
1784
+
1785
+ #: (MouseEvent ev) -> void
1786
+ def cursor_pos(ev)
1787
+ end
1788
+
1789
+ #: (String text) -> void
1790
+ def input_char(text)
1791
+ end
1792
+
1793
+ #: (Integer key_code, Integer modifiers) -> void
1794
+ def input_key(key_code, modifiers)
1795
+ end
1796
+
1797
+ #: (String text, Integer sel_start, Integer sel_end) -> void
1798
+ def ime_preedit(text, sel_start, sel_end)
1799
+ end
1800
+
1801
+ # Text state for focus preservation across Component rebuilds
1802
+ # Override in Input/MultilineInput
1803
+ #: () -> String
1804
+ def get_text
1805
+ ""
1806
+ end
1807
+
1808
+ #: (String t) -> void
1809
+ def set_text(t)
1810
+ end
1811
+
1812
+ # Restore text without triggering update/requestFrame
1813
+ #: (String t) -> void
1814
+ def restore_text(t)
1815
+ set_text(t)
1816
+ end
1817
+
1818
+ #: () -> void
1819
+ def focused
1820
+ end
1821
+
1822
+ # Restore focus state without triggering update/requestFrame
1823
+ # Used during Component rebuild to avoid infinite rendering loop
1824
+ #: () -> void
1825
+ def restore_focus
1826
+ focused
1827
+ end
1828
+
1829
+ #: () -> void
1830
+ def unfocused
1831
+ end
1832
+
1833
+ # --- Update ---
1834
+ # Walk up the tree to find scrollable/component parent for targeted update
1835
+
1836
+ #: () -> void
1837
+ def update
1838
+ parent = @parent
1839
+ root = nil
1840
+ while parent != nil
1841
+ if parent.is_scrollable
1842
+ root = parent
1843
+ end
1844
+ parent = parent.get_parent
1845
+ end
1846
+
1847
+ app = App.current
1848
+ if app == nil
1849
+ return
1850
+ end
1851
+
1852
+ if root == nil
1853
+ app.post_update(self)
1854
+ else
1855
+ app.post_update(root)
1856
+ end
1857
+ end
1858
+ end
1859
+
1860
+ # ===== Layout =====
1861
+ # Now with LayoutRenderNode, z-order dispatch, child lifecycle
1862
+
1863
+ class Layout < Widget
1864
+ def initialize
1865
+ super
1866
+ @children = []
1867
+ # Visual properties (background, border)
1868
+ @bg_color_val = 0
1869
+ @border_color_val = 0
1870
+ @custom_bg = false
1871
+ @custom_border = false
1872
+ @border_radius_val = 0.0
1873
+ @border_width_val = 1.0
1874
+ @bg_clear_color = nil
1875
+ end
1876
+
1877
+ # --- Visual Properties (method chaining) ---
1878
+
1879
+ #: (Integer c) -> Layout
1880
+ def bg_color(c)
1881
+ @bg_color_val = c
1882
+ @custom_bg = true
1883
+ self
1884
+ end
1885
+
1886
+ #: (Integer c) -> Layout
1887
+ def border_color(c)
1888
+ @border_color_val = c
1889
+ @custom_border = true
1890
+ self
1891
+ end
1892
+
1893
+ #: (Float r) -> Layout
1894
+ def border_radius(r)
1895
+ @border_radius_val = r
1896
+ self
1897
+ end
1898
+
1899
+ #: (Float w) -> Layout
1900
+ def border_width(w)
1901
+ @border_width_val = w
1902
+ self
1903
+ end
1904
+
1905
+ # Draw background and border if visual properties are set.
1906
+ #: (untyped painter) -> void
1907
+ def draw_visual_background(painter)
1908
+ if @custom_bg
1909
+ painter.fill_round_rect(0.0, 0.0, @width, @height, @border_radius_val, @bg_color_val)
1910
+ @bg_clear_color = @bg_color_val
1911
+ end
1912
+ end
1913
+
1914
+ #: () -> Array
1915
+ def get_children
1916
+ @children
1917
+ end
1918
+
1919
+ #: () -> LayoutRenderNode
1920
+ def create_render_node
1921
+ LayoutRenderNode.new(self)
1922
+ end
1923
+
1924
+ #: (untyped w) -> Layout
1925
+ def add(w)
1926
+ if w == nil
1927
+ return self
1928
+ end
1929
+ # Remove from old parent if needed
1930
+ old_parent = w.get_parent
1931
+ if old_parent != nil && old_parent != self
1932
+ old_parent.remove_child_widget(w)
1933
+ end
1934
+
1935
+ @children << w
1936
+ w.set_parent(self)
1937
+
1938
+ # Sync with render node for z-order caching
1939
+ rn = ensure_render_node
1940
+ rn.add_child(w)
1941
+ self
1942
+ end
1943
+
1944
+ #: (untyped w) -> void
1945
+ def remove_child_widget(w)
1946
+ i = 0
1947
+ while i < @children.length
1948
+ if @children[i] == w
1949
+ @children.delete_at(i)
1950
+ break
1951
+ end
1952
+ i = i + 1
1953
+ end
1954
+ rn = get_render_node
1955
+ if rn != nil
1956
+ rn.remove_child(w)
1957
+ end
1958
+ end
1959
+
1960
+ #: (untyped w) -> void
1961
+ def remove(w)
1962
+ remove_child_widget(w)
1963
+ w.do_unmount
1964
+ end
1965
+
1966
+ #: () -> void
1967
+ def clear_children
1968
+ @children = []
1969
+ rn = get_render_node
1970
+ if rn != nil
1971
+ rn.clear_children
1972
+ end
1973
+ end
1974
+
1975
+ #: () -> void
1976
+ def detach
1977
+ super
1978
+ if @enable_to_detach
1979
+ i = 0
1980
+ while i < @children.length
1981
+ @children[i].detach
1982
+ i = i + 1
1983
+ end
1984
+ end
1985
+ end
1986
+
1987
+ # --- Hit Test with z-order ---
1988
+
1989
+ #: (Point p) -> Array
1990
+ def dispatch(p)
1991
+ if contain(p)
1992
+ # Use z-order: higher z-index receives events first
1993
+ rn = ensure_render_node
1994
+ hit_order = rn.iter_hit_test_order
1995
+ i = 0
1996
+ while i < hit_order.length
1997
+ result = hit_order[i].dispatch(p)
1998
+ target = result[0]
1999
+ adjusted = result[1]
2000
+ if target != nil
2001
+ return [target, adjusted]
2002
+ end
2003
+ i = i + 1
2004
+ end
2005
+ local_p = Point.new(p.x - @x, p.y - @y)
2006
+ [self, local_p]
2007
+ else
2008
+ [nil, nil]
2009
+ end
2010
+ end
2011
+
2012
+ #: (Point p, bool is_direction_x) -> Array
2013
+ def dispatch_to_scrollable(p, is_direction_x)
2014
+ if contain(p)
2015
+ rn = ensure_render_node
2016
+ hit_order = rn.iter_hit_test_order
2017
+ i = 0
2018
+ while i < hit_order.length
2019
+ result = hit_order[i].dispatch_to_scrollable(p, is_direction_x)
2020
+ target = result[0]
2021
+ adjusted = result[1]
2022
+ if target != nil
2023
+ return [target, adjusted]
2024
+ end
2025
+ i = i + 1
2026
+ end
2027
+ if has_scrollbar(is_direction_x)
2028
+ return [self, p]
2029
+ end
2030
+ [nil, nil]
2031
+ else
2032
+ [nil, nil]
2033
+ end
2034
+ end
2035
+
2036
+ #: (bool is_direction_x) -> bool
2037
+ def has_scrollbar(is_direction_x)
2038
+ false
2039
+ end
2040
+
2041
+ # --- Redraw with z-order ---
2042
+ # Separated into _relocate_children and _redraw_children
2043
+
2044
+ #: (untyped painter, bool completely) -> void
2045
+ def redraw(painter, completely)
2046
+ relocate_children(painter)
2047
+ redraw_children(painter, completely)
2048
+ end
2049
+
2050
+ #: (untyped painter) -> void
2051
+ def relocate_children(painter)
2052
+ # Subclasses override this (Column, Row, Box)
2053
+ relocate(painter)
2054
+ end
2055
+
2056
+ #: (untyped painter, bool completely) -> void
2057
+ def redraw_children(painter, completely)
2058
+ # Determine effective clear color with 3-level fallback:
2059
+ # own @bg_clear_color > propagated Kumiki._bg_clear_color > Kumiki.theme.bg_canvas
2060
+ has_own_bg = false
2061
+ if @bg_clear_color != nil
2062
+ has_own_bg = true
2063
+ end
2064
+ has_parent_bg = false
2065
+ if !has_own_bg && Kumiki._bg_clear_color != 0
2066
+ has_parent_bg = true
2067
+ end
2068
+ effective_clear = 0
2069
+ if has_own_bg
2070
+ effective_clear = @bg_clear_color
2071
+ else
2072
+ if has_parent_bg
2073
+ effective_clear = Kumiki._bg_clear_color
2074
+ else
2075
+ effective_clear = Kumiki.theme.bg_canvas
2076
+ end
2077
+ end
2078
+ # When this layout itself is dirty (scroll, resize, etc.), force full child repaint.
2079
+ # This is in redraw_children (not redraw) because Column/Row override redraw without calling super.
2080
+ if is_dirty
2081
+ completely = true
2082
+ painter.fill_rect(0.0, 0.0, @width, @height, effective_clear)
2083
+ end
2084
+ # Sub-painter caching: when available (ranma/vello backend), cache each child's
2085
+ # vello Scene independently. Non-dirty children reuse cached scenes.
2086
+ use_sub = painter.respond_to?(:supports_sub_painter?) && painter.supports_sub_painter?
2087
+ if use_sub
2088
+ @_sub_painters ||= {}
2089
+ @_sub_painter_sizes ||= {}
2090
+ if @_sub_painter_parent_id != painter.object_id
2091
+ @_sub_painters.clear
2092
+ @_sub_painter_sizes.clear
2093
+ @_sub_painter_parent_id = painter.object_id
2094
+ end
2095
+ end
2096
+ # Use z-order: lower z-index drawn first (background to foreground)
2097
+ rn = ensure_render_node
2098
+ paint_order = rn.iter_paint_order
2099
+ i = 0
2100
+ while i < paint_order.length
2101
+ c = paint_order[i]
2102
+ if use_sub
2103
+ child_id = c.object_id
2104
+ sub_p = @_sub_painters[child_id]
2105
+ if sub_p.nil?
2106
+ sub_p = painter.create_sub_painter
2107
+ @_sub_painters[child_id] = sub_p
2108
+ end
2109
+ child_size_key = [c.get_width, c.get_height]
2110
+ size_changed = @_sub_painter_sizes[child_id] != child_size_key
2111
+ if completely || c.is_dirty || c.is_subtree_dirty || size_changed
2112
+ sub_p.reset
2113
+ sub_p.save
2114
+ sub_p.clip_rect(0.0, 0.0, c.get_width, c.get_height)
2115
+ c.redraw(sub_p, completely || size_changed)
2116
+ sub_p.restore
2117
+ c.set_dirty(false)
2118
+ @_sub_painter_sizes[child_id] = child_size_key
2119
+ end
2120
+ painter.append(sub_p, c.get_x - @x, c.get_y - @y)
2121
+ else
2122
+ if completely || c.is_dirty || c.is_subtree_dirty
2123
+ painter.save
2124
+ painter.translate(c.get_x - @x, c.get_y - @y)
2125
+ painter.clip_rect(0.0, 0.0, c.get_width, c.get_height)
2126
+ # Clear dirty widget's area before redrawing (off-screen surface retains old pixels)
2127
+ if !completely && c.is_dirty
2128
+ painter.fill_rect(0.0, 0.0, c.get_width, c.get_height, effective_clear)
2129
+ end
2130
+ c.redraw(painter, completely)
2131
+ painter.restore
2132
+ c.set_dirty(false)
2133
+ end
2134
+ end
2135
+ i = i + 1
2136
+ end
2137
+ end
2138
+ end
2139
+
2140
+ # ===== BuildOwner =====
2141
+ # Batches multiple state changes into a single rebuild pass.
2142
+ #
2143
+ # Usage:
2144
+ # owner = BuildOwner.get
2145
+ # owner.build_scope {
2146
+ # state1.set(value1)
2147
+ # state2.set(value2)
2148
+ # }
2149
+ # # → Only ONE rebuild for all affected components
2150
+
2151
+ class BuildOwner
2152
+ @@instance = nil
2153
+
2154
+ #: () -> BuildOwner
2155
+ def self.get
2156
+ if @@instance == nil
2157
+ @@instance = BuildOwner.new
2158
+ end
2159
+ @@instance
2160
+ end
2161
+
2162
+ #: () -> void
2163
+ def self.reset
2164
+ @@instance = nil
2165
+ end
2166
+
2167
+ def initialize
2168
+ @dirty_components = []
2169
+ @in_build_scope = false
2170
+ @scope_depth = 0
2171
+ end
2172
+
2173
+ #: () -> bool
2174
+ def is_in_build_scope
2175
+ @in_build_scope
2176
+ end
2177
+
2178
+ # Schedule a component for rebuild. Deduplicates.
2179
+ # Inside build_scope: just adds to dirty list.
2180
+ # Outside build_scope: immediate mode (backward compatibility).
2181
+ #: (untyped component) -> void
2182
+ def schedule_build_for(component)
2183
+ # Dedup: skip if already in dirty list
2184
+ i = 0
2185
+ while i < @dirty_components.length
2186
+ if @dirty_components[i] == component
2187
+ return
2188
+ end
2189
+ i = i + 1
2190
+ end
2191
+
2192
+ if !@in_build_scope
2193
+ # Immediate mode: mark and trigger redraw now (don't accumulate)
2194
+ component.mark_pending_rebuild
2195
+ app = App.current
2196
+ if app != nil
2197
+ app.post_update(component)
2198
+ end
2199
+ else
2200
+ # Batched mode: just add to dirty list for flush_builds
2201
+ @dirty_components << component
2202
+ end
2203
+ end
2204
+
2205
+ # Execute a block with batched rebuilds.
2206
+ # Supports nesting: only the outermost scope triggers flush.
2207
+ #: () -> void
2208
+ def build_scope
2209
+ @scope_depth = @scope_depth + 1
2210
+ @in_build_scope = true
2211
+ yield
2212
+ @scope_depth = @scope_depth - 1
2213
+ if @scope_depth == 0
2214
+ @in_build_scope = false
2215
+ flush_builds
2216
+ end
2217
+ end
2218
+
2219
+ # Process all pending rebuilds: mark as pending, trigger one redraw.
2220
+ # Components are sorted by depth (parents before children) so parent
2221
+ # rebuilds don't cause redundant child rebuilds.
2222
+ #: () -> void
2223
+ def flush_builds
2224
+ while @dirty_components.length > 0
2225
+ # Sort by depth (parents first)
2226
+ sorted = sort_by_depth(@dirty_components)
2227
+ @dirty_components = []
2228
+
2229
+ # Mark all as pending rebuild
2230
+ i = 0
2231
+ while i < sorted.length
2232
+ sorted[i].mark_pending_rebuild
2233
+ i = i + 1
2234
+ end
2235
+
2236
+ # Trigger single redraw
2237
+ if sorted.length > 0
2238
+ app = App.current
2239
+ if app != nil
2240
+ app.post_update(sorted[0])
2241
+ end
2242
+ end
2243
+ end
2244
+ end
2245
+
2246
+ private
2247
+
2248
+ # Insertion sort by widget depth (ascending)
2249
+ #: (Array components) -> Array
2250
+ def sort_by_depth(components)
2251
+ result = []
2252
+ i = 0
2253
+ while i < components.length
2254
+ result << components[i]
2255
+ i = i + 1
2256
+ end
2257
+ i = 1
2258
+ while i < result.length
2259
+ j = i
2260
+ while j > 0
2261
+ if result[j].get_depth < result[j - 1].get_depth
2262
+ tmp = result[j]
2263
+ result[j] = result[j - 1]
2264
+ result[j - 1] = tmp
2265
+ end
2266
+ j = j - 1
2267
+ end
2268
+ i = i + 1
2269
+ end
2270
+ result
2271
+ end
2272
+ end
2273
+
2274
+ # ===== Component =====
2275
+ # Now with cache() for widget reuse and BuildOwner integration
2276
+
2277
+ class Component < Layout
2278
+ def initialize
2279
+ super
2280
+ @width_policy = EXPANDING
2281
+ @height_policy = EXPANDING
2282
+ @child = nil
2283
+ @pending_rebuild = false
2284
+ @cache_data = [] # Array of [keys_array, widgets_array] pairs per cache() call
2285
+ @cache_counter = 0 # Reset each view() call
2286
+ end
2287
+
2288
+ # Helper: create State + auto-attach
2289
+ #: (untyped initial) -> State
2290
+ def state(initial)
2291
+ s = State.new(initial)
2292
+ s.attach(self)
2293
+ s
2294
+ end
2295
+
2296
+ # Subclass overrides: returns widget tree
2297
+ #: () -> untyped
2298
+ def view
2299
+ nil
2300
+ end
2301
+
2302
+ # Cache widget instances across view() rebuilds.
2303
+ # Returns an array of widgets, reusing existing ones for matching items.
2304
+ # Items are matched by == comparison.
2305
+ #
2306
+ # Usage in view():
2307
+ # widgets = cache(items) { |item| Text.new(item.label) }
2308
+ # i = 0
2309
+ # while i < widgets.length
2310
+ # embed(widgets[i])
2311
+ # i = i + 1
2312
+ # end
2313
+ #
2314
+ #: (Array items) -> Array
2315
+ def cache(items)
2316
+ slot = @cache_counter
2317
+ @cache_counter = @cache_counter + 1
2318
+
2319
+ # Get old cache for this slot
2320
+ old_keys = nil
2321
+ old_widgets = nil
2322
+ if slot < @cache_data.length
2323
+ entry = @cache_data[slot]
2324
+ if entry != nil
2325
+ old_keys = entry[0]
2326
+ old_widgets = entry[1]
2327
+ end
2328
+ end
2329
+
2330
+ new_keys = []
2331
+ new_widgets = []
2332
+
2333
+ i = 0
2334
+ while i < items.length
2335
+ item = items[i]
2336
+ # Look up in old cache by == comparison
2337
+ found = nil
2338
+ if old_keys != nil
2339
+ j = 0
2340
+ while j < old_keys.length
2341
+ if old_keys[j] != nil && old_keys[j] == item
2342
+ found = old_widgets[j]
2343
+ old_keys[j] = nil # Mark as used
2344
+ break
2345
+ end
2346
+ j = j + 1
2347
+ end
2348
+ end
2349
+
2350
+ if found != nil
2351
+ found.set_cached(true) # Safety: prevent do_unmount if somehow reached
2352
+ new_keys << item
2353
+ new_widgets << found
2354
+ else
2355
+ widget = yield(item)
2356
+ new_keys << item
2357
+ new_widgets << widget
2358
+ end
2359
+ i = i + 1
2360
+ end
2361
+
2362
+ # Old widgets not reused will be detached when old tree is destroyed.
2363
+ # Clear cached flag so they can be properly cleaned up.
2364
+ if old_keys != nil
2365
+ j = 0
2366
+ while j < old_keys.length
2367
+ if old_keys[j] != nil
2368
+ old_widgets[j].set_cached(false)
2369
+ end
2370
+ j = j + 1
2371
+ end
2372
+ end
2373
+
2374
+ # Store updated cache
2375
+ while @cache_data.length <= slot
2376
+ @cache_data << nil
2377
+ end
2378
+ @cache_data[slot] = [new_keys, new_widgets]
2379
+
2380
+ new_widgets
2381
+ end
2382
+
2383
+ # Mark this component as needing rebuild (called by BuildOwner)
2384
+ #: () -> void
2385
+ def mark_pending_rebuild
2386
+ @pending_rebuild = true
2387
+ mark_paint_dirty
2388
+ end
2389
+
2390
+ # State change notification -> route to BuildOwner for batched rebuild
2391
+ #: () -> void
2392
+ def on_notify
2393
+ owner = BuildOwner.get
2394
+ owner.schedule_build_for(self)
2395
+ end
2396
+
2397
+ #: (untyped painter, bool completely) -> void
2398
+ def redraw(painter, completely)
2399
+ needs_build = false
2400
+ if @pending_rebuild
2401
+ @pending_rebuild = false
2402
+ needs_build = true
2403
+ end
2404
+ if @child == nil
2405
+ needs_build = true
2406
+ end
2407
+
2408
+ if needs_build
2409
+ # Reset cache counter for view()
2410
+ @cache_counter = 0
2411
+
2412
+ # Save focused widget's tab_index
2413
+ saved_focus_tab = -1
2414
+ app = App.current
2415
+ if app != nil
2416
+ focused = app.get_focused
2417
+ if focused != nil
2418
+ saved_focus_tab = focused.get_tab_index
2419
+ end
2420
+ end
2421
+
2422
+ # Build new tree FIRST (cache() may reuse widgets from old tree).
2423
+ # Reused widgets are removed from old tree by Layout#add() when
2424
+ # they are added to the new tree, so they won't be affected by
2425
+ # the subsequent old tree destruction.
2426
+ new_child = view
2427
+
2428
+ # Destroy old tree (cached widgets already removed from it)
2429
+ if @child != nil
2430
+ remove(@child)
2431
+ @child.detach
2432
+ @child = nil
2433
+ end
2434
+
2435
+ # Install new tree
2436
+ @child = new_child
2437
+ if @child != nil
2438
+ add(@child)
2439
+ completely = true
2440
+ end
2441
+
2442
+ # Restore focus (text restoration not needed — InputState persists)
2443
+ if saved_focus_tab > 0
2444
+ app = App.current
2445
+ if app != nil
2446
+ focus_target = find_focusable_by_tab_index(@child, saved_focus_tab)
2447
+ if focus_target != nil
2448
+ app.set_focused(focus_target)
2449
+ focus_target.restore_focus
2450
+ end
2451
+ end
2452
+ end
2453
+ end
2454
+
2455
+ # Relocate + redraw
2456
+ if @children.length > 0
2457
+ relocate_children(painter)
2458
+ redraw_children(painter, completely)
2459
+ end
2460
+ end
2461
+
2462
+ # Resize and position child to fill this Component
2463
+ #: (untyped painter) -> void
2464
+ def relocate_children(painter)
2465
+ if @children.length > 0
2466
+ c = @children[0]
2467
+ c.resize_wh(@width, @height)
2468
+ c.move_xy(@x, @y)
2469
+ end
2470
+ end
2471
+
2472
+ #: (untyped widget, Integer tab_index) -> untyped
2473
+ def find_focusable_by_tab_index(widget, tab_index)
2474
+ return nil if widget == nil
2475
+ if widget.is_focusable && widget.get_tab_index == tab_index
2476
+ return widget
2477
+ end
2478
+ children = widget.get_children
2479
+ i = 0
2480
+ while i < children.length
2481
+ result = find_focusable_by_tab_index(children[i], tab_index)
2482
+ if result != nil
2483
+ return result
2484
+ end
2485
+ i = i + 1
2486
+ end
2487
+ nil
2488
+ end
2489
+
2490
+ #: (untyped painter) -> Size
2491
+ def measure(painter)
2492
+ if @child == nil
2493
+ Size.new(0.0, 0.0)
2494
+ else
2495
+ @child.measure(painter)
2496
+ end
2497
+ end
2498
+ end
2499
+
2500
+ # ===== StatefulComponent =====
2501
+ # Shorthand: Component that auto-attaches to a State
2502
+
2503
+ class StatefulComponent < Component
2504
+ #: (State state) -> void
2505
+ def initialize(state)
2506
+ super()
2507
+ model(state)
2508
+ end
2509
+ end
2510
+
2511
+ end