citrine 0.2.0

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.
@@ -0,0 +1,503 @@
1
+ # backtick_javascript: true
2
+ # frozen_string_literal: true
3
+
4
+ require "native"
5
+ require "citrine"
6
+ require "citrine/renderer"
7
+
8
+ module Citrine
9
+ # Canvas 2D 渲染器(Opal 环境)——可移植性演示后端。
10
+ #
11
+ # 与 DOM 渲染器共用 Renderer 基类的全部树管理 / Effect / 块级重建机制,
12
+ # 差异只在钩子实现:布局自管(direction/gap 的 stack/flow 语义)、
13
+ # 绘制原语、包围盒命中检测;任何变更后全量重绘(游戏式)。
14
+ # 文本输入用 window.prompt(演示级,避开 canvas 输入的 IME/光标深水区)。
15
+ class CanvasRenderer < Renderer
16
+ def initialize
17
+ super()
18
+ @hits = [] # [bbox, node],绘制时登记,点击时反查
19
+ end
20
+
21
+ def self.mount_at(element_id, component)
22
+ Citrine.renderer = new
23
+ canvas = Citrine.renderer.document_element(element_id)
24
+ Citrine.mount(component, canvas)
25
+ end
26
+
27
+ def document_element(element_id)
28
+ Native(`window.document`).getElementById(element_id)
29
+ end
30
+
31
+ private
32
+
33
+ # ── Renderer 钩子 ────────────────────────────────────────
34
+
35
+ def setup_root(root, element)
36
+ @root = root
37
+ @canvas = element
38
+ @ctx = element.getContext("2d")
39
+ root.dom = { x: 0, y: 0, w: @canvas[:width], h: @canvas[:height] }
40
+ element.addEventListener("click", ->(event) {
41
+ ev = Native(event)
42
+ handle_canvas_click(ev[:offsetX], ev[:offsetY])
43
+ })
44
+ end
45
+
46
+ def create_dom(_node)
47
+ {} # 布局盒,redraw 时填充
48
+ end
49
+
50
+ def attach(_node, _parent); end
51
+
52
+ # Canvas 没有"页面宿主"概念:portal 内容按画布根级布局/绘制
53
+ def resolve_portal_host(_target)
54
+ @root.dom
55
+ end
56
+
57
+ def detach(_node); end
58
+
59
+ def apply_props(_node); end
60
+
61
+ # 响应式属性(G-2):Canvas 每次重绘都重新读 props,Proc 在绘制时求值
62
+ def style_of(node)
63
+ prop_value(node, node.props[:style]) || {}
64
+ end
65
+
66
+ def set_text(node, text)
67
+ node.text = text
68
+ end
69
+
70
+ def setup_widget(node)
71
+ # T-B2:text_input 换成真实 <input> 覆盖层(中文输入法可用);
72
+ # 无 DOM 环境的宿主(Node 桩)回退 window.prompt 路径
73
+ setup_text_input_overlay(node) if node.type == :text_input && overlay_available?
74
+ end
75
+
76
+ # ── T-B2 spike:隐藏 DOM 测量 + 真实输入覆盖层 ──────────────
77
+ # 选型结论:隐藏 DOM 测量(0KB、白送字体回退与度量),不引入 Yoga。
78
+ # 环境探测带缓存:Node 桩等无 body 环境自动走旧路径,行为不回归。
79
+
80
+ def dom_measurement?
81
+ return @dom_measurement unless @dom_measurement.nil?
82
+
83
+ @dom_measurement = `typeof document !== 'undefined' && !!document.body`
84
+ end
85
+
86
+ def overlay_available?
87
+ return @overlay_available unless @overlay_available.nil?
88
+
89
+ @overlay_available = `typeof document !== 'undefined' && !!document.body && !!document.createElement`
90
+ end
91
+
92
+ def hidden_measurer
93
+ @hidden_measurer ||= begin
94
+ el = Native(`document.createElement("div")`)
95
+ style = el[:style]
96
+ style[:position] = "absolute"
97
+ style[:left] = "-99999px"
98
+ style[:top] = "0"
99
+ style[:visibility] = "hidden"
100
+ style[:whiteSpace] = "nowrap"
101
+ `document.body.appendChild(#{el.to_n})`
102
+ el
103
+ end
104
+ end
105
+
106
+ # 按节点字体量测文本宽度(px)——度量与回退由浏览器负责
107
+ def dom_text_width(text, node)
108
+ el = hidden_measurer
109
+ el[:textContent] = text.to_s
110
+ el[:style][:font] = font_string(node)
111
+ el.getBoundingClientRect()[:width]
112
+ end
113
+
114
+ # 真实 <input> 覆盖层:绝对定位到画布上方,value 信号双向绑定
115
+ def setup_text_input_overlay(node)
116
+ input = Native(`document.createElement("input")`)
117
+ input[:type] = "text"
118
+ value = node.props[:value]
119
+ input[:value] = value.get.to_s if value.is_a?(Signal)
120
+ input[:style][:position] = "absolute"
121
+ input[:style][:boxSizing] = "border-box"
122
+ input[:style][:border] = "1px solid #999"
123
+ input[:style][:font] = "15px sans-serif"
124
+ input[:style][:background] = "#fff"
125
+ overlay_root.appendChild(input)
126
+ if value.is_a?(Signal)
127
+ input.addEventListener("input", ->(_e) { value.set(input[:value]) })
128
+ node.owned_effects << Effect.create { input[:value] = value.get.to_s }
129
+ end
130
+ (@overlays ||= {})[node.object_id] = { node: node, input: input }
131
+ node
132
+ end
133
+
134
+ # 覆盖层宿主:挂在 body 上的绝对定位层(节点坐标 = 画布视口偏移 + 布局盒)
135
+ def overlay_root
136
+ @overlay_root ||= begin
137
+ wrap = Native(`document.createElement("div")`)
138
+ wrap[:style][:cssText] = "position:absolute;left:0;top:0;width:0;height:0;overflow:visible"
139
+ `document.body.appendChild(#{wrap.to_n})`
140
+ wrap
141
+ end
142
+ end
143
+
144
+ # 每次 redraw 后按布局盒重新定位覆盖层,并清掉已消失的输入框
145
+ def sync_overlays
146
+ return unless @overlays
147
+
148
+ canvas_rect = @canvas.getBoundingClientRect()
149
+ live = {}
150
+ collect_text_inputs = proc do |node|
151
+ live[node.object_id] = true if node.type == :text_input
152
+ node.children.each { |child| collect_text_inputs.call(child) }
153
+ end
154
+ collect_text_inputs.call(@root)
155
+
156
+ @overlays.each do |id, entry|
157
+ unless live.key?(id)
158
+ entry[:input].remove
159
+ @overlays.delete(id)
160
+ next
161
+ end
162
+
163
+ box = entry[:node].dom
164
+ style = entry[:input][:style]
165
+ style[:left] = "#{canvas_rect[:left] + box[:x]}px"
166
+ style[:top] = "#{canvas_rect[:top] + box[:y]}px"
167
+ style[:width] = "#{box[:w]}px"
168
+ style[:height] = "#{box[:h]}px"
169
+ end
170
+ end
171
+
172
+ # 嵌套挂载期间不重绘,等最外层 settle(@parents 为空)后整体画一次
173
+ def finalize(_node)
174
+ redraw if @parents.empty?
175
+ end
176
+
177
+ # 子组件 view 的信号重跑(S1-2)发生在父块渲染之外:重跑收尾时补一次整体重绘。
178
+ # 若重跑本身嵌在别的渲染过程里(@parents 非空),仍由最外层 finalize 统一重绘。
179
+ def rerun_component_view(child, parent)
180
+ super
181
+ ensure
182
+ redraw if @parents.empty?
183
+ end
184
+
185
+ # ── 事件(命中检测 → 分发) ─────────────────────────────
186
+
187
+ def handle_canvas_click(x, y)
188
+ @hits.reverse_each do |box, node|
189
+ next unless x >= box[:x] && x <= box[:x] + box[:w] &&
190
+ y >= box[:y] && y <= box[:y] + box[:h]
191
+
192
+ dispatch(node)
193
+ break
194
+ end
195
+ redraw
196
+ end
197
+
198
+ def dispatch(node)
199
+ case node.type
200
+ when :button
201
+ handler = node.props[:on_click]
202
+ node.owner.handle_event(handler) if handler
203
+ when :check_box
204
+ checked = node.props[:checked]
205
+ checked = checked.get if checked.is_a?(Signal)
206
+ handler = node.props[:on_change]
207
+ return unless handler
208
+
209
+ # 受控语义(S2-4):先写回信号再调处理器,处理器读到的是新勾选态
210
+ node.props[:checked].set(!checked) if node.props[:checked].is_a?(Signal)
211
+ node.owner.handle_event(handler, !checked)
212
+ when :text_input
213
+ dispatch_text_input(node)
214
+ end
215
+ end
216
+
217
+ def dispatch_text_input(node)
218
+ # T-B2:有覆盖层时聚焦真实 <input>(IME 可用);否则回退 prompt 演示路径
219
+ overlay = @overlays && @overlays[node.object_id]
220
+ if overlay
221
+ overlay[:input].focus
222
+ return
223
+ end
224
+
225
+ text = prompt_value(prop_value(node, node.props[:placeholder]).to_s)
226
+ return if text.nil?
227
+
228
+ value = node.props[:value]
229
+ value.set(text) if value.is_a?(Signal)
230
+ handler = node.props[:on_enter]
231
+ node.owner.handle_event(handler) if handler
232
+ end
233
+
234
+ def prompt_value(message)
235
+ return nil unless `typeof window.prompt === 'function'`
236
+
237
+ `window.prompt(#{message})`
238
+ end
239
+
240
+ # ── 布局:size(后序测尺寸)→ place(前序定位) ──────────
241
+
242
+ def redraw
243
+ return unless @root && @ctx
244
+
245
+ @hits = []
246
+ # P1:本轮重绘的尺寸缓存。size_of 原本随 place 的前序遍历被逐层重复递归
247
+ # (复杂度 深度 × 节点数,叶子量测重复最狠)——每轮重绘只算一次、缓存命中即返回;
248
+ # ensure 里清空:样式 / props 变化后的下一轮重绘必须重新量测
249
+ @size_cache = {}
250
+ @ctx.clearRect(0, 0, @root.dom[:w], @root.dom[:h])
251
+ place(@root, 0, 0)
252
+ paint(@root)
253
+ sync_overlays
254
+ ensure
255
+ @size_cache = nil
256
+ end
257
+
258
+ def box?(node)
259
+ # fragment / portal(S1-4 / S1-5):透明容器按容器参与布局,让多根/弹层照常展开
260
+ node.type == :root || node.type == :box || node.type == :fragment || node.type == :portal
261
+ end
262
+
263
+ def box_axes(node)
264
+ style = style_of(node)
265
+ pad = px_num(node.type == :root ? (style[:padding] || 12) : (style[:padding] || 0))
266
+ gap = (gap_prop = prop_value(node, node.props[:gap])) ? gap_prop.to_i : 10
267
+ direction = if node.type == :root || prop_value(node, node.props[:direction]) == :column
268
+ :column
269
+ else
270
+ :row
271
+ end
272
+ [direction, pad, gap]
273
+ end
274
+
275
+ def size_of(node)
276
+ return [@root.dom[:w], @root.dom[:h]] if node.type == :root
277
+
278
+ # P1:命中本轮缓存直接返回——首次计算时已把尺寸写进 node.dom,place/paint 只读
279
+ return @size_cache[node.object_id] if @size_cache&.key?(node.object_id)
280
+
281
+ if box?(node)
282
+ direction, pad, gap = box_axes(node)
283
+ sizes = node.children.map { |child| size_of(child) }
284
+ n = node.children.size
285
+ gaps = n > 1 ? gap * (n - 1) : 0
286
+ main = direction == :column ? sizes.sum(&:last) + gaps : sizes.sum(&:first) + gaps
287
+ cross = direction == :column ? (sizes.map(&:first).max || 0) : (sizes.map(&:last).max || 0)
288
+ w = (direction == :column ? cross : main) + 2 * pad
289
+ h = (direction == :column ? main : cross) + 2 * pad
290
+ else
291
+ w, h = leaf_size(node)
292
+ end
293
+ node.dom = node.dom.merge(w: w, h: h)
294
+ @size_cache[node.object_id] = [w, h] if @size_cache
295
+ [w, h]
296
+ end
297
+
298
+ def leaf_size(node)
299
+ case node.type
300
+ when :text_input then [240, 36]
301
+ when :check_box then [18, 18]
302
+ else
303
+ text = display_text(node).to_s
304
+ # T-B2 spike(隐藏 DOM 测量):真机里按同一字体用隐藏元素量测宽度——
305
+ # 字体回退 / CJK 度量交给浏览器;Node 桩等无 body 环境回退 measureText。
306
+ width = dom_measurement? ? dom_text_width(text, node) : ctx_text_width(text, node)
307
+ width += node.type == :button ? 24 : 4
308
+ height = font_size(node) * (node.type == :button ? 1.9 : 1.6)
309
+
310
+ # T-B2:显式 style width 且文本超宽 → 贪心断行,多行堆叠
311
+ max_w = wrap_max_width(node)
312
+ if max_w && width > max_w
313
+ lines = wrapped_lines(text, node, max_w)
314
+ width = max_w
315
+ height = lines.size * font_size(node) * 1.5
316
+ end
317
+ [width, height]
318
+ end
319
+ end
320
+
321
+ # 断行宽度上限:label 的显式 style width
322
+ def wrap_max_width(node)
323
+ w = style_of(node)[:width]
324
+ w ? px_num(w) : nil
325
+ end
326
+
327
+ # 贪心断行:CJK 逐字、拉丁/数字按词、空白串整体。
328
+ # 返回各行文本;调用方需已设置 @ctx.font(量测用)。
329
+ def wrapped_lines(text, node, max_width)
330
+ @ctx.font = font_string(node)
331
+ lines = []
332
+ current = ""
333
+ wrap_units(text).each do |unit|
334
+ candidate = current + unit
335
+ if current.empty? || @ctx.measureText(candidate)[:width] <= max_width
336
+ current = candidate
337
+ else
338
+ lines << current
339
+ current = unit
340
+ end
341
+ end
342
+ lines << current unless current.empty?
343
+ lines
344
+ end
345
+
346
+ # 分词:拉丁/数字词整体、空白串整体、其余逐字(CJK 逐字断行)
347
+ def wrap_units(text)
348
+ text.to_s.scan(/[A-Za-z0-9]+|\s+|[^A-Za-z0-9\s]/)
349
+ end
350
+
351
+ def ctx_text_width(text, node)
352
+ @ctx.font = font_string(node)
353
+ @ctx.measureText(text)[:width]
354
+ end
355
+
356
+ def place(node, x, y)
357
+ size_of(node)
358
+ node.dom = node.dom.merge(x: x, y: y)
359
+ return unless box?(node)
360
+
361
+ direction, pad, gap = box_axes(node)
362
+ cx = x + pad
363
+ cy = y + pad
364
+ node.children.each do |child|
365
+ place(child, cx, cy)
366
+ if direction == :column
367
+ cy += child.dom[:h] + gap
368
+ else
369
+ cx += child.dom[:w] + gap
370
+ end
371
+ end
372
+ end
373
+
374
+ # ── 绘制 ───────────────────────────────────────────────
375
+
376
+ def paint(node)
377
+ case node.type
378
+ when :root
379
+ node.children.each { |child| paint(child) }
380
+ when :box
381
+ style = style_of(node)
382
+ bg = style[:background] || style[:background_color]
383
+ # 渐变等复杂值在 Canvas 上跳过(仅支持纯色),避免污染当前 fillStyle
384
+ if bg.is_a?(String) && bg.start_with?("#", "rgb")
385
+ @ctx.fillStyle = bg
386
+ @ctx.fillRect(node.dom[:x], node.dom[:y], node.dom[:w], node.dom[:h])
387
+ end
388
+ node.children.each { |child| paint(child) }
389
+ when :fragment, :portal
390
+ # 透明容器:只递归子根,自身不占绘制
391
+ node.children.each { |child| paint(child) }
392
+ when :label
393
+ paint_label(node)
394
+ when :button
395
+ paint_button(node)
396
+ when :text_input
397
+ paint_text_input(node)
398
+ when :check_box
399
+ paint_check_box(node)
400
+ end
401
+ end
402
+
403
+ def paint_label(node)
404
+ style = style_of(node)
405
+ color = style[:color] || "#222"
406
+ @ctx.font = font_string(node)
407
+ @ctx.fillStyle = color
408
+
409
+ # T-B2:多行文本逐行绘制(换行条件与 leaf_size 一致)
410
+ max_w = wrap_max_width(node)
411
+ lines = max_w ? wrapped_lines(display_text(node).to_s, node, max_w) : [display_text(node).to_s]
412
+ line_h = font_size(node) * 1.5
413
+ lines.each_with_index do |line, i|
414
+ @ctx.fillText(line, node.dom[:x] + 2, node.dom[:y] + font_size(node) * 1.15 + i * line_h)
415
+ end
416
+
417
+ return unless lines.size == 1 && style[:text_decoration] == "line-through"
418
+
419
+ y_mid = node.dom[:y] + font_size(node) * 0.75
420
+ @ctx.strokeStyle = color
421
+ @ctx.moveTo(node.dom[:x] + 2, y_mid)
422
+ @ctx.lineTo(node.dom[:x] + 2 + @ctx.measureText(lines.first)[:width], y_mid)
423
+ @ctx.stroke
424
+ end
425
+
426
+ def paint_button(node)
427
+ box = node.dom
428
+ text = display_text(node)
429
+ @ctx.fillStyle = "#f3f3f3"
430
+ @ctx.fillRect(box[:x], box[:y], box[:w], box[:h])
431
+ @ctx.strokeStyle = "#999"
432
+ @ctx.strokeRect(box[:x], box[:y], box[:w], box[:h])
433
+ @ctx.font = font_string(node)
434
+ @ctx.fillStyle = "#222"
435
+ @ctx.fillText(text, box[:x] + (box[:w] - @ctx.measureText(text)[:width]) / 2,
436
+ box[:y] + font_size(node) * 1.25)
437
+ @hits << [box, node]
438
+ end
439
+
440
+ def paint_text_input(node)
441
+ # 真实 <input> 覆盖层已就位:不再绘制占位框(避免视觉重复)
442
+ return if @overlays && @overlays.key?(node.object_id)
443
+
444
+ box = node.dom
445
+ value = node.props[:value]
446
+ value = value.get if value.is_a?(Signal)
447
+ value = value.to_s
448
+ @ctx.fillStyle = "#fff"
449
+ @ctx.fillRect(box[:x], box[:y], box[:w], box[:h])
450
+ @ctx.strokeStyle = "#aaa"
451
+ @ctx.strokeRect(box[:x], box[:y], box[:w], box[:h])
452
+ @ctx.font = "15px sans-serif"
453
+ if value.empty?
454
+ @ctx.fillStyle = "#999"
455
+ @ctx.fillText(prop_value(node, node.props[:placeholder]).to_s, box[:x] + 8, box[:y] + 23)
456
+ else
457
+ @ctx.fillStyle = "#222"
458
+ @ctx.fillText(value[0, 20], box[:x] + 8, box[:y] + 23)
459
+ end
460
+ @hits << [box, node]
461
+ end
462
+
463
+ def paint_check_box(node)
464
+ box = node.dom
465
+ @ctx.fillStyle = "#fff"
466
+ @ctx.fillRect(box[:x], box[:y], box[:w], box[:h])
467
+ @ctx.strokeStyle = "#666"
468
+ @ctx.strokeRect(box[:x], box[:y], box[:w], box[:h])
469
+ @hits << [box, node]
470
+ checked = node.props[:checked]
471
+ checked = checked.get if checked.is_a?(Signal)
472
+ return unless checked
473
+
474
+ @ctx.fillStyle = "#333"
475
+ @ctx.font = "14px sans-serif"
476
+ @ctx.fillText("✓", box[:x] + 3, box[:y] + 14)
477
+ end
478
+
479
+ # ── 工具 ───────────────────────────────────────────────
480
+
481
+ def display_text(node)
482
+ node.text.to_s
483
+ end
484
+
485
+ def font_size(node)
486
+ px_num(style_of(node)[:font_size] || 16)
487
+ end
488
+
489
+ def font_string(node)
490
+ weight = style_of(node)[:font_weight]
491
+ size = font_size(node)
492
+ if weight && weight.to_s != "400"
493
+ "#{weight} #{size}px sans-serif"
494
+ else
495
+ "#{size}px sans-serif"
496
+ end
497
+ end
498
+
499
+ def px_num(value)
500
+ value.to_s.sub("px", "").to_f
501
+ end
502
+ end
503
+ end