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
data/README.md
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# citrine-native
|
|
2
|
+
|
|
3
|
+
Citrine 组件的 CRuby 原生运行时:不经 Opal/JS,`ruby app.rb` 直接把信号式
|
|
4
|
+
组件跑成原生控件桌面应用(Shoes 精神)。
|
|
5
|
+
|
|
6
|
+
**定位**:citrine 主仓(平台无关核心 + 渲染器协议)的一个外部 Port。
|
|
7
|
+
同一份组件代码可跑在浏览器 DOM(Opal)/ Canvas / SSR / **原生控件(本 gem)**。
|
|
8
|
+
|
|
9
|
+
**状态**:**N0–N4 全部完成,0.1.0 待发布**(Roadmap 见 [GOALS.md](GOALS.md))。
|
|
10
|
+
能力:原生控件(stack/row/label/button/entry/checkbox)+ **自绘面板(area)**(绘制图元、
|
|
11
|
+
鼠标/键盘事件、重绘调度、面板句柄、定时器)+ 样式能力矩阵 + 元素/事件支持矩阵;
|
|
12
|
+
`examples/` 两个示例与两个真实应用(citrine-sheets / citrine-market-terminal 的原生版)都可跑。
|
|
13
|
+
**macOS(Cocoa)与 Windows(Win32)两种 libui 实现均已实测跑通**,CI 覆盖两个平台。
|
|
14
|
+
|
|
15
|
+
三份能力清单(改行为时要同步更新):
|
|
16
|
+
|
|
17
|
+
- [docs/design/native-area.md](docs/design/native-area.md) —— 自绘面板的冻结接口与实测边界
|
|
18
|
+
- [docs/design/style-matrix.md](docs/design/style-matrix.md) —— **样式能力矩阵**:
|
|
19
|
+
样式键在原生后端的落点(`:mapped` / `:painted` / `:ignored`)
|
|
20
|
+
- [docs/design/element-event-matrix.md](docs/design/element-event-matrix.md) —— **元素/事件支持矩阵**
|
|
21
|
+
- [docs/design/platform-matrix.md](docs/design/platform-matrix.md) —— **平台能力矩阵**(macOS ↔ Windows)
|
|
22
|
+
- [docs/design/semantics-coverage.md](docs/design/semantics-coverage.md) —— 语义覆盖(主仓断言 ↔ 本后端)
|
|
23
|
+
|
|
24
|
+
## 安装与运行
|
|
25
|
+
|
|
26
|
+
从 RubyGems 安装(0.1.0 起):
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
gem install citrine-native # 依赖 citrine(核心)与 libui(原生控件动态库,含预编译包)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
在本仓库开发:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bundle install # citrine 依赖开发期指向 ../citrine(path)
|
|
36
|
+
bundle exec ruby examples/counter.rb # 起真窗口;点按钮,计数精确 +1
|
|
37
|
+
bundle exec ruby examples/todo.rb # 输入 + 添加 + 勾选 + 删除
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
在自己的应用里:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
require "citrine-native"
|
|
44
|
+
|
|
45
|
+
class Counter < Citrine::Component
|
|
46
|
+
state :count, default: 0 # 三宏收关键字参数
|
|
47
|
+
|
|
48
|
+
def view
|
|
49
|
+
stack(gap: 8) do
|
|
50
|
+
label { "计数:#{count}" }
|
|
51
|
+
button(on_click: -> { self.count += 1 }) { "点我 +1" } # 文本走 block
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Citrine::Native.run(Counter, title: "计数器", width: 400, height: 300)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`Citrine::Native.run(组件类或实例, title:, width:, height:, dev_mode:, activate:)`
|
|
60
|
+
建窗口、挂载组件、进主循环(阻塞到窗口关闭),关窗后按序拆解(卸载组件 → 销毁窗口 →
|
|
61
|
+
反初始化)。要自己管事件循环就用 `Citrine::Native.start`(只建窗口挂组件)。
|
|
62
|
+
|
|
63
|
+
`activate:`(默认 `true`)在显示窗口后**激活应用**——macOS 下不激活时窗口不是 key window,
|
|
64
|
+
键盘事件一个也收不到(见设计 2.3 的实测);不想抢用户焦点时传 `activate: false`,
|
|
65
|
+
代价是要先点一下面板才能用键盘。
|
|
66
|
+
|
|
67
|
+
## 自绘面板(area)与定时器(NA-1)
|
|
68
|
+
|
|
69
|
+
libui 的 box 没有背景/边框、label 没有颜色、只有 button/entry/checkbox 可点——
|
|
70
|
+
数据密集区(表格线、涨跌红绿、图表)和"任意位置可点 + 键盘操作"要靠**自绘面板**:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
class Quote < Citrine::Component
|
|
74
|
+
state :rows, default: [["贵州茅台", 1288.50], ["宁德时代", 198.20]]
|
|
75
|
+
|
|
76
|
+
def view
|
|
77
|
+
stack(gap: 6) do
|
|
78
|
+
element(:area, ref: :panel, # refs[:panel] → AreaHandle
|
|
79
|
+
size: [320, 600], # 仅 scroll: true 时生效(滚动内容尺寸)
|
|
80
|
+
scroll: true,
|
|
81
|
+
watch: -> { rows.size }, # 响应式:依赖变化即重绘
|
|
82
|
+
on_draw: ->(p) { draw(p) },
|
|
83
|
+
on_click: ->(ev) { pick(ev.x, ev.y) },
|
|
84
|
+
on_key: { "ArrowDown" => :move_down, "Enter" => :commit, else: :type })
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def draw(p)
|
|
89
|
+
p.rect(0, 0, p.width, p.height, fill: "#14203a", stroke: "#1e2c48")
|
|
90
|
+
rows.each_with_index do |(name, price), i|
|
|
91
|
+
y = 8 + i * 22
|
|
92
|
+
p.text(name, x: 8, y: y, color: "#e6ecf8", size: 13)
|
|
93
|
+
p.text(price.to_s, x: 200, y: y, color: "#4ecb71", size: 13, weight: :bold,
|
|
94
|
+
align: :right, width: 100) # 对齐要同时给 width
|
|
95
|
+
end
|
|
96
|
+
p.line(0, 0, p.width, p.height, color: "#1e2c48", width: 1)
|
|
97
|
+
p.clip(0, 0, p.width, 40) { p.rect(0, 0, p.width, 40, fill: "#0b1424") }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
handle = refs[:panel] # Citrine::Native::AreaHandle(不是 libui 裸指针)
|
|
102
|
+
handle.repaint # 手动标脏重画
|
|
103
|
+
handle.scroll_to(0, 200, 320, 120) # 仅滚动面板:把内容坐标里这块滚进视口
|
|
104
|
+
handle.focus # 把键盘焦点给面板(macOS;做不到时返回 false)
|
|
105
|
+
|
|
106
|
+
@ticker = Citrine::Native.every(200) { self.tick } # 周期定时器(主线程执行)
|
|
107
|
+
@once = Citrine::Native.after(500) { self.refresh }
|
|
108
|
+
on_unmount { @ticker.stop; @once.stop } # #stop 幂等,卸载时记得停
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
图元:`rect`(可圆角)/`line`/`polyline`/`polygon`(面积图)/`text`(颜色+字号+字重+字体)/
|
|
112
|
+
`measure_text`/`clip`;另有两个只读属性 `width`/`height`(面板内容尺寸)与
|
|
113
|
+
`clip_rect`(当前可见区,内容坐标——滚动面板下随滚动位置变化、**不含滚动条**,
|
|
114
|
+
可用它只画看得见的部分)。
|
|
115
|
+
颜色接受 `"#rgb"` / `"#rrggbb"` / `"#rrggbbaa"` / `[r,g,b(,a)]`(0..1 浮点)/ `:none`;
|
|
116
|
+
字号字重从字体描述符来,颜色作为属性烘进文本布局。**每帧新建 Painter,文本布局按
|
|
117
|
+
(文本, 字号, 字重, 字体, 颜色, 宽度, 对齐) 在面板级缓存里复用**(否则每帧每格新建会掉帧)。
|
|
118
|
+
`text` 的 `(x, y)` 是外接矩形**左上角**(不是基线);`align:` 只在同时给 `width:` 时生效。
|
|
119
|
+
在 `on_draw` 里调 `handle.repaint` 是安全的(适配层把请求排到下一帧,自排队动画可跑)。
|
|
120
|
+
|
|
121
|
+
`Widgets::Memory` 桩后端把 `on_draw` 交给 `Painter::Recording`(记录图元调用序列),
|
|
122
|
+
测试可以直接断言"画了什么":
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
rec = backend.fire_draw(area) # 桩后端跑一次绘制
|
|
126
|
+
rec.types # => [:rect, :text, :text]
|
|
127
|
+
rec.calls_of(:text).first[:color] # => [0.9, 0.3, 0.3, 1.0]
|
|
128
|
+
backend.fire_click(area, 12, 34) # 合成点击/按键/移动
|
|
129
|
+
backend.fire_key(area, "ArrowDown", modifiers: { shift: true })
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## 支持的元素与样式(v0)
|
|
133
|
+
|
|
134
|
+
| DSL | 原生控件 | 说明 |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `stack { }` / `row { }` / `box(direction:)` | 竖排 / 横排 box | 方向必须静态(控件创建后不能换方向) |
|
|
137
|
+
| `label { "…" }` | `uiNewLabel` | |
|
|
138
|
+
| `button(on_click:) { "…" }` | `uiNewButton` | 文本走 block |
|
|
139
|
+
| `text_input(value:, type: "password")` | `uiNewEntry` / `uiNewPasswordEntry` | `value:` 传 Signal 即受控双向绑定 |
|
|
140
|
+
| `check_box(checked:, on_change:)` | `uiNewCheckbox` | **没有内容位**:标签用相邻 `label { }` |
|
|
141
|
+
| `element(:area, on_draw:, …)` | `uiNewArea` / `uiNewScrollingArea` | 自绘面板:见上一节;`ref:` 拿到 `AreaHandle` |
|
|
142
|
+
|
|
143
|
+
样式按**能力矩阵**([docs/design/style-matrix.md](docs/design/style-matrix.md),72 个键三档):
|
|
144
|
+
|
|
145
|
+
- **自动映射**:`gap` / `padding*`(→ 容器 padding 的**有/无**两档)、
|
|
146
|
+
`flex_grow` / `flex`(→ 该子控件在父 box 里 stretchy,即吃掉剩余空间)
|
|
147
|
+
- **自绘(area 自动消费)**:`background`、`border`(`"1px solid #rrggbb"` 或颜色串)、
|
|
148
|
+
`border_color` / `border_width`、`border_radius`——写在 `element(:area)` 上,
|
|
149
|
+
框架在 `on_draw` **之前**画一次底板,应用只管内容:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
element(:area, scroll: true, size: [400, 300],
|
|
153
|
+
style: { background: "#101827", border: "1px solid #1e2b45", border_radius: 8 },
|
|
154
|
+
on_draw: ->(panel) { panel.text("内容", x: 12, y: 12, color: "#e7edf7") })
|
|
155
|
+
```
|
|
156
|
+
- **自绘(写在 on_draw 里)**:`color` / `font_size` / `font_weight` / `font_family` /
|
|
157
|
+
`text_align` 这些文字样式——原生 label / button 没有公开 API 能设字体与颜色
|
|
158
|
+
- **无对应概念**:`width` / `height` / `margin*` / `align_items` / `box_shadow` /
|
|
159
|
+
`transition` / `overflow` 等;`disabled` 属性 → 控件禁用态
|
|
160
|
+
|
|
161
|
+
`flex_grow` 在 libui 里只是**"stretchy"开关、不是权重**:同一 box 里两个 stretchy
|
|
162
|
+
子控件**等分**剩余空间。
|
|
163
|
+
**其余样式键与 HTML 专属属性在 dev_mode 下按矩阵分档提醒**(说清"为什么 / 怎么办 / 去哪看"),
|
|
164
|
+
绝不静默丢弃;未支持的元素(`textarea`/`select`/`table`/`img`…,18 个核心标签逐个有处置)
|
|
165
|
+
直接抛 `UnsupportedElementError` 并给出替代建议(见元素/事件支持矩阵)。
|
|
166
|
+
`css_class` 目前整块忽略(原生没有 CSS),两条后续路径见样式矩阵文档第九节。
|
|
167
|
+
|
|
168
|
+
## 使用约束
|
|
169
|
+
|
|
170
|
+
- **单线程**:控件回调、Signal 写入、Effect 重跑全在主线程,`Citrine.batch` 可直接用。
|
|
171
|
+
长任务(网络/文件)放后台线程,再用适配层的 `queue_main` 把更新排回主线程
|
|
172
|
+
(`uiQueueMain`)。
|
|
173
|
+
- **回调里的异常不会中断应用**:打到 stderr 后继续跑主循环(异常穿过
|
|
174
|
+
Fiddle/Objective-C 栈可能把整个 GUI 带走);组件内的错误请用 `error_fallback`。
|
|
175
|
+
- **键盘在自绘面板上可用,原生输入控件上不行**:`on_key`/`window_key` 都由聚焦中的
|
|
176
|
+
`element(:area)` 转发(`App` 的 `activate:` 负责让窗口成为 key window)。
|
|
177
|
+
libui 的 entry 仍然不暴露按键事件,所以**"输入框里按回车"还是不可用**——
|
|
178
|
+
要么放一个按钮,要么由面板侧处理回车。原生 entry 上也会因此收不到 `window_key`。
|
|
179
|
+
- **没有滚轮事件**:libui 的 `uiArea` 不投递滚轮(设计 2.2 的实测);要滚动就用
|
|
180
|
+
`scroll: true` 的原生滚动条 + `AreaHandle#scroll_to`。
|
|
181
|
+
- **面板尺寸**:`size:` 只在 `scroll: true`(滚动内容尺寸,**不是视口尺寸**)时生效;
|
|
182
|
+
非滚动面板的尺寸由外层容器布局决定(libui 的 `uiAreaSetSize` 只对滚动面板可用,
|
|
183
|
+
dev_mode 下会提醒)。**面板拿不到空间是静默的**(0×0、还会挤扁兄弟)——撑不撑得开
|
|
184
|
+
取决于它在**容器链逐层**有没有 stretchy 尺寸:**单个**面板在 stack 里不给 `flex_grow`
|
|
185
|
+
也能拿到剩余空间,"没有尺寸来源就 0×0"是过度概括(见下一条与设计 §5.7.2)。渲染器在
|
|
186
|
+
面板被压扁时给 dev_mode 提醒(0 尺寸、**非滚动**面板被挤成一条、以及滚动面板**真实
|
|
187
|
+
可见视口**被挤扁——最后这条靠读 clip view;滚动面板的**内容**尺寸矮不算"被挤扁")。
|
|
188
|
+
- **嵌套 box 的坑**(实测,两个 demo 都踩过):libui 的 box 布局里,一个 box 能不能撑开
|
|
189
|
+
取决于**它自己在父容器里有没有 stretchy 尺寸**(`flex_grow`),逐层往上都要成立;
|
|
190
|
+
"每个嵌套 box 里都塞一个 stretchy 子控件"**既不必要也不充分**(反例:内层 box 自己
|
|
191
|
+
`flex_grow` 就够了;反过来内层 box 里有 stretchy 子控件、自己却没有,照样被压成 0 宽)。
|
|
192
|
+
例:`stack { label; row(style: { flex_grow: 1 }) { stack { area }; stack { label } }; label }`
|
|
193
|
+
整行只有 376×16 高——row 自己 stretchy 了,但它所在的 stack 高度被两个 label 钉死。
|
|
194
|
+
细节、反例与探针数据见 `docs/design/native-area.md` §5.7.2。与滚动无关,纯 label 同样复现。
|
|
195
|
+
- **⌘ 组合键不会被面板吞掉**:面板声明了 `on_key` 时,⌘H 这类菜单快捷键照常生效
|
|
196
|
+
(回调仍然收到按键,所以应用自己处理的 ⌘Z/⌘B 不受影响)。
|
|
197
|
+
- **绘制不裁剪到面板矩形**:画到面板外的内容会显示(AppKit `NSView` 默认
|
|
198
|
+
`clipsToBounds = NO`),`clip_rect` 是提示不是限制——想限制请自己 `clip`。
|
|
199
|
+
- **`ref:` 拿到的是控件句柄**(`Fiddle::Pointer`),`element(:area)` 的 `ref:` 拿到的是
|
|
200
|
+
`Citrine::Native::AreaHandle`(不是 DOM 元素)。`ref:` 挂在**产出该元素的组件**上:
|
|
201
|
+
子组件里的 `refs[:grid]` 根组件看不到,要由子组件自己暴露读取器(见设计 2.5)。
|
|
202
|
+
- 组件代码要可移植:只用平台无关 API,别 `require "citrine/browser"` / `citrine/canvas`,
|
|
203
|
+
别碰 `Native`/反引号 JS。
|
|
204
|
+
|
|
205
|
+
## 开发
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
bundle exec rake # CRuby 单测(桩后端,不需要窗口)
|
|
209
|
+
bundle exec rake gui_smoke # 真窗口 + 真主循环(窗口会闪现一下)
|
|
210
|
+
CITRINE_NATIVE_GUI=1 bundle exec rake # 连 GUI 模式的真控件冒烟一起跑
|
|
211
|
+
bundle exec rake consumer_smoke # 消费端冒烟:把 gem 装进干净 GEM_HOME 再在仓库外用
|
|
212
|
+
bundle exec rake demo_acceptance # 真窗口端到端验收:N1/N2 示例 + 两个 demo(见下)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`rake consumer_smoke`(`test/support/consumer_smoke.rb`)本地构建 `citrine` 与
|
|
216
|
+
`citrine-native` 两个 gem → 装进一个全新的 `GEM_HOME` → 在**仓库外**的空目录里
|
|
217
|
+
`require "citrine-native"` 并用它渲染一个组件(点击 +1)。它会断言**加载的是装好的那份**
|
|
218
|
+
(`$LOADED_FEATURES` 指向临时 GEM_HOME),所以开发态的 path 依赖骗不过它。不联网、
|
|
219
|
+
不碰工作树,临时目录自清理;需要同级 `citrine` 仓库。发布相关的用法见 [RELEASING.md](RELEASING.md)。
|
|
220
|
+
|
|
221
|
+
`rake demo_acceptance`(`test/support/demo_acceptance.rb`)拉起**本仓的 N1/N2 验收示例**
|
|
222
|
+
(`examples/counter.rb` / `examples/todo.rb`)与同级目录里的
|
|
223
|
+
**citrine-sheets / citrine-market-terminal** 真窗口,用真鼠标点击、逐字符 `WM_CHAR` 打字,
|
|
224
|
+
再回读应用自己的**控件标题**(`计数:3`、`待办:剩余 1 / 共 1`、`位置 D15`、`第 99 档`、
|
|
225
|
+
`⏸ 暂停`↔`▶ 继续`)来断言状态变化,最后关窗并确认进程自行退出、log 干净。
|
|
226
|
+
`DEMO=examples|counter|todo|sheets|market` 可只跑一个(共 58 项断言)。
|
|
227
|
+
需要三个仓库同父目录(示例只用本仓)、且是**装了 libui 的那个 ruby**
|
|
228
|
+
(仅 Windows;非 Windows 或仓库缺失时输出 SKIP 并 0 退出)。跑的时候窗口会真的弹出来抢鼠标。
|
|
229
|
+
|
|
230
|
+
CI(`.github/workflows/ci.yml`)在 **macos-latest 与 windows-latest** 两个平台上跑
|
|
231
|
+
`bundle exec rake`(桩测 + 不开窗的真控件冒烟);真窗口路径留给人工与自托管 runner
|
|
232
|
+
(`demo_acceptance` 也在此列——它要真屏幕与真鼠标)。
|
|
233
|
+
发布走 Trusted Publishing(OIDC):打 `v*` 标签触发 `.github/workflows/release.yml`——
|
|
234
|
+
**一次性前置、发布步骤、发布后验证与出错处置都写在 [RELEASING.md](RELEASING.md)**;
|
|
235
|
+
版本号 ↔ CHANGELOG ↔ gemspec ↔ 锁文件 ↔ 工作流的 Ruby 版本这几处的引用关系由
|
|
236
|
+
`test/release_metadata_test.rb` 机器对拍(随套件跑)。
|
|
237
|
+
|
|
238
|
+
测试分两层(对齐 GOALS 风险 3):
|
|
239
|
+
|
|
240
|
+
- **渲染语义**:`Widgets::Memory` 桩后端(控件树只有结构/文本/回调)——块级更新、
|
|
241
|
+
keyed 复用与重排、组件根落位、透明容器、错误边界、受控输入、自绘面板(事件/重绘/
|
|
242
|
+
句柄/提醒/定时器)、卸载不留活口
|
|
243
|
+
- **真控件**:`test/support/libui_scenario.rb` 在**子进程**里跑(libui 撞到内部 bug 会
|
|
244
|
+
abort 进程):默认不显示窗口,直接触发 libui 真正持有的回调闭包(含合成
|
|
245
|
+
`uiAreaMouseEvent`/`uiAreaKeyEvent`),验证点击精确 +1、容器重排的物理顺序、
|
|
246
|
+
真文本度量/换行/布局缓存释放、面板五个回调槽、⌘ 键的真闭包返回值(不吞菜单快捷键)、
|
|
247
|
+
拆解后 `uiUninit` 无泄漏;
|
|
248
|
+
`--gui` 模式追加真窗口路径:激活后窗口是 key window、`AreaHandle#focus`、
|
|
249
|
+
真绘制(矩形/折线/面积图/中文富文本/裁剪块)、`watch:` 与 `repaint` 驱动重画、
|
|
250
|
+
滚动后 `clip_rect` 跟着走、`clip_rect` 与 AppKit `visibleRect` 对拍、
|
|
251
|
+
滚动面板撑满容器、自排队动画真的持续出帧。
|
|
252
|
+
|
|
253
|
+
⚠️ GUI 路径里"窗口是 key window / `#focus`"两条依赖 macOS 的**协作式激活**:同机有别的
|
|
254
|
+
应用抢焦点(含其它 agent 的 GUI 进程)时会失败。判别是不是环境:起一个**不含 area** 的
|
|
255
|
+
最小窗口看 `window_is_key?`——它也为假就是环境问题。
|
|
256
|
+
|
|
257
|
+
## 仓库结构
|
|
258
|
+
|
|
259
|
+
```
|
|
260
|
+
lib/citrine-native.rb # gem 入口
|
|
261
|
+
lib/citrine/native.rb # Citrine::Native 命名空间 + run/start/every/after + 异常
|
|
262
|
+
lib/citrine/native/renderer.rb # NativeRenderer:节点树 → 控件树(平台钩子)
|
|
263
|
+
lib/citrine/native/style_matrix.rb # 样式能力矩阵(机器可读的事实来源)
|
|
264
|
+
lib/citrine/native/app.rb # 窗口 + 主循环 + 激活 + 信号接管 + 有序拆解
|
|
265
|
+
lib/citrine/native/painter.rb # 自绘面板的绘制层(Painter / 文本布局缓存 / Recording)
|
|
266
|
+
lib/citrine/native/pointer_event.rb # 面板指针事件视图(平台无关)
|
|
267
|
+
lib/citrine/native/area_handle.rb # 面板句柄(repaint / scroll_to / focus)
|
|
268
|
+
lib/citrine/native/timer.rb # 定时器(后台线程 + queue_main)
|
|
269
|
+
lib/citrine/native/widgets.rb # 控件适配层协议(换 GTK 后端只换这一层)
|
|
270
|
+
lib/citrine/native/widgets/libui.rb # libui 后端(真控件 + macOS 直通桥)
|
|
271
|
+
lib/citrine/native/widgets/memory.rb # 内存桩后端(单测用)
|
|
272
|
+
examples/counter.rb # N1 验收示例
|
|
273
|
+
examples/todo.rb # N2 验收示例
|
|
274
|
+
test/ # CRuby 单测 + 真控件冒烟 + demo 端到端验收 + 消费端冒烟脚本
|
|
275
|
+
docs/design/native-area.md # 自绘面板的冻结接口 + 实现说明
|
|
276
|
+
docs/design/style-matrix.md # 样式能力矩阵(三档落点)
|
|
277
|
+
docs/design/element-event-matrix.md # 元素/事件支持矩阵
|
|
278
|
+
docs/design/platform-matrix.md # 平台能力矩阵(macOS ↔ Windows)
|
|
279
|
+
docs/design/semantics-coverage.md # 语义覆盖(主仓断言 ↔ 本后端)
|
|
280
|
+
docs/plan/backlog.md # 剩余问题与待决策项
|
|
281
|
+
.github/workflows/ci.yml # macOS + Windows 矩阵 CI
|
|
282
|
+
.github/workflows/release.yml # Trusted Publishing 发布
|
|
283
|
+
RELEASING.md # 发布手册(一次性前置 + 发布步骤 + 发布后验证 + 出错处置)
|
|
284
|
+
CHANGELOG.md # 版本化变更(使用者向)
|
|
285
|
+
GOALS.md # 设计与计划主文档(含过程变更日志)
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## 约束(贡献者向)
|
|
289
|
+
|
|
290
|
+
- 只依赖 citrine 的平台无关核心,**禁止引入 Opal/JS**
|
|
291
|
+
- 渲染器不直接调 libui API——一律经 `Widgets` 适配层(Painter 例外:它按设计只依赖
|
|
292
|
+
libui 的 draw/attributed-string 接口,放在 `native/painter.rb`)
|
|
293
|
+
- 打包壳本期不做(非目标,见 GOALS 第二节)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "citrine"
|
|
4
|
+
require_relative "widgets"
|
|
5
|
+
require_relative "renderer"
|
|
6
|
+
|
|
7
|
+
module Citrine
|
|
8
|
+
module Native
|
|
9
|
+
# 应用入口:窗口 + 主循环(`Citrine::Native.run` 的落地实现)。
|
|
10
|
+
#
|
|
11
|
+
# 生命周期(GOALS 4.4):**全部在主线程**——libui 的控件回调本身在主线程触发,
|
|
12
|
+
# 因此 Signal 写入与 Effect 重跑天然串行,`Citrine.batch` 直接可用;
|
|
13
|
+
# 后台线程(网络/文件)想更新 UI 必须走 `widgets.queue_main`。
|
|
14
|
+
#
|
|
15
|
+
# 拆解顺序(libui 的硬约束,见 widgets/libui.rb):
|
|
16
|
+
# 卸载组件(逐个 dispose:先子后父,销毁各自控件)
|
|
17
|
+
# → 销毁窗口(连坐根容器)
|
|
18
|
+
# → uiUninit
|
|
19
|
+
# 顺序错了两头都会踩:窗口先销毁,组件卸载就动到已释放的控件;
|
|
20
|
+
# 组件不卸载就先销毁窗口,unmount 钩子与 Effect 释放全被跳过。
|
|
21
|
+
class App
|
|
22
|
+
DEFAULT_OPTIONS = { title: "Citrine", width: 640, height: 480, margined: true,
|
|
23
|
+
activate: true }.freeze
|
|
24
|
+
|
|
25
|
+
# 优雅退出用的信号集合(launcher 传 `signals: :default` 时用这一组)。
|
|
26
|
+
# 为什么是这几个:`run` 把 teardown 放在 ensure 里,而 Ruby 对**未捕获**的终止
|
|
27
|
+
# 信号是直接终止进程、不跑 ensure——libui 的控件销毁记账就整个跳过了(见
|
|
28
|
+
# docs/design/platform-matrix.md 第三节)。实际安装时会按 `Signal.list` 过滤:
|
|
29
|
+
# Windows 没有 HUP/QUIT/ALRM,`trap` 会直接 ArgumentError。
|
|
30
|
+
DEFAULT_QUIT_SIGNALS = %w[INT TERM HUP QUIT ALRM].freeze
|
|
31
|
+
|
|
32
|
+
attr_reader :component, :options, :widgets, :renderer, :window, :root
|
|
33
|
+
|
|
34
|
+
def initialize(component, widgets: nil, signals: nil, **options)
|
|
35
|
+
@component = component.is_a?(Class) ? component.new : component
|
|
36
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
|
37
|
+
@widgets = widgets || Widgets.default
|
|
38
|
+
@renderer = Renderer.new(widgets: @widgets)
|
|
39
|
+
@signals = signals
|
|
40
|
+
@torn_down = false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# 建窗口 + 挂载组件 + 进主循环(阻塞到窗口关闭)
|
|
44
|
+
def run
|
|
45
|
+
setup
|
|
46
|
+
trap_quit!(signals: @signals) if @signals
|
|
47
|
+
@widgets.main_loop
|
|
48
|
+
self
|
|
49
|
+
ensure
|
|
50
|
+
teardown
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# 装"收到终止信号就退出主循环"的处理器:退出后仍走 `run` 的 ensure → 有序拆解。
|
|
54
|
+
# 只在本平台**实际存在**的信号上装(按 `Signal.list` 过滤),返回真正装上的名字,
|
|
55
|
+
# 便于启动器记日志或断言。
|
|
56
|
+
#
|
|
57
|
+
# 为什么不在 `run` 里默认装:`trap` 是**进程级**的,会覆盖宿主已有的处理器——
|
|
58
|
+
# 库不该悄悄接管宿主的信号策略(被嵌入时尤其如此)。应用把自己当独立进程
|
|
59
|
+
# (脚本即应用)时传 `signals: :default` 即可,例如:
|
|
60
|
+
#
|
|
61
|
+
# Citrine::Native.run(MyApp, signals: :default, title: "我的应用")
|
|
62
|
+
# # 或自管主循环:
|
|
63
|
+
# app = Citrine::Native.start(MyApp, title: "我的应用")
|
|
64
|
+
# app.trap_quit! # 或 trap_quit!(signals: %w[INT TERM])
|
|
65
|
+
# app.widgets.main_loop
|
|
66
|
+
# app.teardown
|
|
67
|
+
def trap_quit!(signals: DEFAULT_QUIT_SIGNALS)
|
|
68
|
+
names = (signals == :default ? DEFAULT_QUIT_SIGNALS : Array(signals)).map(&:to_s)
|
|
69
|
+
registered = names.select { |name| ::Signal.list.key?(name) }
|
|
70
|
+
registered.each { |name| trap(name) { quit } }
|
|
71
|
+
registered
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# 非阻塞的前半程(测试与"自己管主循环"的场景用)
|
|
75
|
+
def setup
|
|
76
|
+
@widgets.init
|
|
77
|
+
@root = @renderer.mount_component(@component, @options)
|
|
78
|
+
@window = @renderer.window
|
|
79
|
+
@widgets.window_on_closing(@window) { true } # 回 true 表示"允许关窗"(适配层据此 quit)
|
|
80
|
+
@widgets.window_show(@window)
|
|
81
|
+
activate_window
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# 有序拆解:先卸载组件(跑 on_unmount、dispose 全部 Effect、销毁控件),
|
|
86
|
+
# 再销毁窗口,最后反初始化工具包。幂等。
|
|
87
|
+
def teardown
|
|
88
|
+
return self if @torn_down
|
|
89
|
+
|
|
90
|
+
@torn_down = true
|
|
91
|
+
begin
|
|
92
|
+
Citrine.unmount(@component) if @root
|
|
93
|
+
ensure
|
|
94
|
+
destroy_window
|
|
95
|
+
@widgets.shutdown
|
|
96
|
+
end
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def quit
|
|
101
|
+
@widgets.quit
|
|
102
|
+
self
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# 显示窗口之后必须**激活应用**(设计 2.3 的实测结论):macOS 下
|
|
108
|
+
# `uiControlShow` 出来的窗口不是 key window(firstResponder 为 nil),
|
|
109
|
+
# 于是应用一个键也收不到——"窗口看得见、键盘用不了"。
|
|
110
|
+
# `activate: false` 可关掉(不想抢用户焦点时),此时键盘要靠点一下面板。
|
|
111
|
+
def activate_window
|
|
112
|
+
return false unless @options.fetch(:activate, true)
|
|
113
|
+
|
|
114
|
+
@widgets.window_activate(@window)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def destroy_window
|
|
118
|
+
window = @window || @renderer.window
|
|
119
|
+
return false unless window
|
|
120
|
+
|
|
121
|
+
@widgets.window_destroy(window)
|
|
122
|
+
true
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Citrine
|
|
4
|
+
module Native
|
|
5
|
+
# 自绘面板句柄(设计 2.5,冻结):`ref:` 登记的就是它,**不是 libui 裸指针**——
|
|
6
|
+
# 应用只经它做三件面板特有的事,其余(绘制/事件)都走 on_draw / on_* 回调。
|
|
7
|
+
#
|
|
8
|
+
# element(:area, ref: :grid, on_draw: …)
|
|
9
|
+
# refs[:grid].repaint # 立即标脏重画(等价于"我知道内容变了")
|
|
10
|
+
# refs[:grid].scroll_to(0, 200, 300, 120) # 仅滚动面板:把这块滚进视口
|
|
11
|
+
# refs[:grid].focus # 把键盘焦点给面板(macOS;见 2.3 的实测)
|
|
12
|
+
#
|
|
13
|
+
# 句柄只是"适配层的门面":能力缺口(没有滚动条 / 平台不给焦点)由后端如实返回,
|
|
14
|
+
# 句柄不自己编造成功。
|
|
15
|
+
class AreaHandle
|
|
16
|
+
# 后端句柄(Fiddle::Pointer / 桩后端的 Widget):诊断与测试用,应用一般不用碰
|
|
17
|
+
attr_reader :handle
|
|
18
|
+
|
|
19
|
+
def initialize(widgets:, handle:)
|
|
20
|
+
@widgets = widgets
|
|
21
|
+
@handle = handle
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# 标脏 + 排一次重绘(libui 的 uiAreaQueueRedrawAll:合并进下一帧)
|
|
25
|
+
def repaint
|
|
26
|
+
@widgets.area_queue_redraw(@handle)
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# 仅滚动面板(设计 2.5):把内容坐标里的 (x, y, w, h) 滚进视口。
|
|
31
|
+
# 非滚动面板没有滚动条——libui 对此会 uiprivUserBug 终止进程,所以后端会
|
|
32
|
+
# fail fast(ArgumentError,提示改用 scroll: true);先用 scrollable? 判断。
|
|
33
|
+
def scroll_to(x, y, w, h)
|
|
34
|
+
@widgets.area_scroll_to(@handle, x, y, w, h)
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# 把键盘焦点给面板(设计 2.3:macOS 走 [keyWindow makeFirstResponder:])。
|
|
39
|
+
# 返回 true/false——平台没这条能力时如实返回 false,不假装成功。
|
|
40
|
+
def focus = @widgets.area_focus(@handle)
|
|
41
|
+
|
|
42
|
+
# 有没有滚动条(scroll: true 的面板才有)
|
|
43
|
+
def scrollable? = @widgets.area_scrollable?(@handle)
|
|
44
|
+
|
|
45
|
+
def to_s = "#<Citrine::Native::AreaHandle #{@widgets.describe(@handle)}>"
|
|
46
|
+
def inspect = to_s
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|