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,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
|