citrine-native 0.1.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,526 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../widgets"
4
+ require_relative "../painter"
5
+
6
+ module Citrine
7
+ module Native
8
+ module Widgets
9
+ # 内存打桩后端:控件树只有结构、文本与回调,不做任何真实绘制——
10
+ # 供 CRuby 单测断言渲染器语义(GOALS 风险 3:CI 不能开真窗口)。
11
+ # 与主仓"Node 桩验收"同一思路:同一份组件代码,桩控件树上断言结构等价。
12
+ #
13
+ # backend = Widgets::Memory.new
14
+ # renderer = Renderer.new(widgets: backend)
15
+ # root = renderer.mount_component(Counter, {})
16
+ # backend.fire(backend.find(root.dom, kind: :button), :click)
17
+ # assert_equal "计数:1", backend.text_of(...)
18
+ class Memory < Base
19
+ # 桩控件句柄:渲染器只当作不透明对象用(与 Fiddle::Pointer 同地位)
20
+ class Widget
21
+ attr_reader :kind, :children, :events
22
+ attr_accessor :text, :value, :checked, :enabled, :padded, :direction, :parent
23
+
24
+ def initialize(kind, text: "", value: "", checked: false, direction: nil)
25
+ @kind = kind
26
+ @text = text
27
+ @value = value
28
+ @checked = checked
29
+ @direction = direction
30
+ @enabled = true
31
+ @padded = false
32
+ @children = []
33
+ @events = Hash.new { |hash, key| hash[key] = [] }
34
+ @destroyed = false
35
+ @callback_count = {}
36
+ end
37
+
38
+ def container? = kind == :box
39
+ def destroyed? = @destroyed
40
+ def destroyed! = @destroyed = true
41
+
42
+ # 测试用:模拟用户操作(点击 / 输入变更 / 勾选变更)
43
+ def fire(event, *)
44
+ raise ArgumentError, "#{kind} 已被销毁,不能再触发事件" if @destroyed
45
+
46
+ @events[event].dup.each(&:call)
47
+ self
48
+ end
49
+
50
+ def subscribers(event) = @events[event]
51
+
52
+ # 诊断:桩后端如实记录"装了几个原生回调"——libui 每个控件每种事件只有
53
+ # 一个回调位,多订阅必须复用它(测试可断言不重复安装)
54
+ def callback_count(event) = @callback_count[event].to_i
55
+
56
+ def install_callback(event)
57
+ @callback_count[event] = @callback_count[event].to_i + 1
58
+ end
59
+
60
+ def describe
61
+ "#<memory #{kind}#{@destroyed ? ' 已销毁' : ''}>"
62
+ end
63
+ end
64
+
65
+ def initialize
66
+ @created = []
67
+ @loop_hook = nil
68
+ @quit = false
69
+ end
70
+
71
+ # 测试挂钩:main_loop 里执行一次(模拟"用户操作 → 关窗"),再回到调用方
72
+ def on_main_loop(&block)
73
+ @loop_hook = block
74
+ self
75
+ end
76
+
77
+ def init = self
78
+ def shutdown = self
79
+
80
+ def main_loop
81
+ @quit = false
82
+ @loop_hook&.call(self)
83
+ self
84
+ end
85
+
86
+ def quit
87
+ @quit = true
88
+ self
89
+ end
90
+
91
+ def quit? = @quit
92
+
93
+ # 测试用:本后端创建的窗口(`run` 之前只有一个)
94
+ def window = @created.find { |widget| widget.kind == :window }
95
+
96
+ # 桩后端没有主线程概念:直接执行(渲染器语义不依赖线程模型)。
97
+ # 排队记账(见 queue_log)是为了让"这条路径不许排常驻闭包"这类断言在桩上就锁得住。
98
+ def queue_main(&block)
99
+ queue_log << :resident
100
+ block.call
101
+ self
102
+ end
103
+
104
+ # 桩后端不持有闭包,所以这里只记一笔"一次性排队"(真后端由 libui.rb 覆写成
105
+ # 执行后释放引用的版本)。定时器/延后重绘必须走这条。
106
+ def queue_main_once(&block)
107
+ queue_log << :transient
108
+ block.call
109
+ self
110
+ end
111
+
112
+ # 排队记账(诊断/测试断言用):每次 queue_main / queue_main_once 追加一个
113
+ # :resident / :transient。常驻闭包在 libui 后端是真内存泄漏(Fiddle 闭包不能被 GC),
114
+ # 桩后端没有这个问题,但"哪条路径用了哪个槽"是后端无关的语义
115
+ def queue_log = (@queue_log ||= [])
116
+
117
+ # ── 窗口 ────────────────────────────────────────────────
118
+
119
+ def create_window(title: "Citrine", width: 640, height: 480, margined: true)
120
+ track(Widget.new(:window, text: title).tap { |w| w.instance_variable_set(:@meta, { title:, width:, height:, margined: }) })
121
+ end
122
+
123
+ # 桩后端没有"应用激活"这回事:只记账,供测试断言 App 调过它
124
+ def window_activate(window)
125
+ window.instance_variable_set(:@activated, true)
126
+ true
127
+ end
128
+
129
+ def activated?(window) = window.instance_variable_get(:@activated) == true
130
+
131
+ def window_options(window) = window.instance_variable_get(:@meta)
132
+
133
+ def window_set_child(window, child)
134
+ adopt(window, child)
135
+ child
136
+ end
137
+
138
+ def window_on_closing(window, &block)
139
+ # 与 libui 后端同契约:允许关闭 → 由适配层 quit(窗口销毁顺序留给 App)
140
+ window.events[:closing] << proc do
141
+ allowed = block.call != false
142
+ quit if allowed
143
+ allowed
144
+ end
145
+ self
146
+ end
147
+
148
+ # 测试用:模拟用户关窗(返回 false 表示被应用阻止)
149
+ def fire_closing(window)
150
+ allowed = true
151
+ window.events[:closing].each { |handler| allowed = false if handler.call == false }
152
+ allowed
153
+ end
154
+
155
+ def window_show(window)
156
+ window.instance_variable_set(:@shown, true)
157
+ self
158
+ end
159
+
160
+ def shown?(window) = window.instance_variable_get(:@shown) == true
161
+
162
+ def window_destroy(window)
163
+ window.children.dup.each { |child| destroy(child) }
164
+ window.children.clear
165
+ window.destroyed!
166
+ window
167
+ end
168
+
169
+ # ── 容器 ────────────────────────────────────────────────
170
+
171
+ def create_box(direction)
172
+ track(Widget.new(:box, direction: direction))
173
+ end
174
+
175
+ def box_append(box, child, stretchy: false)
176
+ detach_from_parent(child)
177
+ box.children << child
178
+ child.parent = box
179
+ child.instance_variable_set(:@stretchy, stretchy)
180
+ child
181
+ end
182
+
183
+ def box_remove(box, child)
184
+ index = box.children.index(child)
185
+ return false unless index
186
+
187
+ box.children.delete_at(index)
188
+ child.parent = nil
189
+ true
190
+ end
191
+
192
+ def box_children(box) = box.children.dup
193
+
194
+ def box_move_before(box, child, target)
195
+ raise ArgumentError, "box_move_before:控件不在目标容器里" unless box.children.include?(child)
196
+
197
+ box.children.delete(child)
198
+ index = target ? box.children.index(target) : nil
199
+ index ? box.children.insert(index, child) : box.children.push(child)
200
+ child
201
+ end
202
+
203
+ def set_padding(box, padded)
204
+ box.padded = padded ? true : false
205
+ self
206
+ end
207
+
208
+ # ── 叶子控件 ────────────────────────────────────────────
209
+
210
+ def create_label(text = "") = track(Widget.new(:label, text: text.to_s))
211
+ def create_button(text = "") = track(Widget.new(:button, text: text.to_s))
212
+
213
+ def create_entry(password: false)
214
+ track(Widget.new(:entry).tap { |w| w.instance_variable_set(:@password, password) })
215
+ end
216
+
217
+ def password?(entry) = entry.instance_variable_get(:@password) == true
218
+
219
+ def create_checkbox(text = "", checked: false)
220
+ track(Widget.new(:checkbox, text: text.to_s, checked: checked))
221
+ end
222
+
223
+ def set_text(control, text)
224
+ control.text = text.to_s
225
+ self
226
+ end
227
+
228
+ def get_text(control) = control.text.to_s
229
+
230
+ def set_value(control, value)
231
+ control.value = value.to_s
232
+ self
233
+ end
234
+
235
+ def get_value(control) = control.value.to_s
236
+
237
+ def set_checked(control, checked)
238
+ control.checked = checked ? true : false
239
+ self
240
+ end
241
+
242
+ def checked?(control) = control.checked == true
243
+
244
+ def set_enabled(control, enabled)
245
+ control.enabled = enabled ? true : false
246
+ self
247
+ end
248
+
249
+ def enabled?(control) = control.enabled == true
250
+
251
+ # 桩后端不模拟销毁连坐(真实后端会):测试要断言"渲染器逐个销毁了控件"
252
+ def destroy(control)
253
+ detach_from_parent(control)
254
+ control.children.dup.each { |child| destroy(child) }
255
+ control.children.clear
256
+ control.destroyed!
257
+ control
258
+ end
259
+
260
+ # ── 事件(与 libui 后端同口径:on_change 按控件种类落位)──
261
+ # 测试模拟用户操作:button fire(:click)、entry fire(:change)、checkbox fire(:toggle)
262
+
263
+ def on_click(control, &block)
264
+ subscribe(control, :click, &block)
265
+ end
266
+
267
+ def on_change(control, &block)
268
+ subscribe(control, control.kind == :checkbox ? :toggle : :change, &block)
269
+ end
270
+
271
+ # ── 自绘面板(area)────────────────────────────────────
272
+ # 桩后端不做真绘制:draw 交给 Painter::Recording(记录图元序列),
273
+ # 指针/键盘事件由测试用 fire_* 合成(形状与 libui 后端一致)。
274
+
275
+ # 真实可见视口:桩后端没有布局引擎,只有测试显式给的值(set_visible_size)。
276
+ # 默认 nil = "这个后端没有额外几何信息"(提醒逻辑据此只看 Painter 尺寸)
277
+ def area_visible_size(area) = area.instance_variable_get(:@visible_size)
278
+
279
+ # 测试模拟"视口塌了"(真后端上读的是 clip view 的真实边界)
280
+ def set_visible_size(area, width, height)
281
+ area.instance_variable_set(:@visible_size, [width.to_f, height.to_f])
282
+ self
283
+ end
284
+
285
+ # 默认视口:桩后端没有布局引擎,"撑满父容器"无从得知——测试要别的尺寸
286
+ # 就传 width:/height:(真后端这里是 libui 报的布局尺寸)
287
+ DEFAULT_AREA_VIEWPORT = [200, 100].freeze
288
+
289
+ def create_area(size: nil, scroll: false)
290
+ track(Widget.new(:area).tap do |area|
291
+ area.instance_variable_set(:@size, size && Array(size).map(&:to_f))
292
+ area.instance_variable_set(:@scroll, scroll == true)
293
+ area.instance_variable_set(:@redraws, 0)
294
+ end)
295
+ end
296
+
297
+ def area_queue_redraw(area)
298
+ # 绘制期间发出的重绘请求要**延后到这次绘制结束**(真后端是排到下一轮主循环):
299
+ # darwin 的 AppKit 在 drawRect 里忽略 setNeedsDisplay,直接排会被静默丢掉,
300
+ # "在 on_draw 末尾再排一帧"的自排队动画就冻在第一帧(NA-2 P2.2)。
301
+ if @drawing&.include?(area)
302
+ (@deferred_redraws ||= []) << area
303
+ return self
304
+ end
305
+
306
+ bump_redraw(area)
307
+ self
308
+ end
309
+
310
+ def on_area_draw(area, &block) = subscribe(area, :draw, &block)
311
+ def on_area_pointer(area, &block) = subscribe(area, :pointer, &block)
312
+ def on_area_key(area, &block) = subscribe(area, :key, &block)
313
+ def on_area_crossed(area, &block) = subscribe(area, :crossed, &block)
314
+ def on_area_drag_broken(area, &block) = subscribe(area, :drag_broken, &block)
315
+
316
+ # 仅滚动面板(与 libui 后端同一口径:非滚动面板 fail fast,
317
+ # libui 那边真调下去会终止进程)
318
+ def area_scroll_to(area, x, y, w, h)
319
+ raise ArgumentError,
320
+ "非滚动面板没有滚动条,scroll_to 无从生效:请把元素改成 scroll: true(并给 size:)" \
321
+ unless area_scrollable?(area)
322
+
323
+ area.instance_variable_set(:@scroll_to, [x.to_f, y.to_f, w.to_f, h.to_f])
324
+ self
325
+ end
326
+
327
+ def area_scrollable?(area) = area.instance_variable_get(:@scroll) == true
328
+
329
+ # 桩后端没有焦点:只记账(测试断言"应用/App 要过焦点")
330
+ def area_focus(area)
331
+ area.instance_variable_set(:@focused, true)
332
+ true
333
+ end
334
+
335
+ def focused?(area) = area.instance_variable_get(:@focused) == true
336
+
337
+ # 最近一次滚动请求(测试断言)
338
+ def scrolled_to(area) = area.instance_variable_get(:@scroll_to)
339
+
340
+ # ── 面板诊断与测试模拟(真后端由 libui 的 OS 事件驱动)──
341
+
342
+ # 声明的内容尺寸(create_area 时给的 size:),滚动面板下也是绘制视口
343
+ def area_size(area) = area.instance_variable_get(:@size)
344
+ def scrolling?(area) = area.instance_variable_get(:@scroll) == true
345
+
346
+ # 队列重绘被调用了几次(渲染器去重后应恰好一次/收敛)
347
+ def redraw_count(area) = area.instance_variable_get(:@redraws).to_i
348
+
349
+ # 绘制期间攒下的重绘请求:这次绘制收尾后各补一次(同一面板一轮只补一次)
350
+ def flush_deferred_redraws
351
+ pending = @deferred_redraws
352
+ return if pending.nil? || pending.empty?
353
+
354
+ @deferred_redraws = nil
355
+ pending.uniq.each { |area| bump_redraw(area) }
356
+ self
357
+ end
358
+
359
+ # 诊断:是不是正在绘制这个面板(测试断言"绘制期不递归重绘"用)
360
+ def drawing?(area) = @drawing&.include?(area) == true
361
+
362
+ # 最近一次绘制的记录器("画了什么"的断言入口)
363
+ def painting(area) = area.instance_variable_get(:@painting)
364
+
365
+ # 跑一次绘制:把 Painter::Recording(记录图元,不画)交给 on_draw 的订阅者。
366
+ # 绘制期间的重绘请求延后到收尾(与真后端同契约,见 area_queue_redraw)。
367
+ def fire_draw(area, width: nil, height: nil)
368
+ ensure_live!(area, "绘制")
369
+ size = area_size(area) || DEFAULT_AREA_VIEWPORT
370
+ painter = Painter::Recording.new(width: width || size[0], height: height || size[1])
371
+ @drawing = (@drawing || []) << area
372
+ begin
373
+ area.events[:draw].dup.each { |handler| handler.call(painter) }
374
+ ensure
375
+ @drawing.delete(area)
376
+ flush_deferred_redraws
377
+ end
378
+ area.instance_variable_set(:@painting, painter)
379
+ painter
380
+ end
381
+
382
+ # 点击 = 按下 + 抬起(渲染器负责配对成 "click",与 libui 的 Down/Up 一致)
383
+ def fire_click(area, x = 0, y = 0, button: 1, modifiers: {}, count: 1)
384
+ fire_mouse_down(area, x, y, button: button, count: count, modifiers: modifiers)
385
+ fire_mouse_up(area, x, y, button: button, modifiers: modifiers)
386
+ end
387
+
388
+ def fire_mouse_down(area, x = 0, y = 0, button: 1, count: 1, modifiers: {})
389
+ fire_pointer(area, kind: :down, x: x, y: y, button: button, count: count, modifiers: modifiers)
390
+ end
391
+
392
+ def fire_mouse_up(area, x = 0, y = 0, button: 1, modifiers: {})
393
+ fire_pointer(area, kind: :up, x: x, y: y, button: button, count: 0, modifiers: modifiers)
394
+ end
395
+
396
+ def fire_mouse_move(area, x = 0, y = 0, button: 0, modifiers: {})
397
+ fire_pointer(area, kind: :move, x: x, y: y, button: button, count: 0, modifiers: modifiers)
398
+ end
399
+
400
+ def fire_pointer(area, kind:, x: 0, y: 0, button: 1, count: 1, modifiers: {})
401
+ ensure_live!(area, "指针事件")
402
+ dispatch_area(area, :pointer, kind: kind, x: x.to_f, y: y.to_f, button: button,
403
+ count: count, modifiers: modifiers)
404
+ end
405
+
406
+ # 键盘:key 用 DOM 风格键名(真后端由适配层把 libui 的按键归一成它)
407
+ # 返回 true 表示有订阅者认领了这次按键(真后端据此抑制系统提示音)
408
+ def fire_key(area, key, modifiers: {}, up: false)
409
+ ensure_live!(area, "键盘事件")
410
+ dispatch_area(area, :key, key: key, up: up, modifiers: modifiers)
411
+ end
412
+
413
+ def fire_crossed(area, left: false)
414
+ ensure_live!(area, "鼠标进出")
415
+ dispatch_area(area, :crossed, left)
416
+ end
417
+
418
+ def fire_drag_broken(area)
419
+ ensure_live!(area, "拖拽打断")
420
+ dispatch_area(area, :drag_broken)
421
+ end
422
+
423
+ # ── 诊断 ────────────────────────────────────────────────
424
+
425
+ def kind(handle) = handle.kind
426
+ def describe(handle) = handle.describe
427
+
428
+ def created = @created.dup
429
+ def live_widgets = @created.reject(&:destroyed?)
430
+ def destroyed_widgets = @created.select(&:destroyed?)
431
+
432
+ # 测试用:在子树里按种类找控件
433
+ # 测试用:在子树里按种类找控件。
434
+ # ⚠️ 两个方法都**必须传句柄**(子树根,通常是 `container` 或某个 box 句柄)——
435
+ # 桩后端没有"全局控件表"可查,句柄是唯一入口(backlog F6)。便捷写法:
436
+ # NativeTest 的 `find(kind:)` / `find_all(kind:)` 已经带上了 `container`。
437
+ def find(handle, kind: nil, text: nil)
438
+ return handle if matches?(handle, kind, text)
439
+
440
+ handle.children.each do |child|
441
+ found = find(child, kind: kind, text: text)
442
+ return found if found
443
+ end
444
+ nil
445
+ end
446
+
447
+ def find_all(handle, kind: nil)
448
+ out = matches?(handle, kind, nil) ? [handle] : []
449
+ handle.children.each { |child| out.concat(find_all(child, kind: kind)) }
450
+ out
451
+ end
452
+
453
+ # 测试用:控件树快照(结构断言的可读形式)
454
+ # ["box column", ["label", "计数:0"], ["button", "点我 +1"]]
455
+ def tree(handle)
456
+ label = case handle.kind
457
+ when :box then "box #{handle.direction}"
458
+ when :window then "window #{handle.text.inspect}"
459
+ else handle.kind.to_s
460
+ end
461
+ if handle.container? || handle.kind == :window
462
+ [label, *handle.children.map { |child| tree(child) }]
463
+ else
464
+ [label, handle.text.to_s]
465
+ end
466
+ end
467
+
468
+ private
469
+
470
+ def bump_redraw(area)
471
+ area.instance_variable_set(:@redraws, redraw_count(area) + 1)
472
+ end
473
+
474
+ def dispatch_area(area, event, *args)
475
+ handled = false
476
+ area.events[event].dup.each { |handler| handled = true if handler.call(*args) }
477
+ handled
478
+ end
479
+
480
+ def ensure_live!(area, action)
481
+ return if area && !area.destroyed?
482
+
483
+ raise ArgumentError, "面板已被销毁(#{area&.describe}),不能再模拟#{action}:" \
484
+ "未卸载的组件才有活着的面板"
485
+ end
486
+
487
+ def track(widget)
488
+ @created << widget
489
+ widget
490
+ end
491
+
492
+ # 与 libui 后端同一语义:append 到新容器前先从旧容器摘除(DOM appendChild 的搬运语义)
493
+ def detach_from_parent(child)
494
+ parent = child.parent
495
+ return false unless parent
496
+
497
+ parent.children.delete(child)
498
+ child.parent = nil
499
+ true
500
+ end
501
+
502
+ def adopt(parent, child)
503
+ detach_from_parent(child)
504
+ parent.children << child
505
+ child.parent = parent
506
+ child
507
+ end
508
+
509
+ def subscribe(control, event, &block)
510
+ raise ArgumentError, "事件订阅需要块" unless block
511
+
512
+ control.install_callback(event) if control.subscribers(event).empty?
513
+ control.events[event] << block
514
+ block
515
+ end
516
+
517
+ def matches?(handle, kind, text)
518
+ return false if kind && handle.kind != kind
519
+ return false if text && handle.text.to_s != text
520
+
521
+ true
522
+ end
523
+ end
524
+ end
525
+ end
526
+ end