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,1022 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../widgets"
4
+
5
+ module Citrine
6
+ module Native
7
+ module Widgets
8
+ # libui 后端(决策 N-1:v0 唯一后端)。用 kojix2/libui gem 绑定的 libui-ng
9
+ # 动态库,控件是各平台原生控件(Cocoa / Win32 / GTK)。
10
+ #
11
+ # 本层吸收的三处 libui 特性(实测结论,见 GOALS 变更日志 N1):
12
+ # 1. `uiBoxDelete` **只摘除、不销毁**控件(探针实测:摘除后控件仍在分配表里,
13
+ # 且 `uiControlParent` 变为 NULL),因此落位重排可以无损搬运;
14
+ # 2. box 只有 append / delete-by-index,没有 insert-at:`box_move_before`
15
+ # 用"摘尾部 → 追加 → 回挂尾部"实现;顺序账本在本层维护(libui 不提供
16
+ # child-at-index 查询);
17
+ # 3. 每个控件每种事件只有**一个**回调位(`uiButtonOnClicked` 是覆盖式赋值):
18
+ # 本层用订阅者列表做多路分发,回调只装一次。
19
+ #
20
+ # 句柄是 `Fiddle::Pointer`(裸指针),不是富对象——文本读取得自己 `.to_s`,
21
+ # 生命周期完全由 Ruby 侧(渲染器的 dispose)负责:libui 不会因 GC 回收控件。
22
+ class Libui < Base
23
+ def initialize
24
+ @kinds = {} # 控件地址 => 种类(:window/:box/:label/…)
25
+ @children = {} # 容器地址 => [子控件](libui 没有 child-at 查询,顺序记账)
26
+ @stretchy = {} # 子控件地址 => 追加时的 stretchy(重排回挂时按原样恢复)
27
+ @subscribers = {} # [控件地址, 事件] => [块]
28
+ @closures = [] # 强引用住 Fiddle 闭包(回调被 GC 掉就是野指针)
29
+ @handles = {} # 控件地址 => Fiddle::Pointer(**必须常驻**:libui gem 把
30
+ # Fiddle 闭包挂在指针对象上,指针被 GC 回收 = 回调变野指针)
31
+ @areas = {} # 面板地址 => {handler:, closures:, size:, cache:, control:, scroll:}
32
+ @drawing = [] # 正在绘制中的面板地址(绘制期发出的重绘请求要延后,见 area_queue_redraw)
33
+ @deferred_redraws = nil # 绘制期攒下的重绘请求:[地址 => 面板]
34
+ require "libui"
35
+ rescue LoadError => e
36
+ raise Citrine::Native::ToolkitUnavailableError,
37
+ "加载 libui 失败(#{e.message}):citrine-native 的 v0 后端需要 libui gem。" \
38
+ "请 `gem install libui` 或检查 Gemfile;纯逻辑测试请注入 Memory 后端" \
39
+ "(Citrine::Native::Widgets::Memory)"
40
+ end
41
+
42
+ # ── 工具包生命周期与主循环 ──────────────────────────────
43
+
44
+ def init
45
+ # 幂等守卫:App#setup 会再调一次 init,而 Windows 的 libui 对二次 init
46
+ # 报"registering utility window class; code 1410 类已存在"(返回错误字符串、
47
+ # 打到 stderr)—— macOS 上第二次 init 是静默 no-op,看不出来。
48
+ return self if @initialized
49
+
50
+ ::LibUI.init
51
+ @initialized = true
52
+ self
53
+ end
54
+
55
+ def shutdown
56
+ ::LibUI.uninit
57
+ @initialized = false # 允许下一轮 init(GUI 冒烟里多个 App 顺序复用同一 backend)
58
+ self
59
+ end
60
+
61
+ # 阻塞跑事件循环(所有控件回调都在这里面触发);窗口关闭时 quit 返回
62
+ def main_loop
63
+ ::LibUI.main
64
+ self
65
+ end
66
+
67
+ def quit
68
+ ::LibUI.quit
69
+ self
70
+ end
71
+
72
+ # 后台线程更新 UI 的唯一通道:uiQueueMain 线程安全
73
+ def queue_main(&block)
74
+ handler = ->(*) { safe("queue_main") { block.call } }
75
+ @closures << handler
76
+ ::LibUI.queue_main(handler)
77
+ self
78
+ end
79
+
80
+ # 与 queue_main 同义,但闭包**不常驻**:执行完就没人再引用它(可以 GC)。
81
+ # 用于"每帧都可能排队"的延迟重绘——常驻的写法会让自排队动画每帧漏一个闭包。
82
+ def queue_main_once(&block)
83
+ handler = nil
84
+ handler = lambda do |*|
85
+ @transient_closures.delete(handler)
86
+ safe("延迟重绘") { block.call }
87
+ end
88
+ (@transient_closures ||= []) << handler
89
+ ::LibUI.queue_main(handler)
90
+ self
91
+ end
92
+
93
+ # ── 窗口 ────────────────────────────────────────────────
94
+
95
+ def create_window(title: "Citrine", width: 640, height: 480, margined: true)
96
+ window = ::LibUI.new_window(title.to_s, width.to_i, height.to_i, 0)
97
+ ::LibUI.window_set_margined(window, margined ? 1 : 0)
98
+ remember(window, :window)
99
+ end
100
+
101
+ def window_set_child(window, child)
102
+ ::LibUI.window_set_child(window, child)
103
+ # 窗口也算一层容器,只是它只有一个子控件:记账进来,
104
+ # 销毁窗口时才能(递归)作废整棵子树里的句柄
105
+ @children[key(window)] = [child]
106
+ child
107
+ end
108
+
109
+ # 关窗回调:**一律返回 0 阻止工具包自行销毁窗口**(libui 的语义是
110
+ # 非零 → 销毁窗口,0 → 取消关闭,见 libui-ng ui.h:423-425)。
111
+ # 让 libui 销毁窗口的话,后续的有序拆解(卸载组件 → 销毁控件 → 销毁窗口)
112
+ # 就会动到已释放的控件;因此"是否允许关闭"只决定要不要 quit,
113
+ # 销毁顺序始终由 App 接管。
114
+ def window_on_closing(window, &block)
115
+ handler = ->(*) {
116
+ allowed = safe("窗口关闭") { block.call } != false
117
+ quit if allowed
118
+ 0
119
+ }
120
+ @closures << handler
121
+ ::LibUI.window_on_closing(window, handler)
122
+ self
123
+ end
124
+
125
+ def window_show(window)
126
+ ::LibUI.control_show(window)
127
+ self
128
+ end
129
+
130
+ # 销毁窗口会连坐它的子控件(实测),因此必须先卸载组件再销毁窗口。
131
+ # 记账同步作废:控件已随窗口释放,留着句柄只会在后续操作里踩野指针。
132
+ def window_destroy(window)
133
+ addr = key(window)
134
+ ::LibUI.control_destroy(window)
135
+ forget_tree(addr)
136
+ @handles.delete(addr)
137
+ self
138
+ end
139
+
140
+ # ── 容器 ────────────────────────────────────────────────
141
+
142
+ def create_box(direction)
143
+ box = direction == :column ? ::LibUI.new_vertical_box : ::LibUI.new_horizontal_box
144
+ remember(box, :box)
145
+ @children[key(box)] = []
146
+ box
147
+ end
148
+
149
+ # append 语义与 DOM 的 appendChild 一致:已在容器里的先摘除(搬运到末尾),
150
+ # 否则 libui 的 uiControlSetParent 会撞"控件已有父容器"的内部断言。
151
+ def box_append(box, child, stretchy: false)
152
+ detach_from_parent(child)
153
+ ::LibUI.box_append(box, child, stretchy ? 1 : 0)
154
+ children_of(box) << child
155
+ @stretchy[key(child)] = stretchy
156
+ child
157
+ end
158
+
159
+ # 只摘除、不销毁(对应 libui 的 uiBoxDelete 语义)
160
+ def box_remove(box, child)
161
+ list = children_of(box)
162
+ index = index_of(list, child)
163
+ return false unless index
164
+
165
+ ::LibUI.box_delete(box, index)
166
+ list.delete_at(index)
167
+ @stretchy.delete(key(child))
168
+ true
169
+ end
170
+
171
+ def box_children(box)
172
+ children_of(box).dup
173
+ end
174
+
175
+ def box_move_before(box, child, target)
176
+ list = children_of(box)
177
+ from = index_of(list, child)
178
+ unless from
179
+ raise ArgumentError, "box_move_before:控件不在目标容器里(渲染器记账与控件树不一致)"
180
+ end
181
+
182
+ return child if target.nil? && from == list.size - 1 # 已在末尾
183
+ return child if target && (to = index_of(list, target)) && from == to - 1 # 已就位
184
+
185
+ # 先摘除 child:后面的索引都以"child 已摘除"的坐标系计算
186
+ ::LibUI.box_delete(box, from)
187
+ list.delete_at(from)
188
+ if target.nil? || (to = index_of(list, target)).nil?
189
+ append_child(box, list, child)
190
+ return child
191
+ end
192
+
193
+ # libui 没有 insert-at:把 target 及其后的兄弟整段摘下(从尾往前删,
194
+ # 索引不受影响),追加 child 后再按原相对顺序回挂
195
+ tail = list[to..] || []
196
+ (list.size - 1).downto(to) do |i|
197
+ ::LibUI.box_delete(box, i)
198
+ list.delete_at(i)
199
+ end
200
+ append_child(box, list, child)
201
+ tail.each { |sibling| append_child(box, list, sibling) }
202
+ child
203
+ end
204
+
205
+ def set_padding(box, padded)
206
+ ::LibUI.box_set_padded(box, padded ? 1 : 0)
207
+ self
208
+ end
209
+
210
+ # ── 叶子控件 ────────────────────────────────────────────
211
+
212
+ def create_label(text = "")
213
+ remember(::LibUI.new_label(text.to_s), :label)
214
+ end
215
+
216
+ def create_button(text = "")
217
+ remember(::LibUI.new_button(text.to_s), :button)
218
+ end
219
+
220
+ def create_entry(password: false)
221
+ control = password ? ::LibUI.new_password_entry : ::LibUI.new_entry
222
+ remember(control, :entry)
223
+ end
224
+
225
+ def create_checkbox(text = "", checked: false)
226
+ control = ::LibUI.new_checkbox(text.to_s)
227
+ ::LibUI.checkbox_set_checked(control, checked ? 1 : 0)
228
+ remember(control, :checkbox)
229
+ end
230
+
231
+ def set_text(control, text)
232
+ value = text.to_s
233
+ case kind(control)
234
+ when :label then ::LibUI.label_set_text(control, value)
235
+ when :button then ::LibUI.button_set_text(control, value)
236
+ when :checkbox then ::LibUI.checkbox_set_text(control, value)
237
+ when :entry then ::LibUI.entry_set_text(control, value)
238
+ when :window then ::LibUI.window_set_title(control, value)
239
+ else raise ArgumentError, "#{describe(control)} 不支持文本内容"
240
+ end
241
+ self
242
+ end
243
+
244
+ def get_text(control)
245
+ pointer = case kind(control)
246
+ when :label then ::LibUI.label_text(control)
247
+ when :button then ::LibUI.button_text(control)
248
+ when :checkbox then ::LibUI.checkbox_text(control)
249
+ when :entry then ::LibUI.entry_text(control)
250
+ else raise ArgumentError, "#{describe(control)} 没有文本"
251
+ end
252
+ read_text(pointer)
253
+ end
254
+
255
+ # 受控值写入是**静默**的(实测:uiEntrySetText / uiCheckboxSetChecked
256
+ # 都不触发 onChanged / onToggled),因此信号 → 控件的同步不会回到信号形成环
257
+ def set_value(control, value)
258
+ ::LibUI.entry_set_text(control, value.to_s)
259
+ self
260
+ end
261
+
262
+ def get_value(control)
263
+ read_text(::LibUI.entry_text(control))
264
+ end
265
+
266
+ def set_checked(control, checked)
267
+ ::LibUI.checkbox_set_checked(control, checked ? 1 : 0)
268
+ self
269
+ end
270
+
271
+ def checked?(control)
272
+ ::LibUI.checkbox_checked(control) != 0
273
+ end
274
+
275
+ def set_enabled(control, enabled)
276
+ enabled ? ::LibUI.control_enable(control) : ::LibUI.control_disable(control)
277
+ self
278
+ end
279
+
280
+ # 摘除 + 销毁(先摘再销毁:libui 的 uiControlDestroy 要求控件不带父容器,
281
+ # 违反会直接 abort 进程)
282
+ def destroy(control)
283
+ detach_from_parent(control)
284
+ if (parent = parent_of(control))
285
+ # 走到这里说明账本与控件树不一致(漏摘或摘错了容器)——宁可报清楚的错,
286
+ # 也不要让 libui 的 bug 检查把进程带走
287
+ raise Error, "#{describe(control)} 销毁前仍有父容器 #{describe(parent)}:" \
288
+ "摘除路径没走通(控件树与适配层账本不一致)"
289
+ end
290
+
291
+ addr = key(control)
292
+ ::LibUI.control_destroy(control)
293
+ forget(addr)
294
+ self
295
+ end
296
+
297
+ # ── 事件订阅(每个控件每种事件一个回调位 → 本层做多路分发)──
298
+ # on_change 按控件种类落到原生回调:entry → uiEntryOnChanged,
299
+ # checkbox → uiCheckboxOnToggled(勾选变更在 citrine 侧同为 on_change)
300
+
301
+ def on_click(control, &block)
302
+ subscribe(control, :click, &block)
303
+ end
304
+
305
+ def on_change(control, &block)
306
+ subscribe(control, :change, &block)
307
+ end
308
+
309
+ # ── 自绘面板(area,设计 2.1/2.3)───────────────────────
310
+ #
311
+ # 三条实测结论(GOALS 变更日志 NA-1):
312
+ # 1. `uiAreaHandler` 的五个回调槽**必须全装**:libui 调用前不做 NULL 检查
313
+ # (darwin/area.m 里直接 `(*(a->ah->MouseCrossed))(...)`),留着 NULL 就是野指针;
314
+ # 所以槽位在创建时装满 no-op,订阅者挂上来后由 dispatch 分发给它们。
315
+ # 2. `uiAreaSetSize` 只对**滚动**面板有效:非滚动面板上调它,libui 走
316
+ # uiprivUserBug 直接 abort 进程(实测 exit 134)。所以 size: 只在
317
+ # scroll: true(`uiNewScrollingArea` 的内容尺寸)时落地。
318
+ # 3. 滚动面板下 `uiAreaDrawParams.AreaWidth/AreaHeight` 是 **0**
319
+ # (ui.h:这两个字段 only defined for nonscrolling areas),面板尺寸得
320
+ # 从声明的内容尺寸来。
321
+ def create_area(size: nil, scroll: false)
322
+ content = size && Array(size).map { |value| value.to_f.round }
323
+ if scroll && (content.nil? || content.size != 2)
324
+ raise ArgumentError, "滚动面板需要 size: [宽, 高]——libui 的内容尺寸在创建时定死" \
325
+ "(uiNewScrollingArea),且滚动面板下 Draw 不报尺寸"
326
+ end
327
+
328
+ handler, closures = build_area_handler
329
+ area = scroll ? ::LibUI.new_scrolling_area(handler, content[0], content[1])
330
+ : ::LibUI.new_area(handler)
331
+ remember(area, :area)
332
+ # 结构体与闭包必须常驻(被 GC 回收 = 回调变野指针);文本布局缓存随面板销毁清掉
333
+ # (libui 的对象不归 Ruby GC 管,见 Painter::TextCache)
334
+ @areas[key(area)] = { handler: handler, closures: closures, size: content,
335
+ scroll: scroll == true, cache: Painter::TextCache.new,
336
+ control: area, area_view: nil }
337
+ area
338
+ end
339
+
340
+ # 标脏 + 排一次重绘(uiAreaQueueRedrawAll = darwin 的 setNeedsDisplay:YES,合并进下一帧)。
341
+ # **在 on_draw 里调用要延后**:AppKit 在绘制过程中忽略 setNeedsDisplay,所以
342
+ # "在 on_draw 末尾再排一帧"这种自排队动画会静默冻在第一帧(NA-2 P2.2 实测:
343
+ # frames=1 之后再无绘制)。绘制中发出的请求先记账,等这次绘制收尾再经 queue_main
344
+ # 排到下一轮主循环——既出下一帧,又不会在绘制里递归重绘(同一面板一轮只排一次)。
345
+ def area_queue_redraw(area)
346
+ addr = key(area)
347
+ if @drawing.include?(addr)
348
+ (@deferred_redraws ||= {})[addr] = area
349
+ return self
350
+ end
351
+
352
+ ::LibUI.area_queue_redraw_all(area)
353
+ self
354
+ end
355
+
356
+ # 仅滚动面板:uiAreaScrollTo 对非滚动面板会 uiprivUserBug **终止进程**
357
+ # (实测,与 uiAreaSetSize 同一类),所以这里拦住而不是交给 libui
358
+ def area_scroll_to(area, x, y, w, h)
359
+ raise ArgumentError,
360
+ "非滚动面板没有滚动条,scroll_to 无从生效:请把元素改成 scroll: true(并给 size:)" \
361
+ unless area_scrollable?(area)
362
+
363
+ ::LibUI.area_scroll_to(area, x.to_f, y.to_f, w.to_f, h.to_f)
364
+ self
365
+ end
366
+
367
+ def area_scrollable?(area) = area_record(area)[:scroll] == true
368
+
369
+ # 真实可见视口(诊断 + "面板被压扁"提醒):滚动面板取 clip view 的真实边界
370
+ # (未夹内容尺寸、也不做正数过滤——视口塌成 0 正是要报的形状)。
371
+ # 非滚动面板不做额外读取:Painter 的尺寸就是 libui 报的布局尺寸(= 控件 frame),
372
+ # 没有别的信息可给,返回 nil 让提醒走"只看 Painter 尺寸"的分支。
373
+ def area_visible_size(area)
374
+ record = @areas[key(area)]
375
+ return nil unless record && record[:scroll]
376
+
377
+ rect = raw_visible_rect(record)
378
+ rect && [rect[2], rect[3]]
379
+ end
380
+
381
+ # 给面板键盘焦点:libui 没有这条 API,走 Cocoa 的
382
+ # [keyWindow makeFirstResponder: uiControlHandle(area)](设计 2.3 的实测结论)。
383
+ # 注意窗口得先是 key window(App 的 activate: 负责,见 window_activate)。
384
+ def area_focus(area)
385
+ objc.focus(::LibUI.control_handle(area))
386
+ end
387
+
388
+ # 激活应用(macOS):uiControlShow 之后窗口不是 key window,一个键也收不到,
389
+ # 必须先 [NSApp activateIgnoringOtherApps:YES](设计 2.3 的实测结论)
390
+ def window_activate(_window) = objc.activate
391
+
392
+ def on_area_draw(area, &block) = subscribe(area, :draw, &block)
393
+ def on_area_pointer(area, &block) = subscribe(area, :pointer, &block)
394
+ def on_area_key(area, &block) = subscribe(area, :key, &block)
395
+ def on_area_crossed(area, &block) = subscribe(area, :crossed, &block)
396
+ def on_area_drag_broken(area, &block) = subscribe(area, :drag_broken, &block)
397
+
398
+ # 诊断(冒烟用):窗口是不是 key window(设计 2.3 的键盘前提——
399
+ # uiControlShow 之后不是,必须 activate;这条断言就是那个结论的机器可验证形式)
400
+ def window_is_key?(_window) = objc.window_key?
401
+
402
+ # 诊断(冒烟用):某个窗口此刻的 firstResponder(AppKit 视图地址;没有则 nil)。
403
+ # 与 focus 的返回值对拍:契约是"返回 true ⇔ 目标真的成了 first responder"——
404
+ # 实测只在一个方向成立(见 ObjcBridge#focus 的边界说明)。
405
+ def window_first_responder(window) = objc.first_responder_of(::LibUI.control_handle(window))
406
+
407
+ # 诊断(冒烟用):对**任意视图**走一次 Cocoa 的 makeFirstResponder:。
408
+ # AreaHandle#focus 只覆盖面板句柄;要验"AppKit 拒绝时如实返回 false"需要一个
409
+ # 它一定会拒绝的目标(普通 NSView:不接受成为 first responder)。
410
+ def focus_view(view) = objc.focus(view)
411
+
412
+ # 诊断:五个回调槽是否都装上了(冒烟脚本断言"注册成功"用;读的是真结构体字段)
413
+ def area_handler_slots(area)
414
+ handler = area_record(area)[:handler]
415
+ %i[Draw MouseEvent MouseCrossed DragBroken KeyEvent].select do |slot|
416
+ pointer = handler.public_send(slot)
417
+ !pointer.nil? && pointer.to_i != 0
418
+ end
419
+ end
420
+
421
+ # 诊断(冒烟用):合成一个 uiAreaMouseEvent / uiAreaKeyEvent,调用**已注册到 libui
422
+ # 结构体里的那个回调闭包**——覆盖"libui 结构体 → 适配层事件视图"这段链路
423
+ # (OS 真投递仍需人手点一次,见 GOALS 变更日志 NA-1)。
424
+ # 与桩后端的 fire_* 同口径:不经 OS,只走 libui 真正持有的回调。
425
+ def simulate_area_mouse(area, x:, y:, down: 0, up: 0, count: 0, modifiers: 0)
426
+ event = ::LibUI::FFI::AreaMouseEvent.malloc
427
+ event.X = x.to_f
428
+ event.Y = y.to_f
429
+ event.AreaWidth = 0.0
430
+ event.AreaHeight = 0.0
431
+ event.Down = down
432
+ event.Up = up
433
+ event.Count = count
434
+ event.Modifiers = modifiers
435
+ event.Held1To64 = 0
436
+ call_area_slot(area, :MouseEvent, event)
437
+ end
438
+
439
+ def simulate_area_key(area, character: nil, ext_key: 0, modifiers: 0, up: 0)
440
+ event = ::LibUI::FFI::AreaKeyEvent.malloc
441
+ event.Key = character.to_s.empty? ? 0 : character.to_s.bytes.first
442
+ event.ExtKey = ext_key
443
+ event.Modifier = 0
444
+ event.Modifiers = modifiers
445
+ event.Up = up
446
+ call_area_slot(area, :KeyEvent, event)
447
+ end
448
+
449
+ # ── 诊断 ────────────────────────────────────────────────
450
+
451
+ def kind(handle)
452
+ @kinds[key(handle)] ||
453
+ raise(ArgumentError, "未知控件句柄 #{raw(handle)}:可能已被销毁,或不是本后端创建的")
454
+ end
455
+
456
+ def describe(handle)
457
+ name = @kinds[key(handle)]
458
+ name ? "#<libui #{name}>" : "#<libui 已销毁/未知控件>"
459
+ end
460
+
461
+ # 诊断:记账里还活着的控件数(测试断言"拆解干净、没漏控件"用)
462
+ def live_handles = @kinds.size
463
+
464
+ # 诊断:还活着的面板数(面板另有一套记账:回调结构体 + 文本布局缓存,
465
+ # 拆解时都必须作废——否则就是 C 内存泄漏)
466
+ def live_areas = @areas.size
467
+
468
+ private
469
+
470
+ # ── 自绘面板:回调装配与事件转发 ────────────────────────
471
+
472
+ # 五个槽一次装满(libui 调用前不检查 NULL)。返回 [结构体, 闭包数组],
473
+ # 两者都由 @areas[地址] 常驻持有——闭包被 GC 掉就是野指针。
474
+ def build_area_handler
475
+ handler = ::LibUI::FFI::AreaHandler.malloc
476
+ closures = []
477
+ add = lambda do |slot, return_type, arg_types, &body|
478
+ closure = Fiddle::Closure::BlockCaller.new(return_type, arg_types, &body)
479
+ closures << closure
480
+ handler.public_send("#{slot}=", closure)
481
+ end
482
+
483
+ add.call(:Draw, Fiddle::TYPE_VOID, [Fiddle::TYPE_VOIDP] * 3) do |_handler, area, params|
484
+ safe("面板绘制") { draw_area(key_of(area), params) }
485
+ end
486
+ add.call(:MouseEvent, Fiddle::TYPE_VOID, [Fiddle::TYPE_VOIDP] * 3) do |_handler, area, event|
487
+ safe("面板指针事件") { pointer_area(key_of(area), event) }
488
+ end
489
+ add.call(:MouseCrossed, Fiddle::TYPE_VOID, [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT]) do |_handler, area, left|
490
+ safe("面板鼠标进出") { dispatch_area(key_of(area), :crossed, left != 0) }
491
+ end
492
+ add.call(:DragBroken, Fiddle::TYPE_VOID, [Fiddle::TYPE_VOIDP] * 2) do |_handler, area|
493
+ safe("面板拖拽打断") { dispatch_area(key_of(area), :drag_broken) }
494
+ end
495
+ add.call(:KeyEvent, Fiddle::TYPE_INT, [Fiddle::TYPE_VOIDP] * 3) do |_handler, area, event|
496
+ # 返回非零 = 已处理:libui 的 sendEvent 就此返回,不再走系统默认处理(未处理的
497
+ # 按键会被 AppKit 提示音"叮"一声)。注意它**先于**菜单快捷键:所以带 ⌘ 的按键
498
+ # 即使应用处理了也返回 0,让系统照常走菜单(⌘H/⌘⌥H 这类有绑定的菜单项属于系统,
499
+ # NA-2 的真 OS 投递对照实验:声明 on_key 的面板曾把 ⌘H 吃掉)。
500
+ # 返回值必须是 Integer:闭包的返回类型是 int,返回 true/false/nil 会在
501
+ # Fiddle 边界抛 TypeError(那就穿过 libui 的 C 栈了)
502
+ safe("面板键盘") { key_area(key_of(area), event) } ? 1 : 0
503
+ end
504
+ [handler, closures]
505
+ end
506
+
507
+ def draw_area(addr, params_ptr)
508
+ subscribers = @subscribers[[addr, :draw]]
509
+ record = @areas[addr]
510
+ return if subscribers.nil? || subscribers.empty? || record.nil?
511
+
512
+ params = ::LibUI::FFI::AreaDrawParams.new(params_ptr)
513
+ width, height = area_viewport(record, params)
514
+ painter = Painter.new(ctx: params.Context, width: width, height: height,
515
+ cache: record[:cache],
516
+ clip: area_clip(record, params, width, height))
517
+ @drawing << addr
518
+ begin
519
+ subscribers.dup.each { |block| block.call(painter) }
520
+ ensure
521
+ @drawing.delete(addr)
522
+ flush_deferred_redraws
523
+ end
524
+ end
525
+
526
+ # 绘制期间攒下的重绘请求:这次绘制收尾后各排一次(面板可能已被卸载,先查记账)
527
+ def flush_deferred_redraws
528
+ pending = @deferred_redraws
529
+ return if pending.nil? || pending.empty?
530
+
531
+ @deferred_redraws = nil
532
+ pending.each_value do |area|
533
+ queue_main_once { ::LibUI.area_queue_redraw_all(area) if @areas.key?(key(area)) }
534
+ end
535
+ self
536
+ end
537
+
538
+ # 面板尺寸:非滚动面板用 libui 报的布局尺寸;滚动面板下那两个字段恒为 0
539
+ # (ui.h:only defined for nonscrolling areas),退回声明的内容尺寸
540
+ def area_viewport(record, params)
541
+ width = params.AreaWidth.to_f
542
+ height = params.AreaHeight.to_f
543
+ return [width, height] if width.positive? && height.positive?
544
+
545
+ (record[:size] || [0.0, 0.0]).map(&:to_f)
546
+ end
547
+
548
+ # 当前可见区(Painter#clip_rect,内容坐标):
549
+ # - 非滚动面板:整块面板可见。darwin 下 Clip* 报的是"需要重画的范围"(脏区),
550
+ # 非滚动面板那一帧的脏区是**整窗**(NA-2 实测 [-20,-68,800,632],面板只有
551
+ # 760×528),所以不用它
552
+ # - 滚动面板:取 areaView 的 visibleRect(= clip view 在内容坐标里的范围:
553
+ # origin 是滚动偏移、size 是**不含滚动条**的真实视口)。为什么不用 Clip*:
554
+ # Clip* 就是 drawRect 的脏区(libui-ng darwin/area.m:`dp.ClipX = r.origin.x`),
555
+ # 滚动帧里它只是"新露出来的那条"条带(NA-2 实测 [0,900,743,100]、
556
+ # [0,1000,743,300])——拿它当可见区会漏画(或画到滚动条下面)。
557
+ # 读不到(非 macOS / 视图还没布局 / 首帧瞬态)时退回 Clip* 并夹进内容尺寸。
558
+ def area_clip(record, params, width, height)
559
+ return [0.0, 0.0, width, height] unless record[:scroll]
560
+
561
+ visible = visible_area_rect(record)
562
+ return visible if visible
563
+
564
+ x = params.ClipX.to_f.clamp(0.0, width)
565
+ y = params.ClipY.to_f.clamp(0.0, height)
566
+ [x, y, params.ClipWidth.to_f.clamp(0.0, width - x), params.ClipHeight.to_f.clamp(0.0, height - y)]
567
+ end
568
+
569
+ # areaView 的 visibleRect 原值(内容坐标系,不夹取、不过滤)
570
+ #
571
+ # ⚠️ 首帧瞬态(NA-2 实测,4 次跑里 2 次):滚动面板**第一次**绘制时这里可能
572
+ # 报到 NSScrollView 自己的尺寸(760×560,含滚动条位)而不是视口(743×543)——
573
+ # libui 在同一次 Draw 里才把 document view 的 frame 设上去,而这次读在它之前。
574
+ # 稳态逐帧 0 误差。只影响"首帧就按 clip_rect 裁剪并缓存"的应用。
575
+ def raw_visible_rect(record)
576
+ view = record[:area_view] ||= objc.scrolling_document_view(
577
+ ::LibUI.control_handle(record[:control])
578
+ )
579
+ return nil if view.nil?
580
+
581
+ objc.rect_of(view, "visibleRect")
582
+ end
583
+
584
+ # 可见区(夹进声明的内容尺寸;x/y/w/h 任一非正 → nil,由调用方退回 Clip*)
585
+ def visible_area_rect(record)
586
+ rect = raw_visible_rect(record)
587
+ return nil if rect.nil?
588
+
589
+ x, y, w, h = rect
590
+ return nil unless w.positive? && h.positive?
591
+
592
+ size = record[:size] || [0.0, 0.0]
593
+ [x.clamp(0.0, size[0].to_f), y.clamp(0.0, size[1].to_f),
594
+ w.clamp(0.0, size[0].to_f - x), h.clamp(0.0, size[1].to_f - y)]
595
+ end
596
+
597
+ # libui 的按钮编号:1 左 / 2 中 / 3 右;Down/Up 都为 0 是移动
598
+ def pointer_area(addr, event_ptr)
599
+ event = ::LibUI::FFI::AreaMouseEvent.new(event_ptr)
600
+ down = event.Down.to_i
601
+ up = event.Up.to_i
602
+ kind = if down.positive? then :down
603
+ elsif up.positive? then :up
604
+ else :move
605
+ end
606
+ dispatch_area(addr, :pointer, kind: kind, x: event.X.to_f, y: event.Y.to_f,
607
+ button: down.positive? ? down : up, count: event.Count.to_i,
608
+ delta_y: 0.0, modifiers: modifiers_of(event.Modifiers))
609
+ end
610
+
611
+ # 面板按键:归一键名后交给订阅者(渲染器),返回值 = "要不要抑制系统默认处理"。
612
+ # **⌘ 组合键的不吞规则不在这里做**:策略单点在 `Renderer#dispatch_area_key`
613
+ # (桩后端与真后端同口径,也才有测试锁得住)——这一层只如实转达订阅者的答复。
614
+ # 背景:libui 的 KeyEvent 回调先于菜单快捷键,认领 ⌘ 会让 ⌘H/⌘⌥H(以及应用自己用
615
+ # uiNewMenu 建的菜单项)在焦点落到面板时失效;回调本身照常触发(⌘Z 不受影响)。
616
+ def key_area(addr, event_ptr)
617
+ event = ::LibUI::FFI::AreaKeyEvent.new(event_ptr)
618
+ key = key_name(event.Key, event.ExtKey)
619
+ # Key 与 ExtKey 都是 0 = 修饰键自身的按下/抬起(flagsChanged),不投递给应用
620
+ return false if key.nil?
621
+
622
+ dispatch_area(addr, :key, key: key, up: event.Up != 0,
623
+ modifiers: modifiers_of(event.Modifiers))
624
+ end
625
+
626
+ # 平台按键归一(DOM 风格键名):ExtKey 优先,否则用字符。
627
+ # macOS 下 libui 给的是**与 Shift 无关的等位字符**(keycode 表:'\n' Enter、
628
+ # '\t' Tab、'\b' Backspace、' ' 空格、其余小写字母/数字),Shift 走 modifiers,
629
+ # 所以应用里判断大写请用 shift?(与 DOM 的 ev.key 不同,见 README 限制)。
630
+ def key_name(char_code, ext_key)
631
+ named = ext_key_names[ext_key.to_i]
632
+ return named if named
633
+
634
+ code = char_code.to_i
635
+ # libui 的 Key 是 ASCII 等位字符(表里只有 ASCII),非 ASCII 字节不猜
636
+ return nil unless code.positive? && code < 128
637
+
638
+ case (char = code.chr(Encoding::UTF_8))
639
+ when "\n" then "Enter"
640
+ when "\t" then "Tab"
641
+ when "\b" then "Backspace"
642
+ else char
643
+ end
644
+ end
645
+
646
+ def ext_key_names
647
+ @ext_key_names ||= {
648
+ ::LibUI::ExtKeyEscape => "Escape",
649
+ ::LibUI::ExtKeyInsert => "Insert",
650
+ ::LibUI::ExtKeyDelete => "Delete",
651
+ ::LibUI::ExtKeyHome => "Home",
652
+ ::LibUI::ExtKeyEnd => "End",
653
+ ::LibUI::ExtKeyPageUp => "PageUp",
654
+ ::LibUI::ExtKeyPageDown => "PageDown",
655
+ ::LibUI::ExtKeyUp => "ArrowUp",
656
+ ::LibUI::ExtKeyDown => "ArrowDown",
657
+ ::LibUI::ExtKeyLeft => "ArrowLeft",
658
+ ::LibUI::ExtKeyRight => "ArrowRight",
659
+ ::LibUI::ExtKeyF1 => "F1", ::LibUI::ExtKeyF2 => "F2",
660
+ ::LibUI::ExtKeyF3 => "F3", ::LibUI::ExtKeyF4 => "F4",
661
+ ::LibUI::ExtKeyF5 => "F5", ::LibUI::ExtKeyF6 => "F6",
662
+ ::LibUI::ExtKeyF7 => "F7", ::LibUI::ExtKeyF8 => "F8",
663
+ ::LibUI::ExtKeyF9 => "F9", ::LibUI::ExtKeyF10 => "F10",
664
+ ::LibUI::ExtKeyF11 => "F11", ::LibUI::ExtKeyF12 => "F12",
665
+ # 小键盘:数字与小数点按 DOM 的取值('0'..'9'/'.'),运算符同理
666
+ ::LibUI::ExtKeyN0 => "0", ::LibUI::ExtKeyN1 => "1",
667
+ ::LibUI::ExtKeyN2 => "2", ::LibUI::ExtKeyN3 => "3",
668
+ ::LibUI::ExtKeyN4 => "4", ::LibUI::ExtKeyN5 => "5",
669
+ ::LibUI::ExtKeyN6 => "6", ::LibUI::ExtKeyN7 => "7",
670
+ ::LibUI::ExtKeyN8 => "8", ::LibUI::ExtKeyN9 => "9",
671
+ ::LibUI::ExtKeyNDot => ".", ::LibUI::ExtKeyNEnter => "Enter",
672
+ ::LibUI::ExtKeyNAdd => "+", ::LibUI::ExtKeyNSubtract => "-",
673
+ ::LibUI::ExtKeyNMultiply => "*", ::LibUI::ExtKeyNDivide => "/"
674
+ }.freeze
675
+ end
676
+
677
+ def modifiers_of(mask)
678
+ bits = mask.to_i
679
+ {
680
+ shift: (bits & ::LibUI::ModifierShift) != 0,
681
+ ctrl: (bits & ::LibUI::ModifierCtrl) != 0,
682
+ alt: (bits & ::LibUI::ModifierAlt) != 0,
683
+ meta: (bits & ::LibUI::ModifierSuper) != 0
684
+ }
685
+ end
686
+
687
+ # 多路分发(与控件订阅同一套):任一订阅者返回真值即视为"已处理"
688
+ def dispatch_area(addr, event, *args)
689
+ subscribers = @subscribers[[addr, event]]
690
+ return false if subscribers.nil? || subscribers.empty?
691
+
692
+ handled = false
693
+ subscribers.dup.each { |block| handled = true if block.call(*args) }
694
+ handled
695
+ end
696
+
697
+ def area_record(area) = @areas[key(area)] ||
698
+ raise(ArgumentError, "#{describe(area)} 不是本后端的自绘面板(或已销毁)")
699
+
700
+ # 诊断用:直接调用结构体里那个回调闭包(调用的方式与 libui 调它一致)
701
+ def call_area_slot(area, slot, event)
702
+ pointer = area_record(area)[:handler].public_send(slot)
703
+ if pointer.nil? || pointer.to_i.zero?
704
+ raise Error, "#{describe(area)} 的 #{slot} 回调槽没装上"
705
+ end
706
+
707
+ return_type = slot == :KeyEvent ? Fiddle::TYPE_INT : Fiddle::TYPE_VOID
708
+ function = Fiddle::Function.new(pointer.to_i, [Fiddle::TYPE_VOIDP] * 3, return_type)
709
+ function.call(0, area, event)
710
+ end
711
+
712
+ # 回调拿到的 area 是 Fiddle::Pointer(或地址),记账键统一用地址
713
+ def key_of(area) = area.to_i
714
+
715
+ # macOS 直通桥(懒建:非 macOS 上 dlopen 失败 → available? 为 false)
716
+ def objc
717
+ @objc ||= ObjcBridge.new
718
+ end
719
+
720
+ # ── macOS/Cocoa 直通(libui 公开 API 的能力缺口)────────
721
+ # 设计 2.3 的实测结论:`uiControlShow` 之后窗口**不是 key window**
722
+ # ([NSApp keyWindow] 为 nil、firstResponder 为 nil)→ 一个键也收不到;
723
+ # `[NSApp activateIgnoringOtherApps:YES]` 之后窗口变 key、area 自动成为
724
+ # first responder,键盘事件实测可达。libui 没有暴露这条能力,只能自己调。
725
+ # 非 macOS(或 libobjc 不可用)时 available? 为 false,调用方返回 false 如实上报。
726
+ class ObjcBridge
727
+ def initialize
728
+ lib = Fiddle.dlopen("/usr/lib/libobjc.A.dylib")
729
+ @sel = Fiddle::Function.new(lib["sel_registerName"], [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOIDP)
730
+ @get_class = Fiddle::Function.new(lib["objc_getClass"], [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOIDP)
731
+ @msg = Fiddle::Function.new(lib["objc_msgSend"],
732
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOIDP)
733
+ @msg_int = Fiddle::Function.new(lib["objc_msgSend"],
734
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
735
+ Fiddle::TYPE_VOIDP)
736
+ @msg_ptr = Fiddle::Function.new(lib["objc_msgSend"],
737
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP],
738
+ Fiddle::TYPE_VOIDP)
739
+ @msg_void = Fiddle::Function.new(lib["objc_msgSend"],
740
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP,
741
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG],
742
+ Fiddle::TYPE_VOID)
743
+ # 返回 BOOL 的方法(makeFirstResponder:)必须用 char 返回类型读——
744
+ # 用 TYPE_VOIDP 读小整数返回值在 AArch64 上拿到的是寄存器残值,不是 0/1
745
+ @msg_bool = Fiddle::Function.new(lib["objc_msgSend"],
746
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP],
747
+ Fiddle::TYPE_CHAR)
748
+ @rect_keys = {}
749
+ @available = true
750
+ rescue StandardError
751
+ @available = false
752
+ end
753
+
754
+ def available? = @available
755
+
756
+ def activate
757
+ return false unless available?
758
+
759
+ app = shared_application
760
+ return false if app.to_i.zero?
761
+
762
+ @msg_int.call(app, selector("activateIgnoringOtherApps:"), 1)
763
+ true
764
+ end
765
+
766
+ # 把 first responder 交给 view,**如实转达结果**(NA-2 P3 的必修项;NA-1d 加强)。
767
+ # 旧写法只要"窗口是 key window"就 return true——应用侧按契约检查返回值也察觉不到
768
+ # AppKit 拒绝。现在返回**两层判据的合取**:
769
+ # ① `[NSWindow makeFirstResponder:]` 的 BOOL(用 char 返回类型读)**受理**了请求;
770
+ # ② 窗口此刻的 `firstResponder` **真的是这个视图**(或它的后代——滚动面板的
771
+ # first responder 是 document view/areaView,不是 NSScrollView 本身)。
772
+ #
773
+ # 为什么不能只报 ①(NA-1d 探针 5 实测,macOS 26 / arm64):只要窗口是 key window,
774
+ # 这个 BOOL 对**接受与不接受** first responder 的目标**都返回 YES**——目标没拿到
775
+ # 焦点时 AppKit 把**窗口自己**设成 first responder,返回值却是 YES(实测目标:
776
+ # 游离 areaView、不可编辑的 NSTextField、普通 boxView/NSView 全是 YES)。只报 ①
777
+ # 就是"转达了一个不诚实的答复";加上 ② 之后,返回值 ⇔ "焦点真的落到目标上"。
778
+ def focus(view)
779
+ return false unless available?
780
+ return false if view.to_i.zero?
781
+
782
+ window = @msg.call(shared_application, selector("keyWindow"))
783
+ return false if window.to_i.zero?
784
+
785
+ return false if @msg_bool.call(window, selector("makeFirstResponder:"), view).zero?
786
+
787
+ responder = @msg.call(window, selector("firstResponder"))
788
+ descendant_or_self?(responder, view)
789
+ end
790
+
791
+ # responder 是 view 本身或它的后代?(`isDescendantOf:`,都返回 BOOL)
792
+ # 先 `isKindOfClass:NSView` 再问:窗口自己(uiprivNSWindow)也会成为 first
793
+ # responder,而它不是 NSView——直接发 `isDescendantOf:` 会抛 ObjC 异常,
794
+ # **那是不可捕获的**(进程直接终止,见 rect_of 的限制一)。
795
+ def descendant_or_self?(responder, view)
796
+ return false if responder.to_i.zero?
797
+ return true if responder.to_i == view.to_i
798
+
799
+ ns_view = @get_class.call("NSView")
800
+ return false if @msg_bool.call(responder, selector("isKindOfClass:"), ns_view).zero?
801
+
802
+ !@msg_bool.call(responder, selector("isDescendantOf:"), view).zero?
803
+ end
804
+
805
+ # 某个窗口此刻的 firstResponder(诊断用:冒烟拿它和 focus 的返回值对拍)。
806
+ # 走**给定的窗口**而不是 [NSApp keyWindow],这样窗口不是 key 时也能读
807
+ # (AppKit 会保留已设的 first responder)。没有 first responder 时返回 nil。
808
+ def first_responder_of(window)
809
+ return nil unless available?
810
+ return nil if window.to_i.zero?
811
+
812
+ responder = @msg.call(window, selector("firstResponder"))
813
+ responder.to_i.zero? ? nil : responder
814
+ end
815
+
816
+ # [NSApp keyWindow] 非 nil?(键盘投递的前提;返回指针类型,读返回值是安全的)
817
+ def window_key?
818
+ return false unless available?
819
+
820
+ !@msg.call(shared_application, selector("keyWindow")).to_i.zero?
821
+ end
822
+
823
+ # NSScrollView → 它的 document view(= libui 的 areaView:内容坐标系的原点
824
+ # 与 Painter 的坐标同一套)。不是滚动视图 / 还没建好时返回 nil。
825
+ def scrolling_document_view(scroll_view)
826
+ return nil unless available? && !scroll_view.to_i.zero?
827
+
828
+ document = @msg.call(scroll_view, selector("documentView"))
829
+ document.to_i.zero? ? nil : document
830
+ rescue StandardError
831
+ nil
832
+ end
833
+
834
+ # 读一个 NSRect 属性(visibleRect / bounds / frame…)。
835
+ # Fiddle 拿不到 32 字节的结构体返回值,所以绕一步:KVC 把 struct 属性包成
836
+ # NSValue,再用 `getValue:size:`(参数全是指针)拷进我们自己的 buffer。
837
+ # 失败(键不存在 / 不是 NSValue)返回 nil,调用方自己兜底。
838
+ #
839
+ # ⚠️ 限制一(NA-2 实测,**会带走整个进程**):KVC 键不存在、或对非滚动视图发
840
+ # `documentView` 会抛 **Objective-C 异常**——它不是 Ruby 异常,`rescue
841
+ # StandardError` 抓不住,进程直接终止(`NSInvalidArgumentException`)。
842
+ # 所以调用方必须保证键存在且句柄类型对:框架只对 record[:scroll] 的面板读
843
+ # `visibleRect`,而那时句柄确实是 NSScrollView(改这里前先复核这条前提)。
844
+ #
845
+ # ⚠️ 限制二(野读不崩、静默给陈旧值):面板销毁后拿缓存下来的视图指针再读
846
+ # 不会崩,而是返回"看着像真的"的旧值(实测 [0,0,543,343])——靠"销毁后不读"
847
+ # 的不变量兜住(draw_area 查 @areas、flush_deferred_redraws 查记账、
848
+ # forget 里 cache.clear!),不是靠这里报错。
849
+ def rect_of(view, key)
850
+ return nil unless available? && !view.to_i.zero?
851
+
852
+ value = @msg_ptr.call(view, selector("valueForKey:"), rect_key(key))
853
+ return nil if value.to_i.zero?
854
+
855
+ buffer = Fiddle::Pointer.malloc(Fiddle::SIZEOF_DOUBLE * 4, Fiddle::RUBY_FREE)
856
+ @msg_void.call(value, selector("getValue:size:"), buffer, Fiddle::SIZEOF_DOUBLE * 4)
857
+ buffer[0, Fiddle::SIZEOF_DOUBLE * 4].unpack("d4")
858
+ rescue StandardError
859
+ nil
860
+ end
861
+
862
+ private
863
+
864
+ # KVC 的键字符串:创建后 retain 一份常驻(`stringWithUTF8String:` 返回的是
865
+ # autorelease 对象,缓存里不 retain 就会在自动释放池排干后变野指针)。
866
+ # 每个键只建一次(绘制每帧都会用到,不能每帧建 NSString)。
867
+ #
868
+ # ⚠️ 这条 retain 对**短键是 no-op、对长键是保命**(NA-2 实测,边界在 11/12 字符):
869
+ # - 长度 ≤ 11 的键(当前唯一用到的 `"visibleRect"` 就是 11)拿到的是 tagged pointer /
870
+ # `__NSCFConstantString`,retainCount = -1(immortal);消融掉这行行为完全一样。
871
+ # - 长度 ≥ 12 的键拿到的是真 `__NSCFString`(autorelease 对象):池排干后再读
872
+ # **进程 exit 133(SIGTRAP)崩溃**(NA-2 用 15 字符键复现)。
873
+ # `rect_of` 的契约写着支持 visibleRect / bounds / frame…,所以 retain 作为通用
874
+ # 防御保留——别按"当前这个键的现状"删掉它。
875
+ def rect_key(key)
876
+ @rect_keys[key] ||= begin
877
+ name = @msg_ptr.call(@get_class.call("NSString"),
878
+ selector("stringWithUTF8String:"), key)
879
+ @msg.call(name, selector("retain"))
880
+ end
881
+ end
882
+
883
+ def shared_application
884
+ @msg.call(@get_class.call("NSApplication"), selector("sharedApplication"))
885
+ end
886
+
887
+ def selector(name) = @sel.call(name)
888
+ end
889
+
890
+ # ── 记账(libui 没有 child-at 查询,顺序只能自己记)────────
891
+
892
+ def remember(handle, kind)
893
+ @kinds[key(handle)] = kind
894
+ @handles[key(handle)] = handle # 常驻引用:句柄被 GC 会连带回收它的回调闭包
895
+ handle
896
+ end
897
+
898
+ def forget(addr)
899
+ @kinds.delete(addr)
900
+ @children.delete(addr)
901
+ @stretchy.delete(addr)
902
+ @handles.delete(addr)
903
+ # 面板:布局缓存必须在 uiUninit 之前释放(libui 的 text layout / attributed
904
+ # string / 字体描述符不归 Ruby GC 管);结构体与闭包随记账一起作废
905
+ @areas.delete(addr)&.fetch(:cache)&.clear!
906
+ @subscribers.delete_if { |(sub_addr, _event), _| sub_addr == addr }
907
+ end
908
+
909
+ # 容器销毁会连坐子控件(实测),记账要跟着整棵作废
910
+ def forget_tree(addr)
911
+ children_of_addr(addr).each { |child| forget_tree(key(child)) }
912
+ forget(addr)
913
+ end
914
+
915
+ def children_of_addr(addr)
916
+ (@children[addr] || []).dup
917
+ end
918
+
919
+ def children_of(box)
920
+ @children[key(box)] ||= []
921
+ end
922
+
923
+ # libui 返回的字符串是库自己分配的(*_text / *_title),读完必须 free_text,
924
+ # 否则每次读取都漏一段 C 内存(适配层的读路径在事件回调里)。
925
+ # Fiddle 的 Pointer#to_s 不做编码推断,拿到的 String 是 ASCII-8BIT——
926
+ # 中文文本直接比较/拼接都会错,这里统一按 UTF-8 解释。
927
+ def read_text(pointer)
928
+ return "" if pointer.nil? || pointer.null?
929
+
930
+ text = pointer.to_s.dup.force_encoding(Encoding::UTF_8)
931
+ ::LibUI.free_text(pointer)
932
+ text
933
+ end
934
+
935
+ def append_child(box, list, child)
936
+ ::LibUI.box_append(box, child, @stretchy.fetch(key(child), false) ? 1 : 0)
937
+ list << child
938
+ end
939
+
940
+ def detach_from_parent(child)
941
+ parent = parent_of(child)
942
+ return false unless parent
943
+ # 父子关系以本层记账为准(libui 的 uiBoxDelete 需要索引,而它不提供 child-at 查询);
944
+ # 只有 box 支持"摘除"——窗口虽然也记了一笔,但它没有摘除 API
945
+ return box_remove(parent, child) if @children.key?(key(parent)) && @kinds[key(parent)] == :box
946
+
947
+ # 父容器是窗口(根容器):窗口只有一个 child,没有摘除 API——
948
+ # 这条路径不该出现(根容器由 App 连窗口一起销毁)
949
+ raise ArgumentError, "#{describe(child)} 的父容器是窗口,不能单独摘除" \
950
+ "(窗口与其内容同生命周期)"
951
+ end
952
+
953
+ def parent_of(control)
954
+ pointer = ::LibUI.control_parent(control)
955
+ return nil if pointer.nil? || pointer.to_i.zero?
956
+
957
+ pointer
958
+ end
959
+
960
+ def index_of(list, child)
961
+ addr = key(child)
962
+ list.index { |candidate| key(candidate) == addr }
963
+ end
964
+
965
+ def key(handle)
966
+ handle.to_i
967
+ end
968
+
969
+ def raw(handle)
970
+ format("0x%x", key(handle))
971
+ end
972
+
973
+ # ── 事件分发 ────────────────────────────────────────────
974
+
975
+ def subscribe(control, event, &block)
976
+ raise ArgumentError, "事件订阅需要块(#{describe(control)} #{event})" unless block
977
+
978
+ subs = (@subscribers[[key(control), event]] ||= [])
979
+ subs << block
980
+ register_native_callback(control, event) if subs.size == 1
981
+ block
982
+ end
983
+
984
+ # libui 的每个控件每种事件只有一个回调位 → 只装一次,内部再分发。
985
+ # 面板(area)的五个槽在 create_area 时就装满了(libui 调它们前不检查 NULL),
986
+ # 这里只登记订阅者、不再装回调。
987
+ def register_native_callback(control, event)
988
+ return if kind(control) == :area
989
+
990
+ handler = ->(*) { dispatch(control, event) }
991
+ @closures << handler
992
+ case [kind(control), event]
993
+ when [:button, :click] then ::LibUI.button_on_clicked(control, handler)
994
+ when [:entry, :change] then ::LibUI.entry_on_changed(control, handler)
995
+ when [:checkbox, :change] then ::LibUI.checkbox_on_toggled(control, handler)
996
+ else raise ArgumentError, "#{describe(control)} 不支持事件 #{event.inspect}"
997
+ end
998
+ end
999
+
1000
+ def dispatch(control, event)
1001
+ subs = @subscribers[[key(control), event]]
1002
+ return if subs.nil? || subs.empty?
1003
+
1004
+ subs.dup.each do |block|
1005
+ safe("#{describe(control)} 的 #{event} 回调") { block.call }
1006
+ end
1007
+ end
1008
+
1009
+ # 回调体一律不把异常抛回 C/Objective-C 栈(Fiddle 闭包里抛出会走未定义路径,
1010
+ # 轻则丢事件重则崩进程);代价是这里"只报不抛":事件处理器里的异常打到
1011
+ # stderr 并继续跑主循环,而不是把整个 GUI 带走。
1012
+ def safe(context)
1013
+ yield
1014
+ rescue StandardError => e
1015
+ warn "[citrine-native] #{context} 抛出 #{e.class}: #{e.message}"
1016
+ warn(e.backtrace.first(8).map { |line| " #{line}" }.join("\n"))
1017
+ nil
1018
+ end
1019
+ end
1020
+ end
1021
+ end
1022
+ end