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,625 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Citrine
4
+ module Native
5
+ # 自绘面板的绘制薄层(冻结接口见 docs/design/native-area.md 2.2)。
6
+ #
7
+ # 三个部分各司其职,参数归一只有一份,两个实现不会漂移:
8
+ # Primitives —— 图元签名 + 颜色/字重/对齐/圆角归一 + "未支持用法"的提醒收集(纯 Ruby)
9
+ # Painter —— 真 libui 绘制(Primitives 的平台实现;坐标是面板本地像素,左上角原点)
10
+ # Recording —— 只记录图元调用序列的桩实现(Memory 后端与"画了什么"的断言用)
11
+ #
12
+ # 生命周期:**一个面板一次 on_draw 一个 Painter 实例,不跨帧复用**。
13
+ # 跨帧复用的只有文本布局(TextCache):它由适配层按面板持有、随面板销毁释放——
14
+ # libui 的 text layout / attributed string / font descriptor 不归 Ruby GC 管,
15
+ # 漏 free 就是每帧漏一段 C 内存(每帧每格新建 layout 还会明显掉帧)。
16
+ class Painter
17
+ # ── "未支持用法/无效值"的提醒收集 ────────────────────────
18
+ # Painter 每帧新建(见上),自己 warn 会每帧刷屏;这里只按 key 收集,
19
+ # 交给渲染器按 dev_mode 去重后输出(Renderer#report_painter_warnings)。
20
+ module Warnings
21
+ # key => message(同一 key 只留第一条)
22
+ def warnings
23
+ @warnings ||= {}
24
+ end
25
+
26
+ private
27
+
28
+ def note_warning(key, message)
29
+ (@warnings ||= {})[key] ||= message
30
+ end
31
+ end
32
+
33
+ # ── 图元(冻结签名,见设计 2.2)──────────────────────────
34
+ # 只做"归一 + 调用平台实现":真正的绘制由 emit_* 钩子落在各自实现上。
35
+ #
36
+ # 坐标/尺寸一律 to_f(应用传整数或字符串都不该炸);颜色接受
37
+ # "#rgb" / "#rrggbb" / "#rrggbbaa" / [r,g,b(,a)](0..1 浮点)/ :none。
38
+ module Primitives
39
+ include Warnings
40
+
41
+ DEFAULT_TEXT_COLOR = "#000000"
42
+ DEFAULT_LINE_COLOR = "#000000"
43
+ DEFAULT_TEXT_SIZE = 13
44
+
45
+ # libui 的约定:宽度为负 = 不换行(uiDrawNewTextLayout 里 Width < 0 → CGFLOAT_MAX)
46
+ NO_WRAP = -1.0
47
+
48
+ # 对齐只在给定宽度内生效(布局的 Width 决定外接矩形),没有 width 时按左对齐
49
+ # 处理并提醒——"以为右对齐了"这种静默偏差最难查。
50
+ ALIGN_KEYS = { left: :left, center: :center, right: :right }.freeze
51
+
52
+ def rect(x, y, w, h, fill: :none, stroke: :none, line_width: 1, radius: 0)
53
+ fill = color_of(fill)
54
+ stroke = color_of(stroke)
55
+ return self if fill.nil? && stroke.nil?
56
+
57
+ w = num(w)
58
+ h = num(h)
59
+ return self unless w.positive? && h.positive?
60
+
61
+ emit_rect(num(x), num(y), w, h, fill, stroke, num(line_width), radius_of(radius, w, h))
62
+ end
63
+
64
+ def line(x1, y1, x2, y2, color: DEFAULT_LINE_COLOR, width: 1)
65
+ color = color_of(color)
66
+ return self if color.nil?
67
+
68
+ emit_line(num(x1), num(y1), num(x2), num(y2), color, num(width))
69
+ end
70
+
71
+ # points = [[x, y], …](至少两个点)
72
+ def polyline(points, color: DEFAULT_LINE_COLOR, width: 1)
73
+ color = color_of(color)
74
+ return self if color.nil?
75
+
76
+ emit_polyline(points_of(points), color, num(width))
77
+ end
78
+
79
+ # 面积图(权益曲线):至少三个点才有面积
80
+ def polygon(points, fill: :none, stroke: :none, line_width: 1)
81
+ fill = color_of(fill)
82
+ stroke = color_of(stroke)
83
+ return self if fill.nil? && stroke.nil?
84
+
85
+ emit_polygon(points_of(points), fill, stroke, num(line_width))
86
+ end
87
+
88
+ def text(string, x:, y:, color: DEFAULT_TEXT_COLOR, size: DEFAULT_TEXT_SIZE,
89
+ weight: :normal, family: nil, align: :left, width: nil)
90
+ entry = text_entry(string.to_s, size: size_of(size), weight: weight_of(weight),
91
+ family: family_of(family), color: color_of(color),
92
+ wrap_width: width.nil? ? NO_WRAP : num(width),
93
+ align: align_of(align, width))
94
+ emit_text(entry, num(x), num(y))
95
+ end
96
+
97
+ # → [宽, 高](像素)。与 text 走同一份布局参数(同字体/字号/字重/字体族)。
98
+ def measure_text(string, size: DEFAULT_TEXT_SIZE, weight: :normal, family: nil)
99
+ entry = text_entry(string.to_s, size: size_of(size), weight: weight_of(weight),
100
+ family: family_of(family), color: nil,
101
+ wrap_width: NO_WRAP, align: :left)
102
+ [entry.measured_width, entry.measured_height]
103
+ end
104
+
105
+ # 块内裁剪(libui 的 save/clip/restore):块里照常画,矩形外的部分被裁掉
106
+ def clip(x, y, w, h, &block)
107
+ raise ArgumentError, "clip 需要块:p.clip(x, y, w, h) { … }" unless block
108
+
109
+ emit_clip_begin(num(x), num(y), num(w), num(h))
110
+ begin
111
+ block.arity.zero? ? block.call : block.call(self)
112
+ ensure
113
+ emit_clip_end
114
+ end
115
+ self
116
+ end
117
+
118
+ # 面板内容尺寸 [w, h](设计 2.2):非滚动面板 = 布局尺寸,滚动面板 = 声明的内容尺寸
119
+ # (macOS 下滚动面板的 Draw 不报尺寸,见 2.2 的实测)
120
+ def content_size = [@width, @height]
121
+
122
+ # 当前可见区 [x, y, w, h],内容坐标系(设计 2.2)。
123
+ # 应用拿它做"只画看得见的部分"的省算:滚动面板下它随滚动位置变化。
124
+ def clip_rect = @clip
125
+
126
+ private
127
+
128
+ # ── 参数归一 ──────────────────────────────────────────
129
+
130
+ def num(value) = value.to_f
131
+
132
+ def size_of(size)
133
+ value = num(size)
134
+ value.positive? ? value : DEFAULT_TEXT_SIZE.to_f
135
+ end
136
+
137
+ def points_of(points)
138
+ list = Array(points)
139
+ raise ArgumentError, "图元至少需要两个点,收到 #{points.inspect}" if list.size < 2
140
+
141
+ list.map { |point| Array(point).map { |coordinate| num(coordinate) } }
142
+ end
143
+
144
+ def color_of(value)
145
+ case value
146
+ when nil, :none then nil
147
+ when Array then channel_color(value)
148
+ when String then hex_color(value)
149
+ else invalid_color(value)
150
+ end
151
+ end
152
+
153
+ def hex_color(text)
154
+ hex = text.strip
155
+ return nil if hex.empty? || hex.casecmp("none").zero? || hex.casecmp("transparent").zero?
156
+ return invalid_color(text) unless hex.start_with?("#") && hex[1..].match?(/\A\h+\z/)
157
+
158
+ digits = hex[1..]
159
+ case digits.length
160
+ when 3
161
+ r, g, b = digits.chars.map { |d| d.to_i(16) * 17 }
162
+ [r / 255.0, g / 255.0, b / 255.0, 1.0]
163
+ when 6, 8 then hex_bytes(digits)
164
+ else invalid_color(text)
165
+ end
166
+ end
167
+
168
+ def hex_bytes(digits)
169
+ bytes = digits.scan(/../).map { |pair| pair.to_i(16) }
170
+ [bytes[0] / 255.0, bytes[1] / 255.0, bytes[2] / 255.0, bytes[3] ? bytes[3] / 255.0 : 1.0]
171
+ end
172
+
173
+ def channel_color(values)
174
+ unless values.size.between?(3, 4) && values.all? { |v| v.is_a?(Numeric) }
175
+ return invalid_color(values)
176
+ end
177
+
178
+ if values.any? { |v| v.negative? || v > 1.0 }
179
+ note_warning([:color_range, values.to_s],
180
+ "[citrine-native] 颜色数组按 0..1 浮点解释(#{values.inspect})," \
181
+ "超出范围的分量已夹到 [0, 1]——0..255 的写法请先除以 255")
182
+ end
183
+ r, g, b, a = values
184
+ [clamp01(r), clamp01(g), clamp01(b), a.nil? ? 1.0 : clamp01(a)]
185
+ end
186
+
187
+ def clamp01(value) = [[value.to_f, 0.0].max, 1.0].min
188
+
189
+ def invalid_color(value)
190
+ note_warning([:color, value.to_s],
191
+ "[citrine-native] 颜色 #{value.inspect} 不是支持的写法" \
192
+ "(#rgb / #rrggbb / #rrggbbaa / [r,g,b] / :none),已忽略这次绘制")
193
+ nil
194
+ end
195
+
196
+ def weight_of(weight)
197
+ case weight
198
+ when nil, :normal, "normal" then 400
199
+ when :bold, "bold" then 700
200
+ when Numeric then weight.to_i.clamp(0, 1000)
201
+ else
202
+ note_warning([:weight, weight.to_s],
203
+ "[citrine-native] 字重 #{weight.inspect} 不是支持的写法" \
204
+ "(:normal / :bold / 100..900),已按 :normal 绘制")
205
+ 400
206
+ end
207
+ end
208
+
209
+ def family_of(family)
210
+ case family
211
+ when nil, "" then nil
212
+ when String then family
213
+ else
214
+ note_warning([:family, family.class],
215
+ "[citrine-native] 字体族 #{family.inspect} 不是字符串,已用系统默认字体")
216
+ nil
217
+ end
218
+ end
219
+
220
+ def align_of(align, width)
221
+ key = align.to_s.to_sym
222
+ unless ALIGN_KEYS.key?(key)
223
+ note_warning([:align, align.to_s],
224
+ "[citrine-native] align #{align.inspect} 不是支持的写法" \
225
+ "(:left / :center / :right),已按左对齐绘制")
226
+ return :left
227
+ end
228
+
229
+ if key != :left && width.nil?
230
+ note_warning(:align_without_width,
231
+ "[citrine-native] text 的 align: #{key.inspect} 需要同时给 width:" \
232
+ "(libui 的对齐是在给定宽度内对齐),本次按左对齐绘制")
233
+ return :left
234
+ end
235
+ key
236
+ end
237
+
238
+ def radius_of(radius, w, h)
239
+ value = num(radius)
240
+ return 0.0 unless value.positive?
241
+
242
+ limit = [w, h].min / 2.0
243
+ return value if value <= limit
244
+
245
+ note_warning([:radius, value],
246
+ "[citrine-native] 圆角半径 #{value} 超过矩形短边的一半(#{limit})," \
247
+ "已按最大圆角绘制")
248
+ limit
249
+ end
250
+
251
+ # ── 平台实现(emit_*)与文本布局入口 ──────────────────
252
+ # 两个实现:Painter(libui 调用)/ Recording(记录调用序列)。
253
+
254
+ def emit_rect(_x, _y, _w, _h, _fill, _stroke, _line_width, _radius)
255
+ raise NotImplementedError, "#{self.class}#emit_rect 未实现"
256
+ end
257
+
258
+ def emit_line(_x1, _y1, _x2, _y2, _color, _width)
259
+ raise NotImplementedError, "#{self.class}#emit_line 未实现"
260
+ end
261
+
262
+ def emit_polyline(_points, _color, _width)
263
+ raise NotImplementedError, "#{self.class}#emit_polyline 未实现"
264
+ end
265
+
266
+ def emit_polygon(_points, _fill, _stroke, _line_width)
267
+ raise NotImplementedError, "#{self.class}#emit_polygon 未实现"
268
+ end
269
+
270
+ def emit_text(_entry, _x, _y)
271
+ raise NotImplementedError, "#{self.class}#emit_text 未实现"
272
+ end
273
+
274
+ def emit_clip_begin(_x, _y, _w, _h)
275
+ raise NotImplementedError, "#{self.class}#emit_clip_begin 未实现"
276
+ end
277
+
278
+ def emit_clip_end
279
+ raise NotImplementedError, "#{self.class}#emit_clip_end 未实现"
280
+ end
281
+
282
+ def text_entry(_text, **_kwargs)
283
+ raise NotImplementedError, "#{self.class}#text_entry 未实现"
284
+ end
285
+ end
286
+
287
+ # 一条文本布局缓存项:绘制对象 + 度量结果 + 释放所需的 libui 对象。
288
+ # 不是冻结接口的一部分(应用只拿到绘制方法,拿不到它)。
289
+ TextEntry = Struct.new(:text, :size, :weight, :family, :color, :wrap_width, :align,
290
+ :layout, :attr_string, :font, :measured_width, :measured_height,
291
+ keyword_init: true)
292
+
293
+ include Primitives
294
+
295
+ attr_reader :width, :height
296
+
297
+ # @param ctx [Fiddle::Pointer] uiDrawContext(只在本次 Draw 回调内有效)
298
+ # @param cache [TextCache] 文本布局缓存:跨帧复用,由适配层按面板持有(随面板销毁 clear!)
299
+ # @param clip [Array, nil] 当前可见区 [x, y, w, h](内容坐标;nil = 整块面板可见)
300
+ def initialize(ctx:, width:, height:, cache:, clip: nil)
301
+ self.class.libui!
302
+ @ctx = ctx
303
+ @width = width.to_f
304
+ @height = height.to_f
305
+ @cache = cache
306
+ @clip = clip || [0.0, 0.0, @width, @height]
307
+ @brush = ::LibUI::FFI::DrawBrush.malloc
308
+ @brush.Type = ::LibUI::DrawBrushTypeSolid
309
+ @stroke = ::LibUI::FFI::DrawStrokeParams.malloc
310
+ @stroke.Cap = ::LibUI::DrawLineCapFlat
311
+ @stroke.Join = ::LibUI::DrawLineJoinMiter
312
+ @stroke.MiterLimit = ::LibUI::DrawDefaultMiterLimit
313
+ end
314
+
315
+ private
316
+
317
+ # ── 平台实现:真 libui 调用 ──────────────────────────────
318
+ # 路径(uiDrawPath)是"一次性"对象:画完立刻 uiDrawFreePath,不能跨图元复用
319
+ # (libui 没有 reset API,且路径不归 Ruby GC 管——漏 free 就是每帧漏一段 C 内存)。
320
+
321
+ def emit_rect(x, y, w, h, fill, stroke, line_width, radius)
322
+ with_path do |path|
323
+ if radius.positive?
324
+ round_rect(path, x, y, w, h, radius)
325
+ else
326
+ ::LibUI.draw_path_add_rectangle(path, x, y, w, h)
327
+ end
328
+ ::LibUI.draw_path_end(path)
329
+ fill_path(path, fill)
330
+ stroke_path(path, stroke, line_width)
331
+ end
332
+ end
333
+
334
+ def emit_line(x1, y1, x2, y2, color, width)
335
+ with_path do |path|
336
+ ::LibUI.draw_path_new_figure(path, x1, y1)
337
+ ::LibUI.draw_path_line_to(path, x2, y2)
338
+ ::LibUI.draw_path_end(path)
339
+ stroke_path(path, color, width)
340
+ end
341
+ end
342
+
343
+ def emit_polyline(points, color, width)
344
+ with_path do |path|
345
+ trace(path, points)
346
+ ::LibUI.draw_path_end(path)
347
+ stroke_path(path, color, width)
348
+ end
349
+ end
350
+
351
+ def emit_polygon(points, fill, stroke, line_width)
352
+ with_path do |path|
353
+ trace(path, points)
354
+ ::LibUI.draw_path_close_figure(path)
355
+ ::LibUI.draw_path_end(path)
356
+ fill_path(path, fill)
357
+ stroke_path(path, stroke, line_width)
358
+ end
359
+ end
360
+
361
+ def emit_text(entry, x, y)
362
+ # (x, y) 是整段文本外接矩形的**左上角**(libui 的语义),不是基线
363
+ ::LibUI.draw_text(@ctx, entry.layout, x, y)
364
+ self
365
+ end
366
+
367
+ def emit_clip_begin(x, y, w, h)
368
+ ::LibUI.draw_save(@ctx)
369
+ with_path do |path|
370
+ ::LibUI.draw_path_add_rectangle(path, x, y, w, h)
371
+ ::LibUI.draw_path_end(path)
372
+ ::LibUI.draw_clip(@ctx, path)
373
+ end
374
+ end
375
+
376
+ def emit_clip_end
377
+ ::LibUI.draw_restore(@ctx)
378
+ self
379
+ end
380
+
381
+ def text_entry(text, size:, weight:, family:, color:, wrap_width:, align:)
382
+ @cache.entry(string: text, size: size, weight: weight, family: family, color: color,
383
+ wrap_width: wrap_width, align: align)
384
+ end
385
+
386
+ def with_path
387
+ path = ::LibUI.draw_new_path(::LibUI::DrawFillModeWinding)
388
+ begin
389
+ yield path
390
+ ensure
391
+ ::LibUI.draw_free_path(path)
392
+ end
393
+ self
394
+ end
395
+
396
+ def trace(path, points)
397
+ points.each_with_index do |(x, y), index|
398
+ index.zero? ? ::LibUI.draw_path_new_figure(path, x, y)
399
+ : ::LibUI.draw_path_line_to(path, x, y)
400
+ end
401
+ end
402
+
403
+ # 圆角矩形:四角各一段 90° 圆弧(y 轴向下,角度从 +x 往 +y 增长即顺时针)
404
+ def round_rect(path, x, y, w, h, radius)
405
+ half_pi = Math::PI / 2
406
+ ::LibUI.draw_path_new_figure_with_arc(path, x + radius, y + radius, radius, Math::PI, half_pi, 0)
407
+ ::LibUI.draw_path_line_to(path, x + w - radius, y)
408
+ ::LibUI.draw_path_arc_to(path, x + w - radius, y + radius, radius, -half_pi, half_pi, 0)
409
+ ::LibUI.draw_path_line_to(path, x + w, y + h - radius)
410
+ ::LibUI.draw_path_arc_to(path, x + w - radius, y + h - radius, radius, 0.0, half_pi, 0)
411
+ ::LibUI.draw_path_line_to(path, x + radius, y + h)
412
+ ::LibUI.draw_path_arc_to(path, x + radius, y + h - radius, radius, half_pi, half_pi, 0)
413
+ ::LibUI.draw_path_close_figure(path)
414
+ end
415
+
416
+ def fill_path(path, color)
417
+ return if color.nil?
418
+
419
+ @brush.R = color[0]
420
+ @brush.G = color[1]
421
+ @brush.B = color[2]
422
+ @brush.A = color[3]
423
+ ::LibUI.draw_fill(@ctx, path, @brush)
424
+ end
425
+
426
+ def stroke_path(path, color, width)
427
+ return if color.nil? || width <= 0
428
+
429
+ @brush.R = color[0]
430
+ @brush.G = color[1]
431
+ @brush.B = color[2]
432
+ @brush.A = color[3]
433
+ @stroke.Thickness = width
434
+ ::LibUI.draw_stroke(@ctx, path, @brush, @stroke)
435
+ end
436
+
437
+ # 文本布局缓存(每个面板一份,见类注释)。面板销毁时必须 clear!。
438
+ #
439
+ # 键除设计里写的 (string, size, weight, family) 还带上 color / wrap_width / align:
440
+ # 颜色是烘进 attributed string 的属性(涨跌红绿靠它),宽度与对齐决定换行与外接
441
+ # 矩形——少任何一项,缓存命中都会拿到"另一段文本"的布局。
442
+ class TextCache
443
+ ALIGN_CODES = { left: :DrawTextAlignLeft, center: :DrawTextAlignCenter,
444
+ right: :DrawTextAlignRight }.freeze
445
+
446
+ def initialize
447
+ Painter.libui!
448
+ @entries = {}
449
+ @fonts = {}
450
+ end
451
+
452
+ # 取(或建)一条布局缓存
453
+ def entry(string:, size:, weight:, family:, color:, wrap_width:, align:)
454
+ @entries[[string, size, weight, family, color, wrap_width, align]] ||=
455
+ build(string, size, weight, family, color, wrap_width, align)
456
+ end
457
+
458
+ # 释放全部 libui 对象(顺序:layout → 它引用的 attributed string / 字体描述符)
459
+ def clear!
460
+ @entries.each_value do |cached|
461
+ ::LibUI.draw_free_text_layout(cached.layout)
462
+ ::LibUI.free_attributed_string(cached.attr_string)
463
+ end
464
+ @entries.clear
465
+ @fonts.each_value do |font|
466
+ # 只有 uiLoadControlFont 填过的描述符能交给 uiFreeFontDescriptor;
467
+ # 自建 family 的(Family 指向我们自己的 buffer)交给它会被 free 掉
468
+ # 不该 free 的内存——实测直接 abort 进程(GOALS 变更日志 NA-1)
469
+ ::LibUI.free_font_descriptor(font[:descriptor]) if font[:libui_owned]
470
+ end
471
+ @fonts.clear
472
+ self
473
+ end
474
+
475
+ # 缓存条目数(测试断言"布局真的被复用"用:画 100 帧,条目数不该跟着涨)
476
+ def entry_count = @entries.size
477
+
478
+ def font_count = @fonts.size
479
+
480
+ private
481
+
482
+ def build(string, size, weight, family, color, wrap_width, align)
483
+ font = font_for(size, weight, family)
484
+ attr_string = attributed_string(string, color)
485
+ params = ::LibUI::FFI::DrawTextLayoutParams.malloc
486
+ params.String = attr_string
487
+ params.DefaultFont = font[:descriptor]
488
+ params.Width = wrap_width
489
+ params.Align = ::LibUI.const_get(ALIGN_CODES.fetch(align))
490
+ layout = ::LibUI.draw_new_text_layout(params)
491
+
492
+ width_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_DOUBLE, Fiddle::RUBY_FREE)
493
+ height_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_DOUBLE, Fiddle::RUBY_FREE)
494
+ ::LibUI.draw_text_layout_extents(layout, width_ptr, height_ptr)
495
+ TextEntry.new(text: string, size: size, weight: weight, family: family, color: color,
496
+ wrap_width: wrap_width, align: align, layout: layout,
497
+ attr_string: attr_string, font: font,
498
+ measured_width: double_at(width_ptr), measured_height: double_at(height_ptr))
499
+ end
500
+
501
+ # 字号与字重都写在**字体描述符**上(libui 用 params.DefaultFont 铺满整段文本),
502
+ # 颜色作为属性烘进 attributed string(涨跌红绿)
503
+ def attributed_string(string, color)
504
+ attr_string = ::LibUI.new_attributed_string(string)
505
+ bytes = string.bytesize
506
+ return attr_string if bytes.zero? || color.nil?
507
+
508
+ # uiAttributedStringSetAttribute 接管属性所有权(uiFreeAttributedString 连它们一起释放),
509
+ # 因此这里**不能**再 uiFreeAttribute——那是 double free
510
+ ::LibUI.attributed_string_set_attribute(attr_string, ::LibUI.new_color_attribute(*color), 0, bytes)
511
+ attr_string
512
+ end
513
+
514
+ # 字体描述符缓存:默认走 uiLoadControlFont(系统控制字体,Family 由 libui 分配),
515
+ # 显式给了 family 就自己填——那条路的 Family 指向我们 malloc 的 buffer,
516
+ # 结构体与 buffer 都交给 Fiddle 的 RUBY_FREE 释放(见 clear! 的说明)
517
+ def font_for(size, weight, family)
518
+ @fonts[[size, weight, family]] ||= if family.nil?
519
+ descriptor = ::LibUI::FFI::FontDescriptor.malloc
520
+ ::LibUI.load_control_font(descriptor)
521
+ descriptor.Size = size
522
+ descriptor.Weight = weight
523
+ { descriptor: descriptor, buffer: nil, libui_owned: true }
524
+ else
525
+ buffer = Fiddle::Pointer.malloc(family.bytesize + 1, Fiddle::RUBY_FREE)
526
+ buffer[0, family.bytesize + 1] = "#{family}\0"
527
+ descriptor = ::LibUI::FFI::FontDescriptor.malloc
528
+ descriptor.Family = buffer
529
+ descriptor.Size = size
530
+ descriptor.Weight = weight
531
+ descriptor.Italic = ::LibUI::TextItalicNormal
532
+ descriptor.Stretch = ::LibUI::TextStretchNormal
533
+ { descriptor: descriptor, buffer: buffer, libui_owned: false }
534
+ end
535
+ end
536
+
537
+ def double_at(pointer) = pointer[0, Fiddle::SIZEOF_DOUBLE].unpack1("d")
538
+ end
539
+
540
+ # 记录图元调用序列(Memory 后端与 demo 的绘制断言用;不画任何东西)。
541
+ # 与真 Painter 共用同一份 Primitives:归一后的参数进 @calls。
542
+ #
543
+ # 度量是**桩**:没有 libui 就没有真字体度量,measure_text 按字符数粗估
544
+ # (CJK 按两个字符宽),只保证"量出来是正数、随字号变大"这类弱断言。
545
+ # 需要真度量的测试跑真控件冒烟(test/support/libui_scenario.rb)。
546
+ class Recording
547
+ include Primitives
548
+
549
+ attr_reader :width, :height, :calls
550
+
551
+ def initialize(width: 0, height: 0, clip: nil)
552
+ @width = width.to_f
553
+ @height = height.to_f
554
+ @clip = clip || [0.0, 0.0, @width, @height]
555
+ @calls = []
556
+ end
557
+
558
+ # 图元类型序列:[:rect, :text, :clip_begin, :clip_end]
559
+ def types = @calls.map(&:first)
560
+
561
+ # 某一类图元的参数:rec.calls_of(:text).first[:color]
562
+ def calls_of(type) = @calls.select { |(name, _)| name == type }.map(&:last)
563
+
564
+ def to_s = "#<Citrine::Native::Painter::Recording #{types.inspect}>"
565
+
566
+ private
567
+
568
+ def record(type, **args)
569
+ @calls << [type, args]
570
+ self
571
+ end
572
+
573
+ def emit_rect(x, y, w, h, fill, stroke, line_width, radius)
574
+ record(:rect, x: x, y: y, w: w, h: h, fill: fill, stroke: stroke,
575
+ line_width: line_width, radius: radius)
576
+ end
577
+
578
+ def emit_line(x1, y1, x2, y2, color, width)
579
+ record(:line, x1: x1, y1: y1, x2: x2, y2: y2, color: color, width: width)
580
+ end
581
+
582
+ def emit_polyline(points, color, width)
583
+ record(:polyline, points: points, color: color, width: width)
584
+ end
585
+
586
+ def emit_polygon(points, fill, stroke, line_width)
587
+ record(:polygon, points: points, fill: fill, stroke: stroke, line_width: line_width)
588
+ end
589
+
590
+ def emit_text(entry, x, y)
591
+ record(:text, text: entry.text, x: x, y: y, size: entry.size, weight: entry.weight,
592
+ family: entry.family, color: entry.color, align: entry.align,
593
+ width: entry.wrap_width)
594
+ end
595
+
596
+ def emit_clip_begin(x, y, w, h)
597
+ record(:clip_begin, x: x, y: y, w: w, h: h)
598
+ end
599
+
600
+ def emit_clip_end = record(:clip_end)
601
+
602
+ def text_entry(text, size:, weight:, family:, color:, wrap_width:, align:)
603
+ TextEntry.new(text: text, size: size, weight: weight, family: family, color: color,
604
+ wrap_width: wrap_width, align: align,
605
+ measured_width: estimate_width(text, size), measured_height: size * 1.25)
606
+ end
607
+
608
+ def estimate_width(text, size)
609
+ units = text.each_char.sum { |char| char.bytesize > 1 ? 2 : 1 }
610
+ units * size * 0.55
611
+ end
612
+ end
613
+
614
+ class << self
615
+ # libui 只在真要用它时加载:Memory 后端下的单测不碰动态库
616
+ def libui!
617
+ require "libui"
618
+ rescue LoadError => e
619
+ raise Citrine::Native::ToolkitUnavailableError,
620
+ "自绘面板需要 libui(#{e.message}):纯逻辑测试请用 Memory 后端"
621
+ end
622
+ end
623
+ end
624
+ end
625
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "citrine"
4
+
5
+ module Citrine
6
+ module Native
7
+ # 面板指针事件(平台无关视图,设计 2.3):适配层把 libui 的
8
+ # uiAreaMouseEvent(或桩后端喂进来的等价数据)交给渲染器,渲染器归一成它。
9
+ #
10
+ # 为什么要包装:与 DOM / Canvas 侧同一口径——组件代码不混平台原生对象,
11
+ # CRuby 也能单测;命中测试由应用负责(面板是矩形,应用知道自己的布局,
12
+ # 与 canvas 后端的 @hits 反查同思路)。
13
+ #
14
+ # type 的取值(设计 2.3):"click" / "mouse_down" / "mouse_up" / "mouse_move"。
15
+ # **没有 "wheel"**:libui 的 uiAreaHandler 不带滚轮事件(设计 2.2 的说明),
16
+ # 要滚动就用 scroll: true 的滚动面板 + AreaHandle#scroll_to。
17
+ # modifiers 是 {shift:, ctrl:, alt:, meta:}。
18
+ class PointerEvent < Citrine::Event
19
+ TYPES = %w[click mouse_down mouse_up mouse_move].freeze
20
+
21
+ attr_reader :x, :y, :modifiers, :button
22
+
23
+ def initialize(type, x: 0, y: 0, button: 1, modifiers: nil, raw: nil)
24
+ super(type, raw: raw)
25
+ @x = x.to_f
26
+ @y = y.to_f
27
+ @button = button
28
+ @modifiers = modifiers || {}
29
+ end
30
+
31
+ # 面板本地坐标(左上角原点)——鼠标位置
32
+ def position = [@x, @y]
33
+
34
+ def shift? = @modifiers[:shift] == true
35
+ def ctrl? = @modifiers[:ctrl] == true
36
+ def alt? = @modifiers[:alt] == true
37
+ # ⌘ / Ctrl 等价判断:与 KeyEvent#command? 同口径
38
+ def meta? = @modifiers[:meta] == true
39
+ def command? = meta? || ctrl?
40
+
41
+ # 左键(libui 的按钮编号:1 左 / 2 中 / 3 右)
42
+ def left? = @button == 1
43
+
44
+ def to_s = "#<Citrine::Native::PointerEvent #{type} (#{x}, #{y})>"
45
+ def inspect = to_s
46
+ end
47
+ end
48
+ end