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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +256 -0
- data/lib/kumiki/animation/animated_state.rb +83 -0
- data/lib/kumiki/animation/easing.rb +62 -0
- data/lib/kumiki/animation/value_tween.rb +69 -0
- data/lib/kumiki/app.rb +381 -0
- data/lib/kumiki/box.rb +40 -0
- data/lib/kumiki/chart/area_chart.rb +308 -0
- data/lib/kumiki/chart/bar_chart.rb +291 -0
- data/lib/kumiki/chart/base_chart.rb +213 -0
- data/lib/kumiki/chart/chart_helpers.rb +74 -0
- data/lib/kumiki/chart/gauge_chart.rb +174 -0
- data/lib/kumiki/chart/heatmap_chart.rb +223 -0
- data/lib/kumiki/chart/line_chart.rb +292 -0
- data/lib/kumiki/chart/pie_chart.rb +222 -0
- data/lib/kumiki/chart/scales.rb +79 -0
- data/lib/kumiki/chart/scatter_chart.rb +306 -0
- data/lib/kumiki/chart/stacked_bar_chart.rb +279 -0
- data/lib/kumiki/column.rb +351 -0
- data/lib/kumiki/core.rb +2511 -0
- data/lib/kumiki/dsl.rb +408 -0
- data/lib/kumiki/frame_ranma.rb +570 -0
- data/lib/kumiki/markdown/ast.rb +127 -0
- data/lib/kumiki/markdown/mermaid/layout.rb +389 -0
- data/lib/kumiki/markdown/mermaid/models.rb +235 -0
- data/lib/kumiki/markdown/mermaid/parser.rb +522 -0
- data/lib/kumiki/markdown/mermaid/renderer.rb +339 -0
- data/lib/kumiki/markdown/parser.rb +808 -0
- data/lib/kumiki/markdown/renderer.rb +642 -0
- data/lib/kumiki/markdown/theme.rb +168 -0
- data/lib/kumiki/render_node.rb +262 -0
- data/lib/kumiki/row.rb +288 -0
- data/lib/kumiki/spacer.rb +20 -0
- data/lib/kumiki/style.rb +799 -0
- data/lib/kumiki/theme.rb +567 -0
- data/lib/kumiki/themes/material.rb +40 -0
- data/lib/kumiki/themes/tokyo_night.rb +11 -0
- data/lib/kumiki/version.rb +5 -0
- data/lib/kumiki/widgets/button.rb +105 -0
- data/lib/kumiki/widgets/calendar.rb +1028 -0
- data/lib/kumiki/widgets/checkbox.rb +119 -0
- data/lib/kumiki/widgets/container.rb +111 -0
- data/lib/kumiki/widgets/data_table.rb +670 -0
- data/lib/kumiki/widgets/divider.rb +31 -0
- data/lib/kumiki/widgets/image.rb +105 -0
- data/lib/kumiki/widgets/input.rb +485 -0
- data/lib/kumiki/widgets/markdown.rb +58 -0
- data/lib/kumiki/widgets/modal.rb +165 -0
- data/lib/kumiki/widgets/multiline_input.rb +970 -0
- data/lib/kumiki/widgets/multiline_text.rb +180 -0
- data/lib/kumiki/widgets/net_image.rb +100 -0
- data/lib/kumiki/widgets/progress_bar.rb +72 -0
- data/lib/kumiki/widgets/radio_buttons.rb +93 -0
- data/lib/kumiki/widgets/slider.rb +135 -0
- data/lib/kumiki/widgets/switch.rb +84 -0
- data/lib/kumiki/widgets/tabs.rb +175 -0
- data/lib/kumiki/widgets/text.rb +120 -0
- data/lib/kumiki/widgets/tree.rb +434 -0
- data/lib/kumiki/widgets/webview.rb +87 -0
- data/lib/kumiki.rb +130 -0
- metadata +113 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
module Kumiki
|
|
2
|
+
# Tabs widget - tabbed container with header buttons
|
|
3
|
+
|
|
4
|
+
class Tabs < Layout
|
|
5
|
+
def initialize(labels, contents)
|
|
6
|
+
super()
|
|
7
|
+
@tab_labels = labels
|
|
8
|
+
@tab_contents = contents
|
|
9
|
+
@selected = 0
|
|
10
|
+
@tab_height = 36.0
|
|
11
|
+
@font_size_val = 13.0
|
|
12
|
+
@width_policy = EXPANDING
|
|
13
|
+
@height_policy = EXPANDING
|
|
14
|
+
@tab_widths = []
|
|
15
|
+
# Add initial content
|
|
16
|
+
if @tab_contents.length > 0
|
|
17
|
+
add(@tab_contents[0])
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def select_tab(index)
|
|
22
|
+
if index >= 0
|
|
23
|
+
if index < @tab_contents.length
|
|
24
|
+
if index != @selected
|
|
25
|
+
# Hide native overlay widgets (e.g. WebView) in the outgoing tab
|
|
26
|
+
_walk_native_widgets(@tab_contents[@selected]) { |w| w.on_tab_hide }
|
|
27
|
+
@selected = index
|
|
28
|
+
clear_children
|
|
29
|
+
add(@tab_contents[@selected])
|
|
30
|
+
# Show native overlay widgets in the incoming tab
|
|
31
|
+
_walk_native_widgets(@tab_contents[@selected]) { |w| w.on_tab_show }
|
|
32
|
+
mark_dirty
|
|
33
|
+
mark_layout_dirty
|
|
34
|
+
update
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def relocate_children(painter)
|
|
42
|
+
# Content area starts below tab header
|
|
43
|
+
content_y = @y + @tab_height
|
|
44
|
+
content_h = @height - @tab_height
|
|
45
|
+
if content_h < 0.0
|
|
46
|
+
content_h = 0.0
|
|
47
|
+
end
|
|
48
|
+
if @children.length > 0
|
|
49
|
+
c = @children[0]
|
|
50
|
+
c.move_xy(@x, content_y)
|
|
51
|
+
c.resize_wh(@width, content_h)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def measure(painter)
|
|
56
|
+
Size.new(@width, @height)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def redraw(painter, completely)
|
|
60
|
+
# 1) Layout + draw children first (redraw_children may clear the entire area)
|
|
61
|
+
relocate_children(painter)
|
|
62
|
+
redraw_children(painter, completely)
|
|
63
|
+
|
|
64
|
+
# 2) Draw tab bar ON TOP so it is not overwritten by background clear
|
|
65
|
+
draw_tab_bar(painter)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def draw_tab_bar(painter)
|
|
69
|
+
tab_bg = Kumiki.theme.bg_canvas
|
|
70
|
+
tab_active_bg = Kumiki.theme.bg_primary
|
|
71
|
+
tab_text_c = Kumiki.theme.text_secondary
|
|
72
|
+
tab_active_text = Kumiki.theme.text_primary
|
|
73
|
+
tab_border_c = Kumiki.theme.border
|
|
74
|
+
tab_indicator_c = Kumiki.theme.accent
|
|
75
|
+
|
|
76
|
+
# Tab bar background
|
|
77
|
+
painter.fill_rect(0.0, 0.0, @width, @tab_height, tab_bg)
|
|
78
|
+
|
|
79
|
+
# Calculate tab widths based on labels
|
|
80
|
+
@tab_widths = []
|
|
81
|
+
pad_h = 16.0
|
|
82
|
+
i = 0
|
|
83
|
+
while i < @tab_labels.length
|
|
84
|
+
tw = painter.measure_text_width(@tab_labels[i], Kumiki.theme.font_family, @font_size_val)
|
|
85
|
+
@tab_widths.push(tw + pad_h * 2.0)
|
|
86
|
+
i = i + 1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Draw each tab header
|
|
90
|
+
ascent = painter.get_text_ascent(Kumiki.theme.font_family, @font_size_val)
|
|
91
|
+
draw_tab_headers(painter, ascent, tab_active_bg, tab_active_text, tab_text_c, tab_indicator_c)
|
|
92
|
+
|
|
93
|
+
# Border line below tabs
|
|
94
|
+
painter.draw_line(0.0, @tab_height, @width, @tab_height, tab_border_c, 1.0)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def draw_tab_headers(painter, ascent, active_bg, active_tc, inactive_tc, indicator_c)
|
|
98
|
+
tab_x = 0.0
|
|
99
|
+
i = 0
|
|
100
|
+
while i < @tab_labels.length
|
|
101
|
+
tw = @tab_widths[i]
|
|
102
|
+
draw_one_tab(painter, i, tab_x, tw, ascent, active_bg, active_tc, inactive_tc, indicator_c)
|
|
103
|
+
tab_x = tab_x + tw
|
|
104
|
+
i = i + 1
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def draw_one_tab(painter, i, tab_x, tw, ascent, active_bg, active_tc, inactive_tc, indicator_c)
|
|
109
|
+
is_selected = (i == @selected)
|
|
110
|
+
|
|
111
|
+
# Tab background
|
|
112
|
+
if is_selected
|
|
113
|
+
painter.fill_rect(tab_x, 0.0, tw, @tab_height, active_bg)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Tab label
|
|
117
|
+
if is_selected
|
|
118
|
+
tc = active_tc
|
|
119
|
+
else
|
|
120
|
+
tc = inactive_tc
|
|
121
|
+
end
|
|
122
|
+
label_w = painter.measure_text_width(@tab_labels[i], Kumiki.theme.font_family, @font_size_val)
|
|
123
|
+
text_x = tab_x + (tw - label_w) / 2.0
|
|
124
|
+
text_y = (@tab_height - painter.measure_text_height(Kumiki.theme.font_family, @font_size_val)) / 2.0 + ascent
|
|
125
|
+
painter.draw_text(@tab_labels[i], text_x, text_y, Kumiki.theme.font_family, @font_size_val, tc)
|
|
126
|
+
|
|
127
|
+
# Active indicator line at bottom
|
|
128
|
+
if is_selected
|
|
129
|
+
painter.fill_rect(tab_x, @tab_height - 2.0, tw, 2.0, indicator_c)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def mouse_down(ev)
|
|
134
|
+
# Check if click is in the tab header area
|
|
135
|
+
click_y = ev.pos.y
|
|
136
|
+
if click_y < @tab_height
|
|
137
|
+
find_clicked_tab(ev.pos.x)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def find_clicked_tab(click_x)
|
|
142
|
+
tab_x = 0.0
|
|
143
|
+
i = 0
|
|
144
|
+
while i < @tab_widths.length
|
|
145
|
+
tw = @tab_widths[i]
|
|
146
|
+
if click_x >= tab_x
|
|
147
|
+
if click_x < tab_x + tw
|
|
148
|
+
select_tab(i)
|
|
149
|
+
return
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
tab_x = tab_x + tw
|
|
153
|
+
i = i + 1
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
# Recursively walk a widget subtree, yielding widgets that respond to on_tab_hide/show.
|
|
160
|
+
def _walk_native_widgets(widget, &block)
|
|
161
|
+
return unless widget
|
|
162
|
+
block.call(widget) if widget.respond_to?(:on_tab_hide)
|
|
163
|
+
children = widget.instance_variable_get(:@children)
|
|
164
|
+
if children.is_a?(Array)
|
|
165
|
+
children.each { |c| _walk_native_widgets(c, &block) }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Top-level helper
|
|
171
|
+
def Tabs(labels, contents)
|
|
172
|
+
Tabs.new(labels, contents)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
module Kumiki
|
|
2
|
+
# Text widget - displays text
|
|
3
|
+
|
|
4
|
+
# Text alignment constants
|
|
5
|
+
TEXT_ALIGN_LEFT = 0
|
|
6
|
+
TEXT_ALIGN_CENTER = 1
|
|
7
|
+
TEXT_ALIGN_RIGHT = 2
|
|
8
|
+
|
|
9
|
+
class Text < Widget
|
|
10
|
+
def initialize(text)
|
|
11
|
+
super()
|
|
12
|
+
@text = text
|
|
13
|
+
@font_family_val = nil
|
|
14
|
+
@font_size_val = 14.0
|
|
15
|
+
@color_val = 0xFFC0CAF5
|
|
16
|
+
@custom_color = false
|
|
17
|
+
@kind_val = 0
|
|
18
|
+
@text_align = TEXT_ALIGN_LEFT
|
|
19
|
+
@font_weight = 0
|
|
20
|
+
@font_slant = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def font_size(s)
|
|
24
|
+
@font_size_val = s
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def font_family(f)
|
|
29
|
+
@font_family_val = f
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def resolved_font_family
|
|
34
|
+
if @font_family_val != nil
|
|
35
|
+
@font_family_val
|
|
36
|
+
else
|
|
37
|
+
Kumiki.theme.font_family
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def bold
|
|
42
|
+
@font_weight = 1
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def italic
|
|
47
|
+
@font_slant = 1
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def color(c)
|
|
52
|
+
@color_val = c
|
|
53
|
+
@custom_color = true
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def kind(k)
|
|
58
|
+
@kind_val = k
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def align(a)
|
|
63
|
+
@text_align = a
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def set_text(t)
|
|
68
|
+
@text = t
|
|
69
|
+
mark_dirty
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def get_text
|
|
73
|
+
@text
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def measure(painter)
|
|
77
|
+
ff = resolved_font_family
|
|
78
|
+
w = painter.measure_text_width(@text, ff, @font_size_val)
|
|
79
|
+
h = painter.measure_text_height(ff, @font_size_val)
|
|
80
|
+
Size.new(w, h)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def redraw(painter, completely)
|
|
84
|
+
ff = resolved_font_family
|
|
85
|
+
ascent = painter.get_text_ascent(ff, @font_size_val)
|
|
86
|
+
th = painter.measure_text_height(ff, @font_size_val)
|
|
87
|
+
|
|
88
|
+
# Vertical centering when widget is taller than text
|
|
89
|
+
if @height > th
|
|
90
|
+
y_offset = (@height - th) / 2.0 + ascent
|
|
91
|
+
else
|
|
92
|
+
y_offset = ascent
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Horizontal alignment
|
|
96
|
+
x_offset = 0.0
|
|
97
|
+
if @text_align == TEXT_ALIGN_CENTER
|
|
98
|
+
text_w = painter.measure_text_width(@text, ff, @font_size_val)
|
|
99
|
+
x_offset = (@width - text_w) / 2.0
|
|
100
|
+
if x_offset < 0.0
|
|
101
|
+
x_offset = 0.0
|
|
102
|
+
end
|
|
103
|
+
elsif @text_align == TEXT_ALIGN_RIGHT
|
|
104
|
+
text_w = painter.measure_text_width(@text, ff, @font_size_val)
|
|
105
|
+
x_offset = @width - text_w
|
|
106
|
+
if x_offset < 0.0
|
|
107
|
+
x_offset = 0.0
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
c = @custom_color ? @color_val : Kumiki.theme.text_color_for_kind(@kind_val)
|
|
111
|
+
painter.draw_text(@text, x_offset, y_offset, ff, @font_size_val, c, @font_weight, @font_slant)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Top-level helper
|
|
116
|
+
def Text(text)
|
|
117
|
+
Text.new(text)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
end
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
module Kumiki
|
|
2
|
+
# Tree - expandable tree view widget with virtual scroll
|
|
3
|
+
# Features: expand/collapse, selection, hover highlight, icons,
|
|
4
|
+
# virtual scroll (only visible rows rendered)
|
|
5
|
+
|
|
6
|
+
TREE_ROW_HEIGHT = 26.0
|
|
7
|
+
TREE_INDENT = 20.0
|
|
8
|
+
TREE_ICON_SIZE = 16.0
|
|
9
|
+
TREE_TOGGLE_SIZE = 16.0
|
|
10
|
+
TREE_SCROLLBAR_WIDTH = 8.0
|
|
11
|
+
|
|
12
|
+
# TreeNode - data model for tree items
|
|
13
|
+
class TreeNode
|
|
14
|
+
def initialize(id, label)
|
|
15
|
+
@id = id
|
|
16
|
+
@label = label
|
|
17
|
+
@children = []
|
|
18
|
+
@icon = nil
|
|
19
|
+
@data = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def id
|
|
23
|
+
@id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def label
|
|
27
|
+
@label
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def children
|
|
31
|
+
@children
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def icon
|
|
35
|
+
@icon
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def data
|
|
39
|
+
@data
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def set_icon(i)
|
|
43
|
+
@icon = i
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def set_data(d)
|
|
48
|
+
@data = d
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def add_child(child)
|
|
53
|
+
@children << child
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def has_children
|
|
58
|
+
@children.length > 0
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# TreeState - reactive state for tree
|
|
63
|
+
class TreeState < ObservableBase
|
|
64
|
+
def initialize(nodes)
|
|
65
|
+
super()
|
|
66
|
+
@nodes = nodes # Array of TreeNode (root nodes)
|
|
67
|
+
@expanded_ids = [] # Array of expanded node IDs
|
|
68
|
+
@selected_id = "none"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def nodes
|
|
72
|
+
@nodes
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def selected_id
|
|
76
|
+
@selected_id
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def is_expanded(id)
|
|
80
|
+
i = 0
|
|
81
|
+
while i < @expanded_ids.length
|
|
82
|
+
if @expanded_ids[i] == id
|
|
83
|
+
return true
|
|
84
|
+
end
|
|
85
|
+
i = i + 1
|
|
86
|
+
end
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def toggle_expanded(id)
|
|
91
|
+
if is_expanded(id)
|
|
92
|
+
collapse(id)
|
|
93
|
+
else
|
|
94
|
+
expand(id)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def expand(id)
|
|
99
|
+
if is_expanded(id)
|
|
100
|
+
return
|
|
101
|
+
end
|
|
102
|
+
@expanded_ids << id
|
|
103
|
+
notify_observers
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def collapse(id)
|
|
107
|
+
new_ids = []
|
|
108
|
+
i = 0
|
|
109
|
+
while i < @expanded_ids.length
|
|
110
|
+
if @expanded_ids[i] != id
|
|
111
|
+
new_ids << @expanded_ids[i]
|
|
112
|
+
end
|
|
113
|
+
i = i + 1
|
|
114
|
+
end
|
|
115
|
+
@expanded_ids = new_ids
|
|
116
|
+
notify_observers
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def expand_all
|
|
120
|
+
collect_all_ids(@nodes)
|
|
121
|
+
notify_observers
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def collapse_all
|
|
125
|
+
@expanded_ids = []
|
|
126
|
+
notify_observers
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def select(id)
|
|
130
|
+
@selected_id = id
|
|
131
|
+
notify_observers
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def set_nodes(n)
|
|
135
|
+
@nodes = n
|
|
136
|
+
@expanded_ids = []
|
|
137
|
+
@selected_id = "none"
|
|
138
|
+
notify_observers
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def collect_all_ids(nodes)
|
|
144
|
+
i = 0
|
|
145
|
+
while i < nodes.length
|
|
146
|
+
node = nodes[i]
|
|
147
|
+
if node.has_children
|
|
148
|
+
@expanded_ids << node.id
|
|
149
|
+
collect_all_ids(node.children)
|
|
150
|
+
end
|
|
151
|
+
i = i + 1
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Tree widget - custom drawing with virtual scroll
|
|
157
|
+
class Tree < Widget
|
|
158
|
+
def initialize(state)
|
|
159
|
+
super()
|
|
160
|
+
@state = state
|
|
161
|
+
@scroll_y = 0.0
|
|
162
|
+
@max_scroll = 0.0
|
|
163
|
+
@scrollable_flag = true
|
|
164
|
+
@hover_row = -1
|
|
165
|
+
@visible_nodes = [] # Flat list of [node, depth] pairs for rendering
|
|
166
|
+
@width_policy = EXPANDING
|
|
167
|
+
@height_policy = EXPANDING
|
|
168
|
+
@state.attach(self)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def on_attach(observable)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def on_detach(observable)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def on_notify
|
|
178
|
+
rebuild_visible
|
|
179
|
+
mark_dirty
|
|
180
|
+
update
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def measure(painter)
|
|
184
|
+
rebuild_visible
|
|
185
|
+
h = @visible_nodes.length * 1.0 * TREE_ROW_HEIGHT
|
|
186
|
+
Size.new(@width, h)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def get_scrollable
|
|
190
|
+
@scrollable_flag
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def rebuild_visible
|
|
194
|
+
@visible_nodes = []
|
|
195
|
+
collect_visible(@state.nodes, 0)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def collect_visible(nodes, depth)
|
|
199
|
+
i = 0
|
|
200
|
+
while i < nodes.length
|
|
201
|
+
node = nodes[i]
|
|
202
|
+
@visible_nodes << [node, depth]
|
|
203
|
+
if node.has_children
|
|
204
|
+
if @state.is_expanded(node.id)
|
|
205
|
+
collect_visible(node.children, depth + 1)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
i = i + 1
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def redraw(painter, completely)
|
|
213
|
+
rebuild_visible
|
|
214
|
+
visible_h = @height
|
|
215
|
+
compute_tree_scroll(visible_h)
|
|
216
|
+
|
|
217
|
+
# Background
|
|
218
|
+
painter.fill_rect(0.0, 0.0, @width, @height, Kumiki.theme.bg_primary)
|
|
219
|
+
|
|
220
|
+
# Clip and draw visible rows
|
|
221
|
+
painter.save
|
|
222
|
+
painter.clip_rect(0.0, 0.0, @width, @height)
|
|
223
|
+
|
|
224
|
+
first_row = tree_float_to_row(@scroll_y / TREE_ROW_HEIGHT)
|
|
225
|
+
if first_row < 0
|
|
226
|
+
first_row = 0
|
|
227
|
+
end
|
|
228
|
+
last_row = tree_float_to_row((@scroll_y + visible_h) / TREE_ROW_HEIGHT) + 1
|
|
229
|
+
if last_row > @visible_nodes.length
|
|
230
|
+
last_row = @visible_nodes.length
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
ri = first_row
|
|
234
|
+
while ri < last_row
|
|
235
|
+
draw_tree_row(painter, ri)
|
|
236
|
+
ri = ri + 1
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
painter.restore
|
|
240
|
+
|
|
241
|
+
# Scrollbar
|
|
242
|
+
draw_tree_scrollbar(painter, visible_h)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def draw_tree_row(painter, ri)
|
|
246
|
+
entry = @visible_nodes[ri]
|
|
247
|
+
node = entry[0]
|
|
248
|
+
depth = entry[1]
|
|
249
|
+
ri_f = ri * 1.0
|
|
250
|
+
row_y = ri_f * TREE_ROW_HEIGHT - @scroll_y
|
|
251
|
+
|
|
252
|
+
# Background: selection > hover > alternating
|
|
253
|
+
bg = compute_tree_row_bg(painter, ri, node)
|
|
254
|
+
painter.fill_rect(0.0, row_y, @width, TREE_ROW_HEIGHT, bg)
|
|
255
|
+
|
|
256
|
+
# Indent
|
|
257
|
+
indent = depth * 1.0 * TREE_INDENT + 8.0
|
|
258
|
+
|
|
259
|
+
# Toggle icon (expand/collapse arrow)
|
|
260
|
+
if node.has_children
|
|
261
|
+
draw_toggle(painter, indent, row_y, node)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Label
|
|
265
|
+
label_x = indent + TREE_TOGGLE_SIZE + 4.0
|
|
266
|
+
ascent = painter.get_text_ascent(Kumiki.theme.font_family, 13.0)
|
|
267
|
+
mh = painter.measure_text_height(Kumiki.theme.font_family, 13.0)
|
|
268
|
+
label_y = row_y + (TREE_ROW_HEIGHT - mh) / 2.0 + ascent
|
|
269
|
+
tc = Kumiki.theme.text_primary
|
|
270
|
+
painter.draw_text(node.label, label_x, label_y, Kumiki.theme.font_family, 13.0, tc)
|
|
271
|
+
|
|
272
|
+
# Bottom border
|
|
273
|
+
bc = painter.with_alpha(Kumiki.theme.border, 30)
|
|
274
|
+
painter.draw_line(0.0, row_y + TREE_ROW_HEIGHT, @width, row_y + TREE_ROW_HEIGHT, bc, 1.0)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def draw_toggle(painter, indent, row_y, node)
|
|
278
|
+
# Draw a small triangle: > for collapsed, v for expanded
|
|
279
|
+
tx = indent + TREE_TOGGLE_SIZE / 2.0
|
|
280
|
+
ty = row_y + TREE_ROW_HEIGHT / 2.0
|
|
281
|
+
tc = Kumiki.theme.text_secondary
|
|
282
|
+
s = 5.0
|
|
283
|
+
if @state.is_expanded(node.id)
|
|
284
|
+
# Down arrow (v)
|
|
285
|
+
painter.fill_triangle(tx - s, ty - s / 2.0,
|
|
286
|
+
tx + s, ty - s / 2.0,
|
|
287
|
+
tx, ty + s / 2.0, tc)
|
|
288
|
+
else
|
|
289
|
+
# Right arrow (>)
|
|
290
|
+
painter.fill_triangle(tx - s / 2.0, ty - s,
|
|
291
|
+
tx + s / 2.0, ty,
|
|
292
|
+
tx - s / 2.0, ty + s, tc)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def compute_tree_row_bg(painter, ri, node)
|
|
297
|
+
bg = Kumiki.theme.bg_primary
|
|
298
|
+
if node.id == @state.selected_id
|
|
299
|
+
ac = Kumiki.theme.accent
|
|
300
|
+
bg = painter.with_alpha(ac, 50)
|
|
301
|
+
elsif ri == @hover_row
|
|
302
|
+
bg = painter.lighten_color(bg, 0.08)
|
|
303
|
+
end
|
|
304
|
+
bg
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def draw_tree_scrollbar(painter, visible_h)
|
|
308
|
+
if @max_scroll <= 0.0
|
|
309
|
+
return
|
|
310
|
+
end
|
|
311
|
+
vn_len = @visible_nodes.length * 1.0
|
|
312
|
+
content_h = vn_len * TREE_ROW_HEIGHT
|
|
313
|
+
sb_x = @width - TREE_SCROLLBAR_WIDTH
|
|
314
|
+
sb_ratio = visible_h / content_h
|
|
315
|
+
sb_h = visible_h * sb_ratio
|
|
316
|
+
if sb_h < 20.0
|
|
317
|
+
sb_h = 20.0
|
|
318
|
+
end
|
|
319
|
+
sb_travel = visible_h - sb_h
|
|
320
|
+
sb_pos = 0.0
|
|
321
|
+
if @max_scroll > 0.0
|
|
322
|
+
sb_pos = (@scroll_y / @max_scroll) * sb_travel
|
|
323
|
+
end
|
|
324
|
+
painter.fill_rect(sb_x, 0.0, TREE_SCROLLBAR_WIDTH, visible_h, Kumiki.theme.scrollbar_bg)
|
|
325
|
+
painter.fill_round_rect(sb_x + 1.0, sb_pos, TREE_SCROLLBAR_WIDTH - 2.0, sb_h, 3.0, Kumiki.theme.scrollbar_fg)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# --- Event Handlers ---
|
|
329
|
+
|
|
330
|
+
def mouse_up(ev)
|
|
331
|
+
mx = ev.pos.x
|
|
332
|
+
my = ev.pos.y
|
|
333
|
+
row_idx = tree_row_at_y(my)
|
|
334
|
+
if row_idx < 0
|
|
335
|
+
return
|
|
336
|
+
end
|
|
337
|
+
if row_idx >= @visible_nodes.length
|
|
338
|
+
return
|
|
339
|
+
end
|
|
340
|
+
entry = @visible_nodes[row_idx]
|
|
341
|
+
node = entry[0]
|
|
342
|
+
depth = entry[1]
|
|
343
|
+
indent = depth * 1.0 * TREE_INDENT + 8.0
|
|
344
|
+
toggle_end = indent + TREE_TOGGLE_SIZE
|
|
345
|
+
if mx < toggle_end
|
|
346
|
+
if node.has_children
|
|
347
|
+
@state.toggle_expanded(node.id)
|
|
348
|
+
return
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
@state.select(node.id)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def cursor_pos(ev)
|
|
355
|
+
my = ev.pos.y
|
|
356
|
+
old_hr = @hover_row
|
|
357
|
+
@hover_row = tree_row_at_y(my)
|
|
358
|
+
if @hover_row != old_hr
|
|
359
|
+
mark_dirty
|
|
360
|
+
update
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def mouse_out
|
|
365
|
+
if @hover_row != -1
|
|
366
|
+
@hover_row = -1
|
|
367
|
+
mark_dirty
|
|
368
|
+
update
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def dispatch_to_scrollable(p, is_direction_x)
|
|
373
|
+
if contain(p)
|
|
374
|
+
[self, p]
|
|
375
|
+
else
|
|
376
|
+
[nil, nil]
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def mouse_wheel(ev)
|
|
381
|
+
@scroll_y = @scroll_y - ev.delta_y * 30.0
|
|
382
|
+
if @scroll_y < 0.0
|
|
383
|
+
@scroll_y = 0.0
|
|
384
|
+
end
|
|
385
|
+
if @scroll_y > @max_scroll
|
|
386
|
+
@scroll_y = @max_scroll
|
|
387
|
+
end
|
|
388
|
+
mark_dirty
|
|
389
|
+
update
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
private
|
|
393
|
+
|
|
394
|
+
def compute_tree_scroll(visible_h)
|
|
395
|
+
vn_len = @visible_nodes.length * 1.0
|
|
396
|
+
content_h = vn_len * TREE_ROW_HEIGHT
|
|
397
|
+
@max_scroll = content_h - visible_h
|
|
398
|
+
if @max_scroll < 0.0
|
|
399
|
+
@max_scroll = 0.0
|
|
400
|
+
end
|
|
401
|
+
if @scroll_y > @max_scroll
|
|
402
|
+
@scroll_y = @max_scroll
|
|
403
|
+
end
|
|
404
|
+
if @scroll_y < 0.0
|
|
405
|
+
@scroll_y = 0.0
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def tree_row_at_y(y)
|
|
410
|
+
row = tree_float_to_row((y + @scroll_y) / TREE_ROW_HEIGHT)
|
|
411
|
+
if row < 0
|
|
412
|
+
row = -1
|
|
413
|
+
end
|
|
414
|
+
if row >= @visible_nodes.length
|
|
415
|
+
row = -1
|
|
416
|
+
end
|
|
417
|
+
row
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def tree_float_to_row(f)
|
|
421
|
+
r = 0
|
|
422
|
+
while r * 1.0 + 1.0 <= f
|
|
423
|
+
r = r + 1
|
|
424
|
+
end
|
|
425
|
+
r
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Top-level helper
|
|
430
|
+
def Tree(state)
|
|
431
|
+
Tree.new(state)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
end
|