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,884 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "citrine"
4
+ require "citrine/renderer"
5
+ require_relative "widgets"
6
+
7
+ module Citrine
8
+ module Native
9
+ # 原生控件渲染器:`Citrine::Renderer` 的平台钩子实现(GOALS 4.2 的映射表)。
10
+ #
11
+ # 节点树管理、Effect 装配、块级重建、keyed 复用、透明容器全部由基类负责——
12
+ # 本类只做"节点 → 控件"的翻译,并且**不直接调 libui API**(一律经 Widgets 适配层,
13
+ # 换 GTK 后端时本文件不动)。
14
+ #
15
+ # 与 DOM 渲染器的两处语义差异(GOALS 第五节,实测结论见变更日志 N1):
16
+ # - `node.dom` 是控件句柄(Fiddle::Pointer),`ref:` 拿到的是控件而非元素
17
+ # - 没有 CSS:样式只映射 gap(→ 容器内边距)与 flex_grow(→ 追加时的 stretchy),
18
+ # 其余样式键在 dev_mode 下提醒;键盘事件不支持(libui 的 entry 不暴露按键)
19
+ class Renderer < Citrine::Renderer
20
+ # DSL 元素 → 控件(GOALS 4.3 的元素词表;area 见 docs/design/native-area.md)
21
+ ELEMENTS = { box: :box, label: :label, button: :button,
22
+ text_input: :entry, check_box: :checkbox, area: :area }.freeze
23
+
24
+ # 样式键的落点登记在 StyleMatrix(docs/design/style-matrix.md 是同一份表的散文版):
25
+ # 本类只实现 :mapped 的落地(apply_padding / stretchy?)与 :painted 里 area 的
26
+ # 视觉底板(paint_area_style),其余由 warn_unsupported_style 按矩阵提醒。
27
+
28
+ # 每个元素支持的事件 prop(其余 on_* 在 dev_mode 下提醒)
29
+ SUPPORTED_EVENTS = {
30
+ box: [].freeze, label: [].freeze, button: %i[on_click].freeze,
31
+ text_input: %i[on_change].freeze, check_box: %i[on_change].freeze,
32
+ area: %i[on_draw on_click on_mouse_down on_mouse_up on_mouse_move on_key].freeze
33
+ }.freeze
34
+
35
+ # 适配层的指针事件 → 应用声明的处理器(设计 2.1/2.3)
36
+ POINTER_PROPS = { down: :on_mouse_down, up: :on_mouse_up, move: :on_mouse_move }.freeze
37
+
38
+ # 适配层的指针事件 → PointerEvent#type
39
+ POINTER_TYPES = { down: "mouse_down", up: "mouse_up", move: "mouse_move" }.freeze
40
+
41
+ # 本后端**自己消费**的 prop(不是"原样透传给平台"的)。
42
+ # 走基类的 passthrough_prop? 钩子一次性生效于两处:基类的"Proc 不会被求值"
43
+ # 提醒(watch: 就是故意收 Proc)、本类的"属性没有对应概念"提醒与 passthrough_props。
44
+ CONSUMED_PROPS = { area: %i[size scroll watch].freeze }.freeze
45
+ NO_CONSUMED_PROPS = [].freeze
46
+
47
+ # 未支持元素/属性的替代建议(报错与提醒里带上,别让用户自己猜)。
48
+ # 覆盖度由 test/element_event_matrix_test.rb 锁住:核心元素词表
49
+ # (Citrine::Component::ELEMENT_TAGS)里每个不在 ELEMENTS 的标签都必须在这里有条目,
50
+ # 否则用户拿到的是一个没有出路的报错。
51
+ ELEMENT_HINTS = {
52
+ textarea: "多行输入对应 libui 的 uiNewMultilineEntry,v0 未接(见 GOALS Roadmap N4)",
53
+ select: "下拉选择对应 uiNewCombobox/uiNewRadioButtons,v0 未接(见 GOALS Roadmap N4)",
54
+ option: "选项属于下拉选择(select):uiNewCombobox 的条目在创建时定死,v0 未接",
55
+ table: "表格对应 uiNewTable,能力有限,v0 未接(见 GOALS Roadmap N4)",
56
+ thead: "表头属于表格:原生没有表格分区,整张表请用 element(:area, on_draw: …) 自绘",
57
+ tbody: "表体属于表格:同上,整张表自绘(Painter 画网格与单元格)",
58
+ tr: "表格行属于表格:同上,自绘(Painter 里按行距铺)",
59
+ td: "表格单元格属于表格:同上,自绘(参考 citrine-market-terminal 的表格画法)",
60
+ th: "表头单元格属于表格:同上,自绘",
61
+ img: "原生控件没有图片元素,可用 element(:area, on_draw: …) 自绘",
62
+ a: "原生控件没有超链接,改用 button + on_click",
63
+ ul: "列表请用 stack { } + label { } 组合",
64
+ ol: "有序列表同 ul:stack { } + label { },序号写在文案里",
65
+ li: "列表项请用 label { }",
66
+ form: "表单请用 stack { } 组合",
67
+ span: "行内文本请并入相邻 label 的字符串",
68
+ video: "原生控件没有视频元素",
69
+ audio: "原生控件没有音频元素"
70
+ }.freeze
71
+
72
+ attr_reader :widgets, :window
73
+
74
+ def initialize(widgets: nil)
75
+ @widgets = widgets || Widgets.default
76
+ @window = nil
77
+ @warned = {}
78
+ @areas = [] # 活着的自绘面板句柄(收敛时兜底重绘用)
79
+ @pressed = {} # 面板节点的 object_id => 按下的键号(合成 click 用)
80
+ @window_keys = {} # 组件 => [window_key 处理器](面板转发全局键盘,见 register_window_key)
81
+ # 活动后端(Timer 的跨线程通道,与核心"活动渲染器 = 最近挂载的那个"同口径)
82
+ Citrine::Native.active_widgets = @widgets
83
+ super()
84
+ end
85
+
86
+ private
87
+
88
+ # 响应式渲染:信号驱动的原地更新正是本运行时的存在意义
89
+ def reactive? = true
90
+
91
+ # element 语义重定义为窗口描述:创建窗口 + 根容器(GOALS 4.2)
92
+ def setup_root(root, element)
93
+ @tree_root = root # 挂载期提醒要遍历整棵树(见 warn_strict_stretch_chain)
94
+ options = element || {}
95
+ @window = @widgets.create_window(
96
+ title: options.fetch(:title, "Citrine"),
97
+ width: options.fetch(:width, 640),
98
+ height: options.fetch(:height, 480),
99
+ margined: options.fetch(:margined, true)
100
+ )
101
+ @root_container = @widgets.create_box(:column)
102
+ @widgets.window_set_child(@window, @root_container)
103
+ root.dom = @root_container
104
+ end
105
+
106
+ def create_dom(node)
107
+ case node.type
108
+ when :box then @widgets.create_box(box_direction(node))
109
+ when :label then @widgets.create_label("")
110
+ when :button then @widgets.create_button("")
111
+ when :text_input then @widgets.create_entry(password: node.props[:type].to_s == "password")
112
+ when :check_box then @widgets.create_checkbox("")
113
+ when :area then create_area(node)
114
+ else unsupported_element!(node)
115
+ end
116
+ end
117
+
118
+ # 自绘面板(设计 2.1):size:/scroll: 只在创建时生效(libui 的面板种类与内容
119
+ # 尺寸定了就不能改)。size: 的两条实测约束(GOALS 变更日志 NA-1):
120
+ # scroll: true → uiNewScrollingArea 的内容尺寸,**必需**(且滚动面板下
121
+ # Draw 报的 AreaWidth/Height 恒为 0,Painter 尺寸只能来自它)
122
+ # scroll: false → 面板尺寸由外层容器布局决定;uiAreaSetSize 只对滚动面板可用,
123
+ # 对非滚动面板调用会让 libui 直接 abort 进程,所以只能提醒并忽略
124
+ def create_area(node)
125
+ scroll = node.props[:scroll] == true
126
+ size = area_size(node)
127
+ if scroll && size.nil?
128
+ raise Error, "[citrine-native] area 的 scroll: true 需要同时给 size: [宽, 高]:" \
129
+ "libui 的滚动内容尺寸在创建时定死(uiNewScrollingArea)," \
130
+ "而滚动面板下 Draw 不报尺寸(ui.h: only defined for nonscrolling areas)"
131
+ end
132
+ warn_ignored_area_size if !scroll && size && Citrine.dev_mode?
133
+
134
+ area = @widgets.create_area(size: scroll ? size : nil, scroll: scroll)
135
+ @areas << area
136
+ area
137
+ end
138
+
139
+ def warn_ignored_area_size
140
+ warn_once(:area_size, "[citrine-native] area 的 size: 在 scroll: false 时不生效" \
141
+ "(libui 的 uiAreaSetSize 只对滚动面板可用,对非滚动面板调用会终止进程)," \
142
+ "面板尺寸由外层容器布局决定——要固定尺寸请用 scroll: true")
143
+ end
144
+
145
+ def area_size(node)
146
+ size = node.props[:size]
147
+ return nil if size.nil?
148
+
149
+ if size.is_a?(Proc)
150
+ return nil unless Citrine.dev_mode?
151
+
152
+ warn_once(:area_size_proc, "[citrine-native] area 的 size 传了 Proc:面板尺寸与种类在创建时定死," \
153
+ "不能随信号变化,已忽略——请用静态 [宽, 高]")
154
+ return nil
155
+ end
156
+
157
+ values = Array(size)
158
+ unless values.size == 2 && values.all? { |value| value.is_a?(Numeric) }
159
+ raise Error, "[citrine-native] area 的 size 应为 [宽, 高](数字像素),收到 #{size.inspect}"
160
+ end
161
+ values
162
+ end
163
+
164
+ def attach(node, parent)
165
+ # 容器是窗口的唯一直系子控件;其余一律追加到父容器的末尾(基类约定)
166
+ @widgets.box_append(parent.dom, node.dom, stretchy: stretchy?(node))
167
+ end
168
+
169
+ # S1-2:子组件 view 重跑的落位。libui 的 box 没有 insert-at,但
170
+ # `uiBoxDelete` 只摘除不销毁(实测),因此适配层能无损重排——
171
+ # 结论:**不需要容器级重建**,"细粒度更新"的卖点在本后端成立。
172
+ # anchor 可能是透明容器(fragment/portal/suspense,自己没有控件)→ 取它的首个控件。
173
+ def attach_before(node, parent, anchor)
174
+ child = widget_of(node)
175
+ return unless child
176
+
177
+ @widgets.box_move_before(parent.dom, child, widget_of(anchor))
178
+ end
179
+
180
+ # 摘除 + 销毁(适配层负责先摘后销毁:libui 的 destroy 要求控件不带父容器)
181
+ def detach(node)
182
+ @areas.delete(node.dom)
183
+ @pressed.delete(node.object_id)
184
+ @widgets.destroy(node.dom)
185
+ end
186
+
187
+ # ── 面板重绘(设计 2.4)────────────────────────────────
188
+ # 两条一起用:
189
+ # 1. watch:(可选)跑在该节点的 Effect 里(见 setup_area)——依赖变化即排该面板
190
+ # 2. 粗粒度兜底:每次响应式收敛(最外层)把所有活着的面板排一次
191
+ # uiAreaQueueRedrawAll 本身就是"标脏 + 合并进下一帧"(macOS 下是 setNeedsDisplay),
192
+ # 同一轮里重复排不会多画一帧,所以兜底不需要更细的脏标记。
193
+ # 窗口尺寸变化不用管:libui 的 areaView 在 setFrameSize 里已自己标脏(darwin/area.m)。
194
+ def repaint_areas
195
+ @areas.each { |area| @widgets.area_queue_redraw(area) }
196
+ end
197
+
198
+ # 幂等:响应式属性/样式重跑会再次调用
199
+ def apply_props(node)
200
+ apply_padding(node) if node.type == :box
201
+ apply_enabled(node)
202
+ warn_unsupported_style(node)
203
+ warn_unsupported_props(node)
204
+ end
205
+
206
+ # ── 响应式收敛的三个收尾点(设计 2.4)──────────────────
207
+ # @parents.empty? = 最外层收敛:嵌套挂载/重跑期间不排重绘,整棵树落定后一次排完
208
+ # (与 canvas 后端"最外层 settle 才重绘"同思路)。
209
+
210
+ def finalize(_node)
211
+ return unless @parents.empty?
212
+
213
+ repaint_areas
214
+ warn_strict_stretch_chain
215
+ end
216
+
217
+ # 块级重建(信号驱动)也发生在最外层 Effect 里:收尾补一次兜底重绘
218
+ def run_block(node)
219
+ super
220
+ ensure
221
+ repaint_areas if @parents.empty?
222
+ end
223
+
224
+ # 子组件 view 重跑(S1-2)同理:重跑完把面板刷新,否则"信号变了但画面没变"
225
+ def rerun_component_view(child, parent)
226
+ super
227
+ ensure
228
+ repaint_areas if @parents.empty?
229
+ end
230
+
231
+ # 挂载时绑一次;闭包在**派发时**从 node.props 现取处理器,因此 keyed 复用后
232
+ # 换上的新处理器自然生效(与 DOM 渲染器同口径,复用时不需要重绑)
233
+ def bind_events(node)
234
+ case node.type
235
+ when :button
236
+ @widgets.on_click(node.dom) { dispatch_event(node, :on_click, Citrine::Event.new("click", raw: node.dom)) }
237
+ when :text_input
238
+ @widgets.on_change(node.dom) do
239
+ # 受控语义:先把控件值写回 Signal,处理器读到的是新值(与 DOM 侧 check_box 同口径)
240
+ write_back_value(node)
241
+ dispatch_event(node, :on_change, @widgets.get_value(node.dom))
242
+ end
243
+ when :check_box
244
+ @widgets.on_change(node.dom) do
245
+ checked = @widgets.checked?(node.dom)
246
+ write_back_checked(node, checked)
247
+ dispatch_event(node, :on_change, checked)
248
+ end
249
+ when :area
250
+ bind_area_events(node)
251
+ end
252
+ end
253
+
254
+ # 面板事件(设计 2.3):适配层给的是平台无关的形态(绘制器 / 指针事件 Hash /
255
+ # 已归一键名的键盘事件 Hash),这里变成 Citrine 的事件视图交给组件。
256
+ def bind_area_events(node)
257
+ @widgets.on_area_draw(node.dom) do |painter|
258
+ paint_area_style(node, painter)
259
+ handler = node.props[:on_draw]
260
+ node.owner.handle_event(handler, painter) if handler
261
+ # 绘制期的提醒(颜色写错、align 缺 width…)按 dev_mode 去重输出:画一次说一次
262
+ report_painter_warnings(painter)
263
+ warn_starved_area(node, painter) if Citrine.dev_mode?
264
+ end
265
+ @widgets.on_area_pointer(node.dom) { |event| dispatch_pointer(node, event) }
266
+ @widgets.on_area_key(node.dom) { |event| dispatch_area_key(node, event) }
267
+ end
268
+
269
+ # ── 面板的视觉底板(L2:样式的 :painted 组里 area 自动消费的那几个键)──────
270
+ #
271
+ # 应用给 area 写 style: { background: …, border: …, border_radius: … } 时,框架在
272
+ # **on_draw 之前**画一次底板,应用只管内容——两个 demo 里手写的
273
+ # `painter.rect(0, 0, w, h, fill: Theme::PANEL, stroke: Theme::LINE)` 由此收进框架。
274
+ #
275
+ # 每帧现读 node.props[:style](经 resolve_style):样式是 Proc 时也跟着重画,
276
+ # 与响应式属性同口径。原生控件没有这项能力(libui 的 box/label 无法着色),
277
+ # 所以非 area 元素上的这些键仍由 warn_unsupported_style 提醒。
278
+ def paint_area_style(node, painter)
279
+ style = resolve_style(node)
280
+ fill = style[:background]
281
+ width, stroke = border_of(style)
282
+ radius = style[:border_radius]
283
+
284
+ fill = :none if fill.nil?
285
+ stroke = :none if stroke.nil?
286
+ return if fill == :none && stroke == :none && radius.nil?
287
+
288
+ painter.rect(0, 0, painter.width, painter.height,
289
+ fill: fill, stroke: stroke,
290
+ line_width: width || 1, radius: radius || 0)
291
+ end
292
+
293
+ # border 的三种写法(够用即止,不做 CSS 解析器):
294
+ # border: "1px solid #1e2c48" 简写(只画实线,dashed/dotted 提醒后按实线)
295
+ # border: "#1e2c48" 只有颜色 → 1px
296
+ # border: { width: 2, color: … } 或 border_color / border_width 分开写
297
+ def border_of(style)
298
+ width = positive_number(style[:border_width])
299
+ color = normalize_border_color(style[:border_color])
300
+ shorthand = style[:border]
301
+
302
+ case shorthand
303
+ when Hash
304
+ width ||= positive_number(shorthand[:width])
305
+ color ||= normalize_border_color(shorthand[:color])
306
+ when String
307
+ width, color = parse_border_shorthand(shorthand, width, color)
308
+ end
309
+
310
+ return [nil, nil] if color.nil?
311
+
312
+ [width || 1.0, color]
313
+ end
314
+
315
+ def parse_border_shorthand(text, width, color)
316
+ stripped = text.strip
317
+ return [width, color] if stripped.empty? || stripped == "none"
318
+
319
+ if (match = /\A(\d+(?:\.\d+)?)px\b(.*)\z/.match(stripped))
320
+ width ||= match[1].to_f
321
+ rest = match[2]
322
+ warn_dashed_border(rest)
323
+ color ||= normalize_border_color(rest.sub(/\A\s*(solid|dashed|dotted)\b/, "").strip)
324
+ else
325
+ color ||= normalize_border_color(stripped) # 只有颜色
326
+ end
327
+ [width, color]
328
+ end
329
+
330
+ def warn_dashed_border(rest)
331
+ return unless Citrine.dev_mode?
332
+ return unless rest.match?(/\b(dashed|dotted)\b/)
333
+
334
+ warn_once(:border_style, "[citrine-native] border 只画实线(solid):dashed / dotted " \
335
+ "没有对应,按实线画(见 docs/design/style-matrix.md)")
336
+ end
337
+
338
+ def normalize_border_color(value)
339
+ return nil if value.nil?
340
+
341
+ text = value.to_s.strip
342
+ return nil if text.empty? || text == "none" || value == :none
343
+
344
+ value
345
+ end
346
+
347
+ def positive_number(value)
348
+ number = value.to_f
349
+ number.positive? ? number : nil
350
+ end
351
+
352
+ # 指针事件归一:适配层给 :down/:up/:move,这里映射成 PointerEvent 的
353
+ # mouse_down/mouse_up/mouse_move;click 由"在本面板按下又抬起"合成
354
+ # (libui 没有 DOM 的 click,只有 Down/Up 与 Count)。
355
+ def dispatch_pointer(node, event)
356
+ kind = event[:kind]
357
+ button = event[:button].to_i
358
+ pressed = kind == :up ? @pressed.delete(node.object_id) : @pressed[node.object_id]
359
+ @pressed[node.object_id] = button if kind == :down
360
+
361
+ prop = POINTER_PROPS[kind]
362
+ dispatch_pointer_event(node, prop, pointer_event(node, event, POINTER_TYPES.fetch(kind, kind.to_s))) if prop
363
+
364
+ return unless kind == :up && pressed == button
365
+
366
+ # DOM 顺序:mouseup 之后才 click;拖动后仍在同面板抬起照样算 click(DOM 同语义)
367
+ dispatch_pointer_event(node, :on_click, pointer_event(node, event, "click"))
368
+ end
369
+
370
+ def pointer_event(node, event, type)
371
+ PointerEvent.new(type, x: event[:x], y: event[:y],
372
+ button: event[:button], modifiers: event[:modifiers], raw: event)
373
+ end
374
+
375
+ def dispatch_pointer_event(node, prop, event)
376
+ handler = node.props[prop]
377
+ return unless handler
378
+
379
+ node.owner.handle_event(handler, event)
380
+ end
381
+
382
+ # 键盘(设计 2.3):键名已由适配层归一成 DOM 风格("ArrowUp"/"Enter"/"a"…),
383
+ # 这里包成核心既有的 Citrine::KeyEvent。抬起不投递(v0 没有 on_key_up)。
384
+ # 顺序与 DOM 冒泡一致:先本面板的 on_key,再转发给 window_key 的全局处理器。
385
+ # 返回值 = "有处理器认领了这次按键"(libui 据此决定要不要走系统默认处理)。
386
+ #
387
+ # **⌘ 组合键一律回报"未处理"**——回调照常触发(应用自己处理的 ⌘Z/⌘B 不受影响),
388
+ # 只是不抑制系统默认处理:libui 的 KeyEvent 回调**先于**菜单快捷键执行,认领它
389
+ # 会让 ⌘H(Hide)这类有绑定的菜单项在焦点落到面板时失效(NA-2 的真 OS 投递对照实验:
390
+ # 声明 on_key 的面板把 ⌘H 吃掉了;NA-1c 修复 + 真 OS 复验)。
391
+ # 策略**单点在这里**:适配层只如实转达本方法的答复(桩后端与真后端因此同口径,
392
+ # 也才有测试锁得住)。见 design 2.3 / 5.3 与变更日志 NA-1c。
393
+ def dispatch_area_key(node, event)
394
+ return false if event[:up]
395
+
396
+ # 适配层的契约就是 {shift:, ctrl:, alt:, meta:} 四个键(见 widgets.rb 的协议说明):
397
+ # KeyEvent 的默认值负责缺省,所以这里直接把哈希铺开,不再自己造一遍
398
+ key_event = KeyEvent.new(event[:key], **(event[:modifiers] || {}), raw: event)
399
+ handler = node.props[:on_key]
400
+ handled = false
401
+ if handler
402
+ node.owner.handle_key(handler, key_event)
403
+ handled = true
404
+ end
405
+ handled = forward_window_key(node, key_event) || handled
406
+ handled && !key_event.meta?
407
+ end
408
+
409
+ # window_key(G-9 / 设计 2.3):原生没有 window 级 keydown,唯一拿得到按键的
410
+ # 控件是自绘面板——所以全局快捷键的语义是"焦点在某个面板上时可用",由收到按键
411
+ # 的那个面板转发。scope: :focused 时要求面板在该组件的子树里(DOM 侧是
412
+ # activeElement 落在组件 root 内)。
413
+ def forward_window_key(node, key_event)
414
+ return false if @window_keys.empty?
415
+
416
+ handled = false
417
+ @window_keys.dup.each do |component, handlers|
418
+ handlers.dup.each do |handler|
419
+ scoped = handler.is_a?(Component::WindowKey)
420
+ next if scoped && !focused_in?(component, node)
421
+
422
+ component.handle_key(scoped ? handler.handler : handler, key_event)
423
+ handled = true
424
+ end
425
+ end
426
+ handled
427
+ end
428
+
429
+ # "焦点"在原生侧没有查询 API(libui-ng 没有 uiControlSetFocus,见 GOALS 变更日志
430
+ # NA-1):焦点就是"哪个面板收到了按键",因此这里判的是节点树的归属
431
+ def focused_in?(component, node)
432
+ root = component.respond_to?(:root) ? component.root : nil
433
+ root ? subtree_includes?(root, node) : false
434
+ end
435
+
436
+ def subtree_includes?(root, node)
437
+ return true if root.equal?(node)
438
+
439
+ root.children.any? { |child| subtree_includes?(child, node) }
440
+ end
441
+
442
+ # 绘制期提醒:Painter 每帧新建(自己 warn 会刷屏),按 key 在渲染器这边去重
443
+ def report_painter_warnings(painter)
444
+ return unless Citrine.dev_mode?
445
+
446
+ painter.warnings.each { |key, message| warn_once([:painter, key], message) }
447
+ end
448
+
449
+ # 比一行 14pt 文本还矮/还窄的面板几乎不可能是有意设计(Painter 的文本外接矩形
450
+ # 就已经 ~17pt 高)。启发式阈值,见 warn_starved_area 的说明。
451
+ MIN_USABLE_PANEL = 24.0
452
+
453
+ # 面板被压扁的 dev_mode 提醒(P2.1 / SHEETS D2 的坑;NA-1d 扩了判据与提示文本,
454
+ # NA-1e 把判据 ② 限定到非滚动面板)。
455
+ #
456
+ # libui 的 box 布局里拿不到空间的控件会被 Auto Layout 解成 0×0——**不报错**,
457
+ # 应用只看到"面板不见了"(实测:`stack { label; area; area }` 里两个面板互相抢,
458
+ # 被压的那个 0 高,还会把兄弟挤扁)。三条判据分开报,因为可操作建议不同:
459
+ # ① Painter 拿到的尺寸是 0/负(就是上面那个 0×0;滚动面板声明 size: [0, x] 也在此列);
460
+ # ② **非滚动**面板的 Painter 尺寸非 0 但**小到画不出东西**(非滚动面板下 Painter
461
+ # 尺寸就是控件的真实 frame:NA-2 实测被兄弟挤成 753×16 的面板,旧口径一言不发);
462
+ # ③ 滚动面板的**真实可见视口**小到画不出东西——滚动面板下 Painter 拿到的是声明的
463
+ # **内容**尺寸(2000×2000),视口塌成 736×16 时旧口径同样一言不发,而这是
464
+ # SHEETS-2 现场最像的形状。视口由后端给(libui 读 clip view 的真实边界),
465
+ # 后端说"没有额外几何"(nil)时这条跳过。
466
+ #
467
+ # ⚠️ 判据 ② **只对非滚动面板**(NA-1e):滚动面板下 Painter 的宽高是**声明的内容
468
+ # 尺寸**,一个健康的面板只要声明了 size: [2000, 20](横向缩略图条)就会被 ② 误报
469
+ # "控件被挤成一条:2000.0×20.0"(真 GUI 里它实到 760×544、视口 743×527)。滚动面板的
470
+ # "小"只有视口说了算,也就是判据 ③;后端不给几何时**宁可不报也不误报**(盲区见 §5.1)。
471
+ #
472
+ # ②③ 共用阈值 MIN_USABLE_PANEL:它是**启发式**——"比一行 14pt 文本还矮的面板
473
+ # 几乎不可能是有意设计"。真要做细条就把 dev_mode 关掉(提醒只服务开发期)。
474
+ # 剩余的盲区如实写在文档 §5.1:拿不到真实几何的后端(非 macOS)下,滚动面板的
475
+ # 视口塌陷报不出来;判据 ③ 的数字取自**当帧**(首帧可能是瞬态读数)。
476
+ #
477
+ # 判据 ③ 的视口数字是后端在**当帧**读到的原始值:滚动面板**首帧**可能还没布局完
478
+ # (libui 在同一次 Draw 里才设 document view 的 frame,而这次读在它之前),此时
479
+ # `visibleRect` 报的是 NSScrollView 自己的尺寸——含滚动条位。本机当场复现(NA-1e
480
+ # 真 GUI 探针):提示里打 712.5×16,0.8 秒后的稳态可见区是 695.5×16(差 17pt =
481
+ # 滚动条宽;NA-1d 记录的首帧 `clip_rect` 瞬态是同一件事,见 §5.7.6-6)。触发与去重
482
+ # 不受影响(每帧都查、按节点去重),**只有消息里的数字可能偏大**——如实标注,不假装
483
+ # 它是稳态值。NA-1e 在"取稳态值 / 标注瞬态"里选了后者:延迟一帧再报会让"只画一帧"
484
+ # 的形状漏报(桩测与真冒烟都是单帧断言),而拿"读数 == NSScrollView 的 frame"猜瞬态
485
+ # 在 overlay 滚动条下会把正常视口也判成瞬态。
486
+ def warn_starved_area(node, painter)
487
+ width = painter.width
488
+ height = painter.height
489
+ scrolling = @widgets.area_scrollable?(node.dom)
490
+ if !(width.positive? && height.positive?)
491
+ warn_once([:area_starved, node.object_id], squeezed_area_message("拿到了 0 尺寸", [width, height]))
492
+ elsif !scrolling && [width, height].min < MIN_USABLE_PANEL
493
+ warn_once([:area_squeezed, node.object_id], squeezed_area_message("控件被挤成一条", [width, height]))
494
+ end
495
+
496
+ viewport = @widgets.area_visible_size(node.dom)
497
+ return if viewport.nil?
498
+
499
+ return if [viewport[0], viewport[1]].min >= MIN_USABLE_PANEL
500
+
501
+ warn_once([:area_viewport_squeezed, node.object_id],
502
+ squeezed_area_message("真实可见视口", viewport,
503
+ "(Painter 拿到的是声明的内容尺寸 #{format_size([width, height])};" \
504
+ "视口是当帧原始读数,滚动面板首帧可能报成 NSScrollView 的尺寸(偏大))"))
505
+ end
506
+
507
+ # 提示必须对"已经给了 flex_grow 还是被压"的形状也可操作(NA-2 实测的那个形状里
508
+ # 面板自己有 flex_grow 却被压成 0 宽,旧提示让它"给 flex_grow"是空转)。
509
+ # 正确判据(NA-2 用两个反例否证了"每个嵌套 box 都要有一个 stretchy 子控件"):
510
+ # **参与拉伸的 box 自己在父容器里要有 stretchy 尺寸**,逐层往上都成立才撑得开。
511
+ def squeezed_area_message(what, size, extra = nil)
512
+ "[citrine-native] 这个面板被压扁了(#{what}:#{format_size(size)}#{extra})," \
513
+ "这么小画不出可见内容。面板能不能撑开,取决于**它所在的每一层容器在各自父容器里" \
514
+ "有没有 stretchy 尺寸**:只给面板自己 style: { flex_grow: 1 } 不够——外层那个" \
515
+ "嵌套 box 也要有(或者别嵌套,把面板直接放进要拉伸的那一层)。`flex_grow` 在 " \
516
+ "libui 里是**布尔**\"吃掉剩余空间\"、不是权重(同一层里两个 stretchy 兄弟等分)。" \
517
+ "滚动面板还要给 size:(内容尺寸)。判据与实测反例见 docs/design/native-area.md §5.7.2"
518
+ end
519
+
520
+ def format_size(size)
521
+ "#{size[0]}×#{size[1]}"
522
+ end
523
+
524
+ # ── 挂载期的"严格后端上会塌"提醒(backlog F24;判据同 F11 / §5.7.2)──────
525
+ #
526
+ # 为什么另开一条(draw 期的 warn_starved_area 不够):Windows 的 libui 对
527
+ # stretchy 链断开的 area 是**完全**的 0×0,`WM_PAINT` 不会来 → Draw 不跑 →
528
+ # 那条提醒永远不会亮(macOS 上 0×0 面板仍有 Draw,所以只在 Windows 上看得见)。
529
+ # 这里做的是**静态判据**(不看几何、不求几何):面板自己能 stretchy,且从组件根
530
+ # 往下的每一层 box 在各自父容器里都 stretchy,逐层成立才能真正拿到剩余空间。
531
+ # 因此措辞说"在严格后端上会塌",不假装量到了尺寸;只在 dev_mode 下提醒、按节点去重。
532
+ def warn_strict_stretch_chain
533
+ return unless Citrine.dev_mode?
534
+ return unless @tree_root
535
+
536
+ @tree_root.children.each { |child| check_stretch_chain(child, true, nil, 1) }
537
+ end
538
+
539
+ # chain_ready:从组件根到这里的 box 链是否**每层**都 stretchy
540
+ # breaker / depth:第一个断掉的 box(`[节点, 层号]`,层号从组件根数起、1 基)——
541
+ # 用于把"链在哪断了"说清楚,而不是让应用自己去猜哪一层
542
+ def check_stretch_chain(node, chain_ready, breaker, depth)
543
+ case node.type
544
+ when :area
545
+ return if chain_ready && stretchy?(node)
546
+
547
+ warn_once([:area_strict_chain, node.object_id], strict_chain_message(breaker))
548
+ when :box
549
+ if chain_ready && !stretchy?(node)
550
+ chain_ready = false
551
+ breaker ||= [node, depth]
552
+ end
553
+ node.children.each { |child| check_stretch_chain(child, chain_ready, breaker, depth + 1) }
554
+ else
555
+ # 透明容器(fragment / portal / suspense)不占控件层,层号不加
556
+ (node.children || []).each { |child| check_stretch_chain(child, chain_ready, breaker, depth) }
557
+ end
558
+ end
559
+
560
+ def strict_chain_message(breaker)
561
+ where = if breaker.nil?
562
+ "**面板自己**没有 stretchy 尺寸(style: { flex_grow: 1 })"
563
+ else
564
+ node, depth = breaker
565
+ "**祖先里第 #{depth} 层那个 #{node.type}** 没有 stretchy 尺寸(style: { flex_grow: 1 })"
566
+ end
567
+ "[citrine-native] 这个面板撑不开:#{where}。\n" \
568
+ " 为什么现在才知道:严格后端(Windows)下它会是 0×0 且**一次都不绘制**," \
569
+ "绘制期的\"面板被压扁\"提醒因此永远不会亮;macOS 对窗口直系子元素宽容," \
570
+ "同一棵树在那里可能看不出问题(跨平台差异见 docs/design/platform-matrix.md)。\n" \
571
+ " 修法:从组件根到面板,**参与拉伸的每一层 box** 都要自己声明 `style: { flex_grow: 1 }`," \
572
+ "逐层成立才撑得开(`flex_grow` 是布尔不是权重;判据与反例见 docs/design/native-area.md §5.7.2)。\n" \
573
+ " 这个提醒只在 dev_mode 下出现(`dev_mode: false` 可关)。"
574
+ end
575
+
576
+ # ref: :grid → refs[:grid] 拿到的是**面板句柄**(AreaHandle,设计 2.5),不是 libui 裸指针
577
+ def register_ref(node)
578
+ return super unless node.type == :area
579
+
580
+ name = node.props[:ref]
581
+ return unless name && node.owner.respond_to?(:refs)
582
+
583
+ node.owner.refs[name] = AreaHandle.new(widgets: @widgets, handle: node.dom)
584
+ end
585
+
586
+ def set_text(node, text)
587
+ if container?(node)
588
+ return if text.to_s.empty?
589
+
590
+ warn_once(:container_text, "[citrine-native] #{node.type} 的内容 block 返回了字符串" \
591
+ "(#{text.inspect}),但原生容器没有文本位,已忽略:" \
592
+ "#{node.type == :area ? '面板内容请在 on_draw 里画' : '请用 label { ... } 包一层'}")
593
+ return
594
+ end
595
+
596
+ @widgets.set_text(node.dom, text)
597
+ # 镜像进 node.text(与 DOM/SSR 同口径):基类靠它判定"上一轮写过文本",
598
+ # 本轮没内容时才能显式清空(renderer.rb 的 C3 逻辑)
599
+ node.text = text
600
+ end
601
+
602
+ # 受控控件的初值与"信号 → 控件"方向(控件 → 信号在 bind_events)
603
+ def setup_widget(node)
604
+ case node.type
605
+ when :text_input then setup_entry(node)
606
+ when :check_box then setup_checkbox(node)
607
+ when :area then setup_area(node)
608
+ end
609
+ end
610
+
611
+ # 面板:on_draw 必填(面板必须知道怎么画);watch:(设计 2.4)在该节点自己的
612
+ # Effect 里跑——应用在里面读它绘制所依赖的信号,依赖变化就排这个面板重绘。
613
+ # 注意方向:绘制回调(on_draw)在 libui 的 Draw 回调里执行、**不在 Effect 内**,
614
+ # 因此那里的信号读取不建立订阅(否则每帧都会重排订阅)。
615
+ def setup_area(node)
616
+ unless node.props[:on_draw]
617
+ raise Error, "[citrine-native] area 必须提供 on_draw:(自绘面板的绘制回调," \
618
+ "参数是 Painter):element(:area, on_draw: ->(p) { … })"
619
+ end
620
+
621
+ watch = node.props[:watch]
622
+ return if watch.nil?
623
+
624
+ node.owned_effects << Effect.create do
625
+ Citrine.dispatch_callable(watch, node.owner, nil, bind: true)
626
+ @widgets.area_queue_redraw(node.dom)
627
+ end
628
+ end
629
+
630
+ # portal 宿主(S1-5):原生没有 DOM body,语义取"根容器"——
631
+ # 逃出父容器的嵌套布局,落到窗口内容区末尾
632
+ def resolve_portal_host(target)
633
+ return @root_container if target.nil? || target == "" || target == :root
634
+
635
+ raise ArgumentError, "[citrine-native] portal 的 target #{target.inspect} 无法解析:" \
636
+ "原生后端只有根容器一个宿主,请用 target: :root(或省略)"
637
+ end
638
+
639
+ # 全局键盘(G-9 / 设计 2.3):原生没有 window 级 keydown,唯一能拿到按键的控件
640
+ # 是自绘面板——因此这里只登记,分发由收到按键的面板转发(forward_window_key)。
641
+ def register_window_key(component, handler)
642
+ handlers = (@window_keys[component] ||= [])
643
+ handlers << handler unless handlers.include?(handler)
644
+ nil
645
+ end
646
+
647
+ def unregister_window_keys(component)
648
+ @window_keys.delete(component)
649
+ nil
650
+ end
651
+
652
+ # ── 受控控件 ────────────────────────────────────────────
653
+
654
+ def setup_entry(node)
655
+ value = node.props[:value]
656
+ if value.is_a?(Signal)
657
+ node.owned_effects << Effect.create { push_value(node, value.get) }
658
+ elsif !value.nil?
659
+ @widgets.set_value(node.dom, value.to_s)
660
+ end
661
+ end
662
+
663
+ def setup_checkbox(node)
664
+ checked = node.props[:checked]
665
+ if checked.is_a?(Signal)
666
+ node.owned_effects << Effect.create { @widgets.set_checked(node.dom, checked.get ? true : false) }
667
+ else
668
+ @widgets.set_checked(node.dom, checked ? true : false)
669
+ end
670
+ end
671
+
672
+ def push_value(node, value)
673
+ text = value.to_s
674
+ # 值没变就不写控件:libui 每次 setText 都会重置光标位置(正打字时最刺眼)
675
+ @widgets.set_value(node.dom, text) unless @widgets.get_value(node.dom) == text
676
+ end
677
+
678
+ def write_back_value(node)
679
+ value = node.props[:value]
680
+ value.set(@widgets.get_value(node.dom)) if value.is_a?(Signal)
681
+ end
682
+
683
+ def write_back_checked(node, checked)
684
+ signal = node.props[:checked]
685
+ signal.set(checked) if signal.is_a?(Signal)
686
+ end
687
+
688
+ # 事件视图是平台无关的:button 收 Citrine::Event,check_box 收布尔勾选态
689
+ # (与 DOM 侧同口径),text_input 收新文本(原生侧专属,见 GOALS 第五节的差异清单)
690
+ def dispatch_event(node, prop, payload)
691
+ handler = node.props[prop]
692
+ return unless handler
693
+
694
+ node.owner.handle_event(handler, payload)
695
+ end
696
+
697
+ # 覆盖基类钩子:本后端消费掉的 prop(area 的 size/scroll/watch)不算透传属性,
698
+ # 否则它们会被"没有对应概念"和"Proc 不会被求值"两处提醒误报
699
+ def passthrough_prop?(name, node)
700
+ return false if CONSUMED_PROPS.fetch(node&.type, NO_CONSUMED_PROPS).include?(name)
701
+
702
+ super
703
+ end
704
+
705
+ # ── 布局与样式 ──────────────────────────────────────────
706
+
707
+ def box_direction(node)
708
+ direction = node.props[:direction]
709
+ if direction.is_a?(Proc)
710
+ warn_once(:proc_direction, "[citrine-native] box 的 direction 传了 Proc:原生控件的方向在创建时定死," \
711
+ "不能随信号切换,已按默认 row 处理;请用静态方向")
712
+ return :row
713
+ end
714
+
715
+ direction == :column ? :column : :row
716
+ end
717
+
718
+ # 追加时的 stretchy ← 静态 flex_grow / flex(flex-grow 的语义就是"吃掉剩余空间",
719
+ # 与 libui box 的 stretchy 同构)。响应式样式不参与:那会在挂载期读到信号、
720
+ # 把订阅落到外层块上(正是 G-2 要消除的隐性外扩)。键的登记见 StyleMatrix。
721
+ def stretchy?(node)
722
+ style = node.props[:style]
723
+ return false if style.is_a?(Proc)
724
+
725
+ normalized = Style.normalize(style)
726
+ return true if normalized[:flex_grow].to_f.positive?
727
+
728
+ # CSS 的 flex 简写:"1" / "1 1 auto" / "0 1 auto"——取首段数值
729
+ flex = normalized[:flex].to_s.strip
730
+ flex.match?(/\A\d/) && flex.to_f.positive?
731
+ end
732
+
733
+ # 容器内边距 ← gap / padding*(StyleMatrix 的 :mapped 组)。
734
+ # libui 的 box 只有 padded 开关,所以数值按是否 > 0 判定
735
+ def apply_padding(node)
736
+ style = resolve_style(node)
737
+ candidates = [style[:padding], style[:padding_top], style[:padding_right],
738
+ style[:padding_bottom], style[:padding_left], style[:gap]].compact
739
+ return if candidates.empty? # 一个都没声明 → 不动容器默认值
740
+
741
+ @widgets.set_padding(node.dom, candidates.any? { |value| spacing?(value) })
742
+ end
743
+
744
+ # gap / padding 只有"有间距/无间距"两档可映射(libui 的 box 只有 padded 开关);
745
+ # 数值按是否 > 0 判定,非数值(主题 token 等)视为"有间距"
746
+ def spacing?(gap)
747
+ value = gap.to_s
748
+ return true unless value.match?(/\A[\d.]+\s*(px|pt|em|rem)?\z/)
749
+
750
+ value.to_f.positive?
751
+ end
752
+
753
+ # disabled 是 citrine 的透传属性(DOM 侧变成 disabled 属性),原生侧映射到控件禁用态
754
+ def apply_enabled(node)
755
+ return unless node.props.key?(:disabled)
756
+
757
+ @widgets.set_enabled(node.dom, !node.props[:disabled])
758
+ end
759
+
760
+ # 样式键的提醒按 StyleMatrix 的三档状态说话(绝不静默丢弃;GOALS 4.5):
761
+ # :mapped 静音(由 stretchy? / apply_padding 落地)
762
+ # :painted area 上的视觉底板静音(L2 会自动画);其余说清"怎么自绘"
763
+ # :ignored 说清"没有对应概念 + 为什么"
764
+ def warn_unsupported_style(node)
765
+ return unless Citrine.dev_mode?
766
+
767
+ resolve_style(node).each_key do |key|
768
+ next if StyleMatrix.status(key) == StyleMatrix::MAPPED
769
+ next if node.type == :area && StyleMatrix.entry(key).area?
770
+
771
+ warn_once([:style, key], style_warning(key, node))
772
+ end
773
+
774
+ warn_non_flex_display(node)
775
+ end
776
+
777
+ def style_warning(key, node)
778
+ entry = StyleMatrix.entry(key)
779
+ if entry.status == StyleMatrix::PAINTED
780
+ if entry.area?
781
+ "[citrine-native] 样式键 #{key.inspect} 不能映射到 #{node.type}(原生控件无法着色):" \
782
+ "把它移到 element(:area) 上(自绘面板会把它画成底板),或在 on_draw 里自绘。" \
783
+ "见 docs/design/style-matrix.md"
784
+ else
785
+ "[citrine-native] 样式键 #{key.inspect} 在原生后端不支持自动映射(需要自绘):" \
786
+ "在 element(:area) 的 on_draw 里用 #{entry.mapping}。见 docs/design/style-matrix.md"
787
+ end
788
+ else
789
+ "#{"[citrine-native] 样式键 #{key.inspect} 在原生后端没有对应概念(libui 无 CSS),已忽略"}" \
790
+ "#{StyleMatrix.registered?(key) ? "(#{entry.note})" : "(未在矩阵中登记)"}。" \
791
+ "见 docs/design/style-matrix.md"
792
+ end
793
+ end
794
+
795
+ # display 只有 flex 有对应:基类给 box 合成的 display: "flex" 静音,
796
+ # 用户显式写的 grid 等值要提醒(否则"以为布局生效了")
797
+ def warn_non_flex_display(node)
798
+ display = resolve_style(node)[:display]
799
+ return if display.nil? || display.to_s == "flex"
800
+
801
+ warn_once([:style, :display_value],
802
+ "[citrine-native] display: #{display.inspect} 在原生后端没有对应概念" \
803
+ "(只支持 flex 布局,stack / row 就是它的两种方向)。见 docs/design/style-matrix.md")
804
+ end
805
+
806
+ def warn_unsupported_props(node)
807
+ return unless Citrine.dev_mode?
808
+
809
+ passthrough_props(node).each do |(name, _value)|
810
+ next if name == "disabled"
811
+
812
+ warn_once([:prop, name], "[citrine-native] 属性 #{name.inspect}(#{node.type})在原生后端没有对应概念,已忽略")
813
+ end
814
+
815
+ warn_unsupported_events(node)
816
+ warn_placeholder(node)
817
+ warn_css_class(node)
818
+ end
819
+
820
+ # css_class 是"给 CSS 用的名字":原生没有 CSS,落不到控件上。
821
+ # 它是被框架消费的属性(不进 passthrough),不提醒就是静默丢弃——
822
+ # 而布局意图(哪些格子/面板该拉伸)经常就藏在 class 背后。
823
+ def warn_css_class(node)
824
+ return if node.props[:css_class].nil?
825
+
826
+ warn_once(:css_class, "[citrine-native] css_class 在原生后端没有对应概念(没有 CSS),已忽略:" \
827
+ "布局意图请改用 gap(间距)与 style: { flex_grow: 1 }(吃掉剩余空间)")
828
+ end
829
+
830
+ def warn_unsupported_events(node)
831
+ supported = SUPPORTED_EVENTS.fetch(node.type, [])
832
+ node.props.each_key do |name|
833
+ next unless name.to_s.start_with?("on_")
834
+ next if supported.include?(name)
835
+
836
+ warn_once([:event, node.type, name],
837
+ "[citrine-native] #{node.type} 的 #{name} 在原生后端不支持" \
838
+ "(v0 支持:#{supported.empty? ? '无' : supported.map(&:inspect).join(' / ')}),已忽略")
839
+ end
840
+ end
841
+
842
+ def warn_placeholder(node)
843
+ return unless node.type == :text_input && node.props[:placeholder]
844
+
845
+ warn_once([:placeholder], "[citrine-native] text_input 的 placeholder 在原生后端不支持" \
846
+ "(libui 的 entry 没有占位文本),已忽略:可用相邻 label 说明")
847
+ end
848
+
849
+ def warn_once(key, message)
850
+ return if @warned[key]
851
+
852
+ @warned[key] = true
853
+ warn message
854
+ end
855
+
856
+ # ── 控件定位 ────────────────────────────────────────────
857
+
858
+ # 透明容器(fragment/portal/suspense)自己没有控件(dom 借的是父容器的),
859
+ # 落位锚点要用它的首个真控件(与 DomRenderer 处理 fragment 锚点同思路)
860
+ def widget_of(node)
861
+ return nil if node.nil?
862
+ return node.dom unless TRANSPARENT_TYPES.include?(node.type)
863
+
864
+ node.children.each do |child|
865
+ found = widget_of(child)
866
+ return found if found
867
+ end
868
+ nil
869
+ end
870
+
871
+ def container?(node)
872
+ node.type == :box || node.type == :area
873
+ end
874
+
875
+ def unsupported_element!(node)
876
+ hint = ELEMENT_HINTS[node.type]
877
+ raise UnsupportedElementError,
878
+ "[citrine-native] 元素 #{node.type} 在原生后端没有对应控件:" \
879
+ "v0 支持 #{ELEMENTS.keys.map(&:to_s).join(' / ')}" \
880
+ "#{hint ? "。#{hint}" : '。可用元素见 GOALS 4.3'}"
881
+ end
882
+ end
883
+ end
884
+ end