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,570 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# RanmaFrame + RanmaPainter — window and rendering backend for kumiki
|
|
4
|
+
#
|
|
5
|
+
# Separates Frame (window/events) from Painter (drawing).
|
|
6
|
+
# RanmaFrame#_do_redraw passes the RanmaPainter instance to the on_redraw callback.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# require "kumiki/frame_ranma"
|
|
10
|
+
# frame = Kumiki::RanmaFrame.new("My App", 800, 600)
|
|
11
|
+
# app = Kumiki::App.new(frame, widget)
|
|
12
|
+
# app.run
|
|
13
|
+
|
|
14
|
+
begin
|
|
15
|
+
require "ranma"
|
|
16
|
+
rescue LoadError => e
|
|
17
|
+
raise LoadError, "kumiki/frame_ranma requires the 'ranma' gem: #{e.message}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module Kumiki
|
|
21
|
+
# Key ordinals used by kumiki widgets (via RANMA_KEY_MAP)
|
|
22
|
+
# ENTER=11, BACKSPACE=12, TAB=13, ESCAPE=17, END=21, HOME=22
|
|
23
|
+
# LEFT=23, UP=24, RIGHT=25, DOWN=26, DELETE=75
|
|
24
|
+
# A=43, C=45, V=64, X=66
|
|
25
|
+
RANMA_KEY_MAP = {
|
|
26
|
+
enter: 11, backspace: 12, tab: 13, escape: 17,
|
|
27
|
+
end: 21, home: 22, left: 23, up: 24, right: 25, down: 26,
|
|
28
|
+
delete: 75, a: 43, c: 45, v: 64, x: 66,
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# ─── Net image download cache (shared across all RanmaPainter instances) ───
|
|
32
|
+
require 'tmpdir'
|
|
33
|
+
NET_IMG_CACHE = {}
|
|
34
|
+
NET_IMG_MUTEX = Mutex.new
|
|
35
|
+
NET_IMG_DIR = File.join(Dir.tmpdir, "kumiki_net_#{Process.pid}")
|
|
36
|
+
NET_IMG_HAS_NEW = [false] # set true by download thread; cleared in _do_redraw
|
|
37
|
+
|
|
38
|
+
# ─── Painter ──────────────────────────────────────────────────────────────
|
|
39
|
+
# Wraps Ranma::Painter and implements kumiki's painter protocol.
|
|
40
|
+
|
|
41
|
+
class RanmaPainter
|
|
42
|
+
def initialize(surface)
|
|
43
|
+
@inner = Ranma::Painter.new(surface)
|
|
44
|
+
# image cache: path -> integer ID
|
|
45
|
+
@image_store = {}
|
|
46
|
+
@image_path_to_id = {}
|
|
47
|
+
@next_image_id = 1
|
|
48
|
+
# font metrics cache: "family_size" -> RbPainterFontMetrics
|
|
49
|
+
@metrics_cache = {}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# --- Canvas state ---
|
|
53
|
+
|
|
54
|
+
def save = @inner.save
|
|
55
|
+
def restore = @inner.restore
|
|
56
|
+
def translate(dx, dy) = @inner.translate(dx.to_f, dy.to_f)
|
|
57
|
+
def scale(sx, sy) = @inner.scale(sx.to_f, sy.to_f)
|
|
58
|
+
def clip_rect(x, y, w, h) = @inner.clip(x.to_f, y.to_f, w.to_f, h.to_f)
|
|
59
|
+
|
|
60
|
+
def flush = @inner.flush
|
|
61
|
+
|
|
62
|
+
# --- Drawing primitives (colors are 0xAARRGGBB integers) ---
|
|
63
|
+
|
|
64
|
+
def clear(color)
|
|
65
|
+
@inner.clear_all(int_to_hex(color))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def fill_rect(x, y, w, h, color)
|
|
69
|
+
@inner.style(Ranma::PainterStyle.new(fill_color: int_to_hex(color)))
|
|
70
|
+
@inner.fill_rect(x.to_f, y.to_f, w.to_f, h.to_f)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def stroke_rect(x, y, w, h, color, sw)
|
|
74
|
+
@inner.style(Ranma::PainterStyle.new(stroke_color: int_to_hex(color), stroke_width: sw.to_f))
|
|
75
|
+
@inner.stroke_rect(x.to_f, y.to_f, w.to_f, h.to_f)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def fill_round_rect(x, y, w, h, r, color)
|
|
79
|
+
@inner.style(Ranma::PainterStyle.new(fill_color: int_to_hex(color), border_radius: r.to_f))
|
|
80
|
+
@inner.fill_rect(x.to_f, y.to_f, w.to_f, h.to_f)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def stroke_round_rect(x, y, w, h, r, color, sw)
|
|
84
|
+
@inner.style(Ranma::PainterStyle.new(
|
|
85
|
+
stroke_color: int_to_hex(color), stroke_width: sw.to_f, border_radius: r.to_f
|
|
86
|
+
))
|
|
87
|
+
@inner.stroke_rect(x.to_f, y.to_f, w.to_f, h.to_f)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def fill_circle(cx, cy, r, color)
|
|
91
|
+
@inner.style(Ranma::PainterStyle.new(fill_color: int_to_hex(color)))
|
|
92
|
+
@inner.fill_circle(cx.to_f, cy.to_f, r.to_f)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def stroke_circle(cx, cy, r, color, sw)
|
|
96
|
+
@inner.style(Ranma::PainterStyle.new(stroke_color: int_to_hex(color), stroke_width: sw.to_f))
|
|
97
|
+
@inner.stroke_circle(cx.to_f, cy.to_f, r.to_f)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def draw_line(x1, y1, x2, y2, color, w)
|
|
101
|
+
@inner.style(Ranma::PainterStyle.new(stroke_color: int_to_hex(color), stroke_width: w.to_f))
|
|
102
|
+
@inner.draw_line(x1.to_f, y1.to_f, x2.to_f, y2.to_f)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def fill_arc(cx, cy, r, start_angle, sweep_angle, color)
|
|
106
|
+
@inner.style(Ranma::PainterStyle.new(fill_color: int_to_hex(color)))
|
|
107
|
+
@inner.fill_arc(cx.to_f, cy.to_f, r.to_f, start_angle.to_f, sweep_angle.to_f)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def stroke_arc(cx, cy, r, start_angle, sweep_angle, color, sw)
|
|
111
|
+
@inner.style(Ranma::PainterStyle.new(stroke_color: int_to_hex(color), stroke_width: sw.to_f))
|
|
112
|
+
@inner.stroke_arc(cx.to_f, cy.to_f, r.to_f, start_angle.to_f, sweep_angle.to_f)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def draw_polyline(x1, y1, x2, y2, color, sw, _dummy)
|
|
116
|
+
draw_line(x1, y1, x2, y2, color, sw)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def fill_triangle(x1, y1, x2, y2, x3, y3, color)
|
|
120
|
+
@inner.style(Ranma::PainterStyle.new(fill_color: int_to_hex(color)))
|
|
121
|
+
@inner.fill_triangle(x1.to_f, y1.to_f, x2.to_f, y2.to_f, x3.to_f, y3.to_f)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# --- Text drawing ---
|
|
125
|
+
# y is the baseline position (Skia convention). ranma uses top, so subtract ascent.
|
|
126
|
+
|
|
127
|
+
def draw_text(text, x, y, font_family, font_size, color, *_extra)
|
|
128
|
+
opts = { fill_color: int_to_hex(color), font_size: font_size.to_f }
|
|
129
|
+
f = ranma_font(font_family)
|
|
130
|
+
opts[:font_family] = f if f
|
|
131
|
+
@inner.style(Ranma::PainterStyle.new(**opts))
|
|
132
|
+
top_y = y.to_f - get_ascent(font_family, font_size)
|
|
133
|
+
@inner.fill_text(text.to_s, x.to_f, top_y, nil)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# --- Text measurement ---
|
|
137
|
+
|
|
138
|
+
def measure_text_width(text, font_family, font_size)
|
|
139
|
+
@inner.measure_text_with_font(text.to_s, ranma_font(font_family) || "", font_size.to_f)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def measure_text_height(font_family, font_size)
|
|
143
|
+
cached_metrics(font_family, font_size).height
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def get_text_ascent(font_family, font_size)
|
|
147
|
+
get_ascent(font_family, font_size)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# --- Path operations ---
|
|
151
|
+
|
|
152
|
+
def begin_path = @inner.begin_path
|
|
153
|
+
def path_move_to(x, y) = @inner.path_move_to(x.to_f, y.to_f)
|
|
154
|
+
def path_line_to(x, y) = @inner.path_line_to(x.to_f, y.to_f)
|
|
155
|
+
|
|
156
|
+
def close_fill_path(color)
|
|
157
|
+
@inner.style(Ranma::PainterStyle.new(fill_color: int_to_hex(color)))
|
|
158
|
+
@inner.close_fill_path
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def fill_path(color)
|
|
162
|
+
@inner.style(Ranma::PainterStyle.new(fill_color: int_to_hex(color)))
|
|
163
|
+
@inner.fill_path
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# --- Image operations ---
|
|
167
|
+
|
|
168
|
+
def load_image(path)
|
|
169
|
+
return @image_path_to_id[path] if @image_path_to_id.key?(path)
|
|
170
|
+
id = @next_image_id
|
|
171
|
+
@next_image_id += 1
|
|
172
|
+
@image_store[id] = path
|
|
173
|
+
@image_path_to_id[path] = id
|
|
174
|
+
id
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def load_net_image(url)
|
|
178
|
+
return @image_path_to_id[url] if @image_path_to_id.key?(url)
|
|
179
|
+
|
|
180
|
+
status = NET_IMG_MUTEX.synchronize { NET_IMG_CACHE[url] }
|
|
181
|
+
case status
|
|
182
|
+
when String # download complete — register with painter
|
|
183
|
+
id = @next_image_id; @next_image_id += 1
|
|
184
|
+
@image_store[id] = status; @image_path_to_id[url] = id
|
|
185
|
+
return id
|
|
186
|
+
when :pending, :failed
|
|
187
|
+
return 0
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# First request: kick off background download
|
|
191
|
+
NET_IMG_MUTEX.synchronize { NET_IMG_CACHE[url] = :pending }
|
|
192
|
+
painter = self
|
|
193
|
+
Thread.new do
|
|
194
|
+
begin
|
|
195
|
+
painter.send(:_download_net_image, url)
|
|
196
|
+
rescue Exception => e
|
|
197
|
+
$stderr.puts "NetImage thread error: #{e.class}: #{e}"
|
|
198
|
+
NET_IMG_MUTEX.synchronize { NET_IMG_CACHE[url] = :failed }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
0
|
|
202
|
+
rescue => e
|
|
203
|
+
$stderr.puts "NetImage load error: #{e}"
|
|
204
|
+
0
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def draw_image(image_id, x, y, w, h)
|
|
208
|
+
path = @image_store[image_id]
|
|
209
|
+
return unless path
|
|
210
|
+
begin
|
|
211
|
+
@inner.draw_image(path, x.to_f, y.to_f, w.to_f, h.to_f)
|
|
212
|
+
rescue; end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def get_image_width(image_id)
|
|
216
|
+
path = @image_store[image_id]
|
|
217
|
+
return 0 unless path
|
|
218
|
+
begin; @inner.measure_image(path)[0]; rescue; 0; end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def get_image_height(image_id)
|
|
222
|
+
path = @image_store[image_id]
|
|
223
|
+
return 0 unless path
|
|
224
|
+
begin; @inner.measure_image(path)[1]; rescue; 0; end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# --- Color utilities (0xAARRGGBB) ---
|
|
228
|
+
|
|
229
|
+
def interpolate_color(c1, c2, t)
|
|
230
|
+
a1, r1, g1, b1 = int_to_argb(c1)
|
|
231
|
+
a2, r2, g2, b2 = int_to_argb(c2)
|
|
232
|
+
argb_to_int(
|
|
233
|
+
lerp(a1, a2, t), lerp(r1, r2, t), lerp(g1, g2, t), lerp(b1, b2, t)
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def with_alpha(color, alpha)
|
|
238
|
+
_a, r, g, b = int_to_argb(color)
|
|
239
|
+
argb_to_int((alpha * 255).to_i.clamp(0, 255), r, g, b)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def lighten_color(color, amount)
|
|
243
|
+
a, r, g, b = int_to_argb(color)
|
|
244
|
+
amt = (amount * 255).to_i
|
|
245
|
+
argb_to_int(a, (r + amt).clamp(0, 255), (g + amt).clamp(0, 255), (b + amt).clamp(0, 255))
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def darken_color(color, amount)
|
|
249
|
+
a, r, g, b = int_to_argb(color)
|
|
250
|
+
amt = (amount * 255).to_i
|
|
251
|
+
argb_to_int(a, (r - amt).clamp(0, 255), (g - amt).clamp(0, 255), (b - amt).clamp(0, 255))
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# --- Math / time (called on painter by kumiki App) ---
|
|
255
|
+
|
|
256
|
+
def math_cos(r) = Math.cos(r)
|
|
257
|
+
def math_sin(r) = Math.sin(r)
|
|
258
|
+
def math_sqrt(v) = Math.sqrt(v)
|
|
259
|
+
def math_atan2(y, x) = Math.atan2(y, x)
|
|
260
|
+
def math_abs(v) = v.abs
|
|
261
|
+
|
|
262
|
+
def current_time_millis
|
|
263
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def number_to_string(v) = v.to_s
|
|
267
|
+
|
|
268
|
+
# --- Sub-painter support (vello scene caching) ---
|
|
269
|
+
|
|
270
|
+
def supports_sub_painter? = true
|
|
271
|
+
|
|
272
|
+
def create_sub_painter
|
|
273
|
+
sub = RanmaPainter.allocate
|
|
274
|
+
sub.instance_variable_set(:@inner, @inner.create_sub_painter)
|
|
275
|
+
sub.instance_variable_set(:@image_store, @image_store)
|
|
276
|
+
sub.instance_variable_set(:@image_path_to_id, @image_path_to_id)
|
|
277
|
+
sub.instance_variable_set(:@next_image_id, @next_image_id)
|
|
278
|
+
sub.instance_variable_set(:@metrics_cache, @metrics_cache)
|
|
279
|
+
sub
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def append(sub_painter, x = 0.0, y = 0.0)
|
|
283
|
+
@inner.append(sub_painter.instance_variable_get(:@inner), x.to_f, y.to_f)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def reset
|
|
287
|
+
@inner.reset
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
private
|
|
291
|
+
|
|
292
|
+
def ranma_font(family)
|
|
293
|
+
return nil if family.nil? || family.empty? || family == "default"
|
|
294
|
+
family
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def get_ascent(font_family, font_size)
|
|
298
|
+
cached_metrics(font_family, font_size).ascent
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def cached_metrics(font_family, font_size)
|
|
302
|
+
key = "#{font_family}_#{font_size}"
|
|
303
|
+
@metrics_cache[key] ||= @inner.get_font_metrics_with_font(
|
|
304
|
+
ranma_font(font_family) || "", font_size.to_f
|
|
305
|
+
)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# 0xAARRGGBB -> "#rrggbbaa"
|
|
309
|
+
def int_to_hex(color)
|
|
310
|
+
a = (color >> 24) & 0xFF
|
|
311
|
+
r = (color >> 16) & 0xFF
|
|
312
|
+
g = (color >> 8) & 0xFF
|
|
313
|
+
b = color & 0xFF
|
|
314
|
+
"#%02x%02x%02x%02x" % [r, g, b, a]
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def int_to_argb(color)
|
|
318
|
+
[(color >> 24) & 0xFF, (color >> 16) & 0xFF, (color >> 8) & 0xFF, color & 0xFF]
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def argb_to_int(a, r, g, b)
|
|
322
|
+
((a & 0xFF) << 24) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def lerp(a, b, t)
|
|
326
|
+
(a + (b - a) * t).to_i.clamp(0, 255)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def _download_net_image(url)
|
|
330
|
+
require 'open-uri'; require 'fileutils'; require 'digest'; require 'uri'
|
|
331
|
+
FileUtils.mkdir_p(NET_IMG_DIR)
|
|
332
|
+
ext = File.extname(URI.parse(url).path).then { |e| e.empty? ? ".jpg" : e }
|
|
333
|
+
path = File.join(NET_IMG_DIR, "#{Digest::MD5.hexdigest(url)}#{ext}")
|
|
334
|
+
unless File.exist?(path)
|
|
335
|
+
URI.open(url, "rb") { |f| File.binwrite(path, f.read) }
|
|
336
|
+
end
|
|
337
|
+
NET_IMG_MUTEX.synchronize { NET_IMG_CACHE[url] = path; NET_IMG_HAS_NEW[0] = true }
|
|
338
|
+
Ranma::App.request_redraw
|
|
339
|
+
rescue => e
|
|
340
|
+
$stderr.puts "NetImage download failed for #{url}: #{e}"
|
|
341
|
+
NET_IMG_MUTEX.synchronize { NET_IMG_CACHE[url] = :failed }
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# ─── Frame ────────────────────────────────────────────────────────────────
|
|
346
|
+
# Creates the window via Ranma::App.start and translates events into kumiki callbacks.
|
|
347
|
+
# Passes a RanmaPainter instance (not self) to the on_redraw callback.
|
|
348
|
+
|
|
349
|
+
class RanmaFrame
|
|
350
|
+
def initialize(title, width, height)
|
|
351
|
+
@title = title
|
|
352
|
+
@width = width.to_i
|
|
353
|
+
@height = height.to_i
|
|
354
|
+
|
|
355
|
+
# event callbacks
|
|
356
|
+
@on_redraw = nil
|
|
357
|
+
@on_mouse_down = nil
|
|
358
|
+
@on_mouse_up = nil
|
|
359
|
+
@on_cursor_pos = nil
|
|
360
|
+
@on_mouse_wheel = nil
|
|
361
|
+
@on_input_char = nil
|
|
362
|
+
@on_input_key = nil
|
|
363
|
+
@on_resize = nil
|
|
364
|
+
@on_ime_preedit = nil
|
|
365
|
+
|
|
366
|
+
# runtime state
|
|
367
|
+
@window = nil
|
|
368
|
+
@surface = nil
|
|
369
|
+
@ranma_painter = nil # created in _update_painter
|
|
370
|
+
@hidpi_scale = 1.0
|
|
371
|
+
@size = Size.new(width.to_f, height.to_f)
|
|
372
|
+
|
|
373
|
+
@last_cursor_x = 0.0
|
|
374
|
+
@last_cursor_y = 0.0
|
|
375
|
+
@mods = 0 # modifier bitmask
|
|
376
|
+
@in_redraw = false
|
|
377
|
+
@skip_redraw_requested = false
|
|
378
|
+
@animation_pending = false
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
attr_reader :window
|
|
382
|
+
|
|
383
|
+
# --- Callback registration ---
|
|
384
|
+
|
|
385
|
+
def on_redraw(&block) = (@on_redraw = block)
|
|
386
|
+
def on_mouse_down(&block) = (@on_mouse_down = block)
|
|
387
|
+
def on_mouse_up(&block) = (@on_mouse_up = block)
|
|
388
|
+
def on_cursor_pos(&block) = (@on_cursor_pos = block)
|
|
389
|
+
def on_mouse_wheel(&block) = (@on_mouse_wheel = block)
|
|
390
|
+
def on_input_char(&block) = (@on_input_char = block)
|
|
391
|
+
def on_input_key(&block) = (@on_input_key = block)
|
|
392
|
+
def on_resize(&block) = (@on_resize = block)
|
|
393
|
+
def on_ime_preedit(&block) = (@on_ime_preedit = block)
|
|
394
|
+
|
|
395
|
+
# --- Frame queries ---
|
|
396
|
+
|
|
397
|
+
def get_painter = @ranma_painter
|
|
398
|
+
def get_size = @size
|
|
399
|
+
|
|
400
|
+
def is_dark_mode
|
|
401
|
+
Ranma::Theme.detect == :dark
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def post_update(_ev)
|
|
405
|
+
if @in_redraw
|
|
406
|
+
# Called from within on_redraw (e.g. animation tick) — schedule next frame.
|
|
407
|
+
# Set flag so :redraw_requested handler knows NOT to skip (animation is pending).
|
|
408
|
+
@animation_pending = true
|
|
409
|
+
@window&.request_redraw
|
|
410
|
+
else
|
|
411
|
+
# Called from event handler — render immediately for responsiveness
|
|
412
|
+
_do_redraw(false)
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# --- IME / text input ---
|
|
417
|
+
|
|
418
|
+
def enable_text_input = nil # IME always active
|
|
419
|
+
def disable_text_input = nil
|
|
420
|
+
|
|
421
|
+
def set_ime_cursor_rect(x, y, _w, _h)
|
|
422
|
+
return unless @window
|
|
423
|
+
begin
|
|
424
|
+
@window.set_ime_position(Ranma::LogicalPosition.new(x.to_f, y.to_f))
|
|
425
|
+
rescue; end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# --- Clipboard ---
|
|
429
|
+
|
|
430
|
+
def get_clipboard_text
|
|
431
|
+
Ranma::Clipboard.get_text
|
|
432
|
+
rescue
|
|
433
|
+
""
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def set_clipboard_text(text)
|
|
437
|
+
Ranma::Clipboard.set_text(text)
|
|
438
|
+
rescue
|
|
439
|
+
nil
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# --- Main run ---
|
|
443
|
+
|
|
444
|
+
def run
|
|
445
|
+
Ranma::App.start do
|
|
446
|
+
@window = Ranma::AppWindow.new(
|
|
447
|
+
title: @title,
|
|
448
|
+
inner_size: Ranma::LogicalSize.new(@width, @height)
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
@hidpi_scale = begin
|
|
452
|
+
@window.scale_factor
|
|
453
|
+
rescue
|
|
454
|
+
1.0
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
@surface = Ranma::GpuSurface.new(@window)
|
|
458
|
+
_update_painter # create initial RanmaPainter
|
|
459
|
+
|
|
460
|
+
phys_w = @surface.width
|
|
461
|
+
phys_h = @surface.height
|
|
462
|
+
@size = Size.new(phys_w.to_f / @hidpi_scale, phys_h.to_f / @hidpi_scale)
|
|
463
|
+
|
|
464
|
+
@window.setup_ime_preedit
|
|
465
|
+
@window.on_event { |event| _handle_event(event) }
|
|
466
|
+
@window.visible = true
|
|
467
|
+
# No request_redraw here: set_visible triggers :resized which renders
|
|
468
|
+
# synchronously, and the OS fires :redraw_requested right after (consumed by skip).
|
|
469
|
+
# An explicit request_redraw would create an extra :redraw_requested that could
|
|
470
|
+
# interfere with the skip timing and consume the animation loop's first frame.
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
private
|
|
475
|
+
|
|
476
|
+
def _update_painter
|
|
477
|
+
@ranma_painter = RanmaPainter.new(@surface)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def _handle_event(event)
|
|
481
|
+
case event[:type]
|
|
482
|
+
when :close_requested
|
|
483
|
+
Ranma::App.exit
|
|
484
|
+
|
|
485
|
+
when :resized
|
|
486
|
+
phys_w = event[:width]
|
|
487
|
+
phys_h = event[:height]
|
|
488
|
+
@surface.resize(phys_w, phys_h)
|
|
489
|
+
@size = Size.new(phys_w.to_f / @hidpi_scale, phys_h.to_f / @hidpi_scale)
|
|
490
|
+
@on_resize&.call
|
|
491
|
+
_do_redraw(true)
|
|
492
|
+
@skip_redraw_requested = true # consume the OS-fired :redraw_requested that follows
|
|
493
|
+
|
|
494
|
+
when :scale_factor_changed
|
|
495
|
+
@hidpi_scale = event[:scale_factor].to_f
|
|
496
|
+
nw = event[:new_width] || (@width * @hidpi_scale).to_i
|
|
497
|
+
nh = event[:new_height] || (@height * @hidpi_scale).to_i
|
|
498
|
+
@surface.resize(nw, nh)
|
|
499
|
+
@size = Size.new(nw.to_f / @hidpi_scale, nh.to_f / @hidpi_scale)
|
|
500
|
+
_do_redraw(true)
|
|
501
|
+
@skip_redraw_requested = true
|
|
502
|
+
|
|
503
|
+
when :redraw_requested
|
|
504
|
+
skip = @skip_redraw_requested && !@animation_pending
|
|
505
|
+
@skip_redraw_requested = false
|
|
506
|
+
@animation_pending = false
|
|
507
|
+
_do_redraw(false) unless skip
|
|
508
|
+
|
|
509
|
+
when :modifiers_changed
|
|
510
|
+
@mods = 0
|
|
511
|
+
@mods |= 0x0001 if event[:shift]
|
|
512
|
+
@mods |= 0x0002 if event[:ctrl]
|
|
513
|
+
@mods |= 0x0004 if event[:alt]
|
|
514
|
+
@mods |= 0x0008 if event[:logo]
|
|
515
|
+
|
|
516
|
+
when :cursor_moved
|
|
517
|
+
x = event[:x].to_f / @hidpi_scale
|
|
518
|
+
y = event[:y].to_f / @hidpi_scale
|
|
519
|
+
@last_cursor_x = x
|
|
520
|
+
@last_cursor_y = y
|
|
521
|
+
@on_cursor_pos&.call(MouseEvent.new(Point.new(x, y), 0))
|
|
522
|
+
|
|
523
|
+
when :mouse_input
|
|
524
|
+
pos = Point.new(@last_cursor_x, @last_cursor_y)
|
|
525
|
+
if event[:state] == :pressed && event[:button] == :left
|
|
526
|
+
@on_mouse_down&.call(MouseEvent.new(pos, 0))
|
|
527
|
+
elsif event[:state] == :released && event[:button] == :left
|
|
528
|
+
@on_mouse_up&.call(MouseEvent.new(pos, 0))
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
when :mouse_wheel
|
|
532
|
+
pos = Point.new(@last_cursor_x, @last_cursor_y)
|
|
533
|
+
delta_y = event[:delta_y].to_f
|
|
534
|
+
@on_mouse_wheel&.call(WheelEvent.new(pos, -delta_y * 20.0))
|
|
535
|
+
|
|
536
|
+
when :keyboard_input
|
|
537
|
+
if event[:state] == :pressed
|
|
538
|
+
key_code = RANMA_KEY_MAP[event[:key_code]] || 0
|
|
539
|
+
@on_input_key&.call(key_code, @mods) if key_code != 0
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
when :received_ime_text
|
|
543
|
+
event[:text]&.each_char { |ch| @on_input_char&.call(ch) }
|
|
544
|
+
|
|
545
|
+
when :ime_preedit
|
|
546
|
+
cursor_pos = event[:cursor_pos] || 0
|
|
547
|
+
@on_ime_preedit&.call(event[:text], cursor_pos, cursor_pos)
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Renders the frame: clear -> save/scale -> callback -> restore -> flush
|
|
552
|
+
def _do_redraw(force_full)
|
|
553
|
+
return unless @ranma_painter && @on_redraw
|
|
554
|
+
# If a net-image download finished since last redraw, force a full repaint
|
|
555
|
+
# so the sub-painter cache is bypassed and the image appears immediately.
|
|
556
|
+
has_new = NET_IMG_MUTEX.synchronize { v = NET_IMG_HAS_NEW[0]; NET_IMG_HAS_NEW[0] = false; v }
|
|
557
|
+
force_full = true if has_new
|
|
558
|
+
|
|
559
|
+
@in_redraw = true
|
|
560
|
+
@ranma_painter.clear(Kumiki.theme.bg_canvas)
|
|
561
|
+
@ranma_painter.save
|
|
562
|
+
@ranma_painter.scale(@hidpi_scale, @hidpi_scale) if @hidpi_scale != 1.0
|
|
563
|
+
@on_redraw.call(@ranma_painter, force_full)
|
|
564
|
+
@ranma_painter.restore
|
|
565
|
+
@ranma_painter.flush
|
|
566
|
+
ensure
|
|
567
|
+
@in_redraw = false
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
module Kumiki
|
|
2
|
+
# Markdown AST node types and node class
|
|
3
|
+
# Used by MarkdownParser and MarkdownRenderer
|
|
4
|
+
|
|
5
|
+
# Node type constants
|
|
6
|
+
MD_DOCUMENT = 0
|
|
7
|
+
MD_HEADING = 1
|
|
8
|
+
MD_PARAGRAPH = 2
|
|
9
|
+
MD_TEXT = 3
|
|
10
|
+
MD_STRONG = 4
|
|
11
|
+
MD_EMPHASIS = 5
|
|
12
|
+
MD_CODE_INLINE = 6
|
|
13
|
+
MD_CODE_BLOCK = 7
|
|
14
|
+
MD_BLOCKQUOTE = 8
|
|
15
|
+
MD_LIST = 9
|
|
16
|
+
MD_LIST_ITEM = 10
|
|
17
|
+
MD_LINK = 11
|
|
18
|
+
MD_HORIZONTAL_RULE = 12
|
|
19
|
+
MD_SOFT_BREAK = 13
|
|
20
|
+
MD_STRIKETHROUGH = 14
|
|
21
|
+
MD_TABLE = 15
|
|
22
|
+
MD_TABLE_ROW = 16
|
|
23
|
+
MD_TABLE_CELL = 17
|
|
24
|
+
MD_IMAGE = 18
|
|
25
|
+
MD_MERMAID = 19
|
|
26
|
+
|
|
27
|
+
class MdNode
|
|
28
|
+
def initialize(type)
|
|
29
|
+
@type = type
|
|
30
|
+
@children = []
|
|
31
|
+
@content = ""
|
|
32
|
+
@level = 0
|
|
33
|
+
@language = ""
|
|
34
|
+
@href = ""
|
|
35
|
+
@ordered = false
|
|
36
|
+
@start_num = 1
|
|
37
|
+
@checked = -1
|
|
38
|
+
@align = 0
|
|
39
|
+
@is_header = false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def type
|
|
43
|
+
@type
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def children
|
|
47
|
+
@children
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def content
|
|
51
|
+
@content
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def content=(v)
|
|
55
|
+
@content = v
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def level
|
|
59
|
+
@level
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def level=(v)
|
|
63
|
+
@level = v
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def language
|
|
67
|
+
@language
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def language=(v)
|
|
71
|
+
@language = v
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def href
|
|
75
|
+
@href
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def href=(v)
|
|
79
|
+
@href = v
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def ordered
|
|
83
|
+
@ordered
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def ordered=(v)
|
|
87
|
+
@ordered = v
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def start_num
|
|
91
|
+
@start_num
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def start_num=(v)
|
|
95
|
+
@start_num = v
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def checked
|
|
99
|
+
@checked
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def checked=(v)
|
|
103
|
+
@checked = v
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def align
|
|
107
|
+
@align
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def align=(v)
|
|
111
|
+
@align = v
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def is_header
|
|
115
|
+
@is_header
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def is_header=(v)
|
|
119
|
+
@is_header = v
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def add_child(node)
|
|
123
|
+
@children.push(node)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
end
|