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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +46 -0
- data/GOALS.md +361 -0
- data/LICENSE +21 -0
- data/README.md +293 -0
- data/lib/citrine/native/app.rb +126 -0
- data/lib/citrine/native/area_handle.rb +49 -0
- data/lib/citrine/native/painter.rb +625 -0
- data/lib/citrine/native/pointer_event.rb +48 -0
- data/lib/citrine/native/renderer.rb +884 -0
- data/lib/citrine/native/style_matrix.rb +127 -0
- data/lib/citrine/native/timer.rb +88 -0
- data/lib/citrine/native/version.rb +9 -0
- data/lib/citrine/native/widgets/libui.rb +1022 -0
- data/lib/citrine/native/widgets/memory.rb +526 -0
- data/lib/citrine/native/widgets.rb +179 -0
- data/lib/citrine/native.rb +89 -0
- data/lib/citrine-native.rb +5 -0
- metadata +130 -0
|
@@ -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
|