citrine 0.2.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/LICENSE +21 -0
- data/README.md +249 -0
- data/bin/citrine +39 -0
- data/desktop/main.swift +44 -0
- data/lib/citrine/browser.rb +11 -0
- data/lib/citrine/canvas.rb +503 -0
- data/lib/citrine/component.rb +692 -0
- data/lib/citrine/debug.rb +109 -0
- data/lib/citrine/dev_server.rb +305 -0
- data/lib/citrine/dom.rb +292 -0
- data/lib/citrine/event.rb +31 -0
- data/lib/citrine/key_event.rb +39 -0
- data/lib/citrine/list_signal.rb +354 -0
- data/lib/citrine/node.rb +36 -0
- data/lib/citrine/num.rb +75 -0
- data/lib/citrine/packager.rb +129 -0
- data/lib/citrine/reactive.rb +40 -0
- data/lib/citrine/renderer.rb +828 -0
- data/lib/citrine/signal.rb +236 -0
- data/lib/citrine/sourcemap.rb +143 -0
- data/lib/citrine/string_renderer.rb +116 -0
- data/lib/citrine/style.rb +79 -0
- data/lib/citrine/theme.rb +99 -0
- data/lib/citrine/version.rb +5 -0
- data/lib/citrine.rb +92 -0
- data/lib/rubocop/cop/citrine/no_raw_ivar_assignment.rb +75 -0
- metadata +157 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "signal"
|
|
4
|
+
|
|
5
|
+
module Citrine
|
|
6
|
+
# 响应式集合(D):把"集合为整体替换语义"包成一套集合 API。
|
|
7
|
+
#
|
|
8
|
+
# 框架的触发路径只有一条——`Signal#set`。于是列表代码只能写成
|
|
9
|
+
# `self.rows = rows + [row]`,或者更糟:维护一份普通数组、在改动末尾补一句
|
|
10
|
+
# `@rows_signal.set(@rows.dup)`(两个真相源,漏一处就静默不更新)。
|
|
11
|
+
# ListSignal 让"改集合"本身成为触发点:每次变更内部换一份**新数组**再通知,
|
|
12
|
+
# 仍然只有一条触发路径,但调用方写的是自然写法:
|
|
13
|
+
#
|
|
14
|
+
# rows = Citrine.signal_list([])
|
|
15
|
+
# rows << row # 通知一次
|
|
16
|
+
# rows.delete_at(0)
|
|
17
|
+
# rows.replace(list) # 整体替换(等价 rows.set(list))
|
|
18
|
+
#
|
|
19
|
+
# **`get` 返回冻结的快照**:就地改它(`rows.get << x`)会当场 `FrozenError`,
|
|
20
|
+
# 而不是像普通数组那样静默不触发。这是本类存在的主要理由——把最阴的坑变成显式错误。
|
|
21
|
+
#
|
|
22
|
+
# 语义边界:只追踪**集合自身**的变化;元素内部的改动不算变更
|
|
23
|
+
# (`rows.get[0][:n] = 1` 不会触发),要更新请替换那个元素——
|
|
24
|
+
# 或者用 S1-11 的元素代理:`rows[0][:n] = 1`(见 {[]})。
|
|
25
|
+
class ListSignal < Signal
|
|
26
|
+
include Enumerable # each 已在下面接了订阅语义 → find/select/count/min/max/sum/sort_by… 都能用
|
|
27
|
+
|
|
28
|
+
def initialize(value = [], &init)
|
|
29
|
+
super(list_value(value), &init)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# 写入一律冻结副本:调用方的原数组不受影响,内部值也不会被就地改到。
|
|
33
|
+
# 相等短路 + 元素信号刷新 + 集合级广播的顺序见 {set}。
|
|
34
|
+
def set(list)
|
|
35
|
+
new_list = list_value(list)
|
|
36
|
+
return self if new_list == @value
|
|
37
|
+
|
|
38
|
+
@init = nil
|
|
39
|
+
@value = new_list
|
|
40
|
+
refresh_element_signals # 元素级读者先拿到新元素(等值检查,未变的不通知)
|
|
41
|
+
broadcast # 再广播集合级
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ── 读:都经 get,因此在 Effect / 块内读会建立依赖 ──────────────
|
|
46
|
+
|
|
47
|
+
def get
|
|
48
|
+
super.freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# 不订阅的读("只想拿一份快照看"):get 一旦落在 Effect 里就建立依赖,
|
|
52
|
+
# peek 明确表达"这次读不参与响应"
|
|
53
|
+
def peek
|
|
54
|
+
@value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# 复制一份集合(而不是像 Object#dup 那样克隆信号对象)——
|
|
58
|
+
# 从数组迁过来的人会习惯性地写 `list.dup`
|
|
59
|
+
def dup = ListSignal.new(get)
|
|
60
|
+
def clone = ListSignal.new(get)
|
|
61
|
+
|
|
62
|
+
def to_a = get
|
|
63
|
+
def size = get.size
|
|
64
|
+
alias length size
|
|
65
|
+
def empty? = get.empty?
|
|
66
|
+
def first(*args) = args.empty? ? get.first : get.first(*args)
|
|
67
|
+
def last(*args) = args.empty? ? get.last : get.last(*args)
|
|
68
|
+
def fetch(*args, &block) = get.fetch(*args, &block)
|
|
69
|
+
def each(&block) = get.each(&block)
|
|
70
|
+
def map(&block) = get.map(&block)
|
|
71
|
+
def include?(value) = get.include?(value)
|
|
72
|
+
alias member? include?
|
|
73
|
+
def index(value) = get.index(value)
|
|
74
|
+
def join(sep = nil) = get.join(sep)
|
|
75
|
+
def inspect = "#<Citrine::ListSignal #{get.inspect}>"
|
|
76
|
+
|
|
77
|
+
# ── S1-11:集合深响应 ─────────────────────────────────────
|
|
78
|
+
#
|
|
79
|
+
# 位置读取返回**元素代理**:在 Effect / 块内 `list[i]` 即订阅该位置的元素,
|
|
80
|
+
# 经代理就地改写(`list[i][:n] = 1`)只通知订阅这个位置的块——其他位置的
|
|
81
|
+
# 读者与集合级读者都不重跑。代理的读取与行为全部转发给底层元素,
|
|
82
|
+
# 与裸元素的 `==` 比较保持一致。
|
|
83
|
+
#
|
|
84
|
+
# 集合结构变更(push / delete_at / 整表 set…)会按位置刷新已存在的元素信号:
|
|
85
|
+
# 位置上的元素内容变了才通知(插入/删除导致的位置平移因此对元素读者可见)。
|
|
86
|
+
def [](index)
|
|
87
|
+
element = element_signal(index).get
|
|
88
|
+
ElementProxy.new(self, index, element)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# 元素代理就地改写后回调:强制广播该位置的元素信号
|
|
92
|
+
# (值按引用共享、内容已变,相等检查会误判为没变,所以走 set!)
|
|
93
|
+
def element_changed!(index)
|
|
94
|
+
element_signal(index).set!(@value[index])
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# 元素信号按位置懒创建;值与底层元素按引用共享(代理改写的是活元素)
|
|
99
|
+
def element_signal(index)
|
|
100
|
+
(@element_signals ||= {})[index] ||= Signal.new(@value[index])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# 元素代理就地改写后的开发模式提醒:非值语义 == 的元素无法经快照对比判定
|
|
104
|
+
# 变更,链式调用(返回 self 的 push / update 之类)改了也检测不到——
|
|
105
|
+
# 按 [元素类, 方法名] 提醒一次,提示手动通知。提醒存信号上而不是代理上:
|
|
106
|
+
# 代理是每次 list[i] 新建的。
|
|
107
|
+
def note_undetectable_element_mutation(index, name, klass)
|
|
108
|
+
return unless Citrine.respond_to?(:dev_mode?) && Citrine.dev_mode?
|
|
109
|
+
|
|
110
|
+
warned = (@undetectable_warns ||= {})
|
|
111
|
+
key = [klass, name]
|
|
112
|
+
return if warned[key]
|
|
113
|
+
|
|
114
|
+
warned[key] = true
|
|
115
|
+
warn "[citrine] #{klass}##{name} 的就地改写无法自动检测(该类型不是值语义 ==):" \
|
|
116
|
+
"改动后请手动调 element_changed!(#{index})"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# ── 写:每次都是一份新数组 + 一次通知(值相等则不通知)──────────
|
|
120
|
+
|
|
121
|
+
def <<(item)
|
|
122
|
+
set(get + [item])
|
|
123
|
+
self
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def push(*items)
|
|
127
|
+
set(get + items)
|
|
128
|
+
self
|
|
129
|
+
end
|
|
130
|
+
alias append push
|
|
131
|
+
|
|
132
|
+
def concat(items)
|
|
133
|
+
set(get + items.to_a)
|
|
134
|
+
self
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def unshift(*items)
|
|
138
|
+
set(items + get)
|
|
139
|
+
self
|
|
140
|
+
end
|
|
141
|
+
alias prepend unshift
|
|
142
|
+
|
|
143
|
+
# 追加 / 前插并保留最多 limit 个(超出的丢弃另一端的旧元素):**一次通知**。
|
|
144
|
+
# 直接写 `list << x` 再 `list.shift` 会通知两次 —— 每次通知都是一轮块重跑 / 渲染,
|
|
145
|
+
# 所以"有上限的列表"需要这个防呆写法。
|
|
146
|
+
def push_bounded(item, limit)
|
|
147
|
+
set((get + [item]).last(limit))
|
|
148
|
+
self
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def unshift_bounded(item, limit)
|
|
152
|
+
set(([item] + get).first(limit))
|
|
153
|
+
self
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def insert(index, *items)
|
|
157
|
+
list = get.dup
|
|
158
|
+
list.insert(index, *items)
|
|
159
|
+
set(list)
|
|
160
|
+
self
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# 以下两个沿用 Array 的返回值(被移除的元素 / 元素数组 / nil)
|
|
164
|
+
def pop(count = nil)
|
|
165
|
+
list = get
|
|
166
|
+
return nil if list.empty?
|
|
167
|
+
|
|
168
|
+
if count.nil?
|
|
169
|
+
set(list.take(list.size - 1))
|
|
170
|
+
list.last
|
|
171
|
+
else
|
|
172
|
+
n = [count, list.size].min
|
|
173
|
+
set(list.take(list.size - n))
|
|
174
|
+
list.last(n)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def shift(count = nil)
|
|
179
|
+
list = get
|
|
180
|
+
return nil if list.empty?
|
|
181
|
+
|
|
182
|
+
if count.nil?
|
|
183
|
+
set(list.drop(1))
|
|
184
|
+
list.first
|
|
185
|
+
else
|
|
186
|
+
n = [count, list.size].min
|
|
187
|
+
set(list.drop(n))
|
|
188
|
+
list.take(n)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def delete(item)
|
|
193
|
+
list = get
|
|
194
|
+
return nil unless list.include?(item)
|
|
195
|
+
|
|
196
|
+
set(list.reject { |value| value == item })
|
|
197
|
+
item
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def delete_at(index)
|
|
201
|
+
list = get
|
|
202
|
+
at = index.negative? ? list.size + index : index
|
|
203
|
+
return nil if at.negative? || at >= list.size
|
|
204
|
+
|
|
205
|
+
set(list[0...at] + list.drop(at + 1))
|
|
206
|
+
list[at]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def clear
|
|
210
|
+
set([])
|
|
211
|
+
self
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def replace(items)
|
|
215
|
+
set(items.to_a)
|
|
216
|
+
self
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def []=(index, value)
|
|
220
|
+
list = get.dup
|
|
221
|
+
result = list.[]=(index, value)
|
|
222
|
+
set(list)
|
|
223
|
+
result
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# 就地改写类:统一返回信号本身以便链式写(与 Array 返回 self/nil 的约定略有出入;
|
|
227
|
+
# 真正"没变化"时 set 不会通知)
|
|
228
|
+
def sort!(&block) = transform_values { |list| list.sort(&block) }
|
|
229
|
+
def reverse! = transform_values(&:reverse)
|
|
230
|
+
def uniq! = transform_values(&:uniq)
|
|
231
|
+
def map!(&block) = transform_values { |list| list.map(&block) }
|
|
232
|
+
def compact! = transform_values(&:compact)
|
|
233
|
+
def select!(&block) = transform_values { |list| list.select(&block) }
|
|
234
|
+
def reject!(&block) = transform_values { |list| list.reject(&block) }
|
|
235
|
+
|
|
236
|
+
private
|
|
237
|
+
|
|
238
|
+
# 归一 + 类型守卫:Hash 会被 to_a 悄悄拆成键值对,这类错误必须在构造/写入期就报
|
|
239
|
+
def list_value(value)
|
|
240
|
+
case value
|
|
241
|
+
when nil then [].freeze
|
|
242
|
+
when Array then value.dup.freeze
|
|
243
|
+
when Hash
|
|
244
|
+
raise ArgumentError, "signal_list 需要数组,收到 Hash;要放 Hash 请用 Citrine.signal(...)"
|
|
245
|
+
else
|
|
246
|
+
raise ArgumentError, "signal_list 需要数组,收到 #{value.class}" unless value.respond_to?(:to_a)
|
|
247
|
+
|
|
248
|
+
value.to_a.dup.freeze
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def transform_values
|
|
253
|
+
set(yield(get))
|
|
254
|
+
self
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# 集合结构变更后,把已存在的元素信号按位置刷新到新数组的元素上
|
|
258
|
+
# (等值检查:位置上的元素没变就不通知,push 不会误伤位置读者)。
|
|
259
|
+
# 迭代快照:订阅者的重跑可能经 [] 新建元素信号,不能边遍历边改表。
|
|
260
|
+
# 越界键(列表缩短后尾部残留的位置)先通知(值置 nil)再在循环后统一
|
|
261
|
+
# 删除:信号表只增不减会在反复增删的长列表上积累幽灵信号(P3)。
|
|
262
|
+
def refresh_element_signals
|
|
263
|
+
return unless @element_signals
|
|
264
|
+
|
|
265
|
+
stale = []
|
|
266
|
+
@element_signals.values.dup.each do |signal|
|
|
267
|
+
index = @element_signals.key(signal)
|
|
268
|
+
if index < @value.size
|
|
269
|
+
signal.set(@value[index])
|
|
270
|
+
else
|
|
271
|
+
signal.set(nil)
|
|
272
|
+
stale << index
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
stale.each { |index| @element_signals.delete(index) }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# 元素代理(S1-11):list[i] 的返回值。读取与行为全部转发给底层元素;
|
|
279
|
+
# 就地改写(proxy[:n] = 1、proxy.sort! …)改的是活元素并强制通知订阅
|
|
280
|
+
# 这个位置的块——其他位置的读者与集合级读者都不重跑。
|
|
281
|
+
#
|
|
282
|
+
# 变更检测的口径:! 结尾的方法按命名约定通知;其余方法转发前后做元素快照
|
|
283
|
+
# 对比(dup ==),push / store / << 这类返回 self 的非 ! 就地改写因此不再
|
|
284
|
+
# 静默丢更新。快照只对值语义 == 的类型有效(见 VALUE_COMPARABLE)——identity
|
|
285
|
+
# == 的类型 dup 后必不相等,读操作会被误判为变更,订阅块重跑后再触发读,
|
|
286
|
+
# 就是无限循环。快照是浅层的:更深层级的就地改写(proxy[:nested][:x] = 1,
|
|
287
|
+
# 内层是转发返回的裸对象)保持 v1 的静默边界,要更新请替换该元素或
|
|
288
|
+
# 调 element_changed!。
|
|
289
|
+
class ElementProxy
|
|
290
|
+
NAME_SUFFIX_BANG = /\A\w+!\z/.freeze
|
|
291
|
+
# 值语义 ==(dup 后与未变的自己相等)的元素类型才做快照对比
|
|
292
|
+
VALUE_COMPARABLE = [Array, Hash, String, Struct].freeze
|
|
293
|
+
|
|
294
|
+
def initialize(list, index, element)
|
|
295
|
+
@list = list
|
|
296
|
+
@index = index
|
|
297
|
+
@element = element
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def []=(key, value)
|
|
301
|
+
@element[key] = value
|
|
302
|
+
@list.element_changed!(@index)
|
|
303
|
+
value
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def method_missing(name, *args, &block)
|
|
307
|
+
unless @element.respond_to?(name)
|
|
308
|
+
raise NoMethodError, "元素代理(#{safe_class})没有 #{name} 方法"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
bang = NAME_SUFFIX_BANG.match?(name)
|
|
312
|
+
before = @element.dup if !bang && value_comparable?
|
|
313
|
+
result = @element.public_send(name, *args, &block)
|
|
314
|
+
if bang || (!before.nil? && before != @element)
|
|
315
|
+
@list.element_changed!(@index)
|
|
316
|
+
elsif before.nil? && result.equal?(@element)
|
|
317
|
+
# 无法判定:非值语义元素的链式调用可能是就地改写
|
|
318
|
+
@list.note_undetectable_element_mutation(@index, name, safe_class)
|
|
319
|
+
end
|
|
320
|
+
result
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def respond_to_missing?(name, include_private = false)
|
|
324
|
+
@element.respond_to?(name, include_private) || super
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# 与裸元素比较保持一致:keyed 复用、相等断言、Hash 键都靠它
|
|
328
|
+
def ==(other)
|
|
329
|
+
@element == other
|
|
330
|
+
end
|
|
331
|
+
alias eql? ==
|
|
332
|
+
|
|
333
|
+
def hash
|
|
334
|
+
@element.hash
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def inspect
|
|
338
|
+
"#<Citrine::ElementProxy(#{@index}) #{@element.inspect}>"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
private
|
|
342
|
+
|
|
343
|
+
def value_comparable?
|
|
344
|
+
VALUE_COMPARABLE.any? { |klass| @element.is_a?(klass) }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def safe_class
|
|
348
|
+
@element.class
|
|
349
|
+
rescue StandardError
|
|
350
|
+
Object
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
data/lib/citrine/node.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Citrine
|
|
4
|
+
# 元素树节点:类型 + 属性 + 内容 block + 子节点。
|
|
5
|
+
# 平台无关(M3 抽象渲染器接口的输入);由渲染器解释挂载。
|
|
6
|
+
class Node
|
|
7
|
+
attr_reader :type, :owner, :children, :owned_effects
|
|
8
|
+
attr_accessor :props, :block
|
|
9
|
+
attr_accessor :dom, :text
|
|
10
|
+
# 上一次应用过的内联样式键:响应式 style 变化时用来清掉已消失的键
|
|
11
|
+
attr_accessor :applied_style_keys
|
|
12
|
+
# 上一次应用过的透传属性键(S2-2):值翻 false/nil 时用来清掉旧属性
|
|
13
|
+
attr_accessor :applied_attrs
|
|
14
|
+
# autofocus(S2-6):挂载聚焦前记录的活动元素,卸载时恢复焦点到它
|
|
15
|
+
attr_accessor :focus_restore_target
|
|
16
|
+
# keyed 复用的身份:显式 key + 身份标签(元素类型)+ 组件根身份(若它是某子组件的根)
|
|
17
|
+
attr_accessor :reuse_key, :identity, :component_identity, :component_props
|
|
18
|
+
# 组件边界节点:由 `render(Child)` 产生,承载子组件 view 的输出。
|
|
19
|
+
# 它自己不对应任何 DOM/画布元素(虚拟节点),只提供"一块可整体复用、整体销毁的区域"。
|
|
20
|
+
attr_accessor :rendered_component
|
|
21
|
+
# 该节点的两个 Effect(响应式属性 / 内容 block):复用时用来就地重跑
|
|
22
|
+
attr_accessor :props_effect, :block_effect
|
|
23
|
+
# 已按需绑定的事件监听标签(DOM 渲染器用):事件发生时从 props 现取处理器,
|
|
24
|
+
# 因此复用时只补挂新出现的处理器,不做"解绑再重绑"
|
|
25
|
+
attr_accessor :bound_listeners
|
|
26
|
+
|
|
27
|
+
def initialize(type, props = {}, block = nil, owner: nil)
|
|
28
|
+
@type = type
|
|
29
|
+
@props = props
|
|
30
|
+
@block = block
|
|
31
|
+
@owner = owner
|
|
32
|
+
@children = []
|
|
33
|
+
@owned_effects = []
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/citrine/num.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Citrine
|
|
4
|
+
# 跨平台数值工具(G-11)。
|
|
5
|
+
#
|
|
6
|
+
# Opal 的数值模型是 JS number:整数与浮点同为 Number,`7 / 2` 是 3.5 而非 3;
|
|
7
|
+
# `Float#round` 在负数半值上也曾与 MRI 不一致(见 opal/opal#2808)。
|
|
8
|
+
# 这些都是 Opal 明文记录的设计选择,不会改——但**每个真实应用都要重写一遍同样的工具**,
|
|
9
|
+
# 两个 dogfooding demo 各写了一份 `Num`,所以框架提供一次。
|
|
10
|
+
#
|
|
11
|
+
# 用法约定:需要整数商、需要与 MRI 一致的取整、需要跨平台一致的判定时走这里,
|
|
12
|
+
# 不要自己写 `(a / b).to_i`(负数会错)或直接 `.round`(负数半值会错)。
|
|
13
|
+
module Num
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# 整数除法,语义同 Ruby 的 Integer#/(向 -∞ 取整):
|
|
17
|
+
# Num.idiv(7, 2) # => 3
|
|
18
|
+
# Num.idiv(-7, 2) # => -4(注意不是 -3:Ruby 是 floor 语义)
|
|
19
|
+
def idiv(value, divisor)
|
|
20
|
+
value.div(divisor)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# 取整到 digits 位小数,**半值远离零**(与 MRI 的 Float#round 一致):
|
|
24
|
+
# Num.round_to(-1.5, 0) # => -2(Opal 1.8.3 的 -1.5.round 得 -1)
|
|
25
|
+
# Num.round_to(-1.25, 1) # => -1.3
|
|
26
|
+
# 先取绝对值再贴符号,绕开平台上"半值朝 +∞"的实现。
|
|
27
|
+
# 返回类型与 MRI 对齐:digits <= 0 → Integer,digits > 0 → Float。
|
|
28
|
+
#
|
|
29
|
+
# 注意:这里不写 `10 ** -digits` 统一处理——Opal 的 Integer#** 在指数为 0 时
|
|
30
|
+
# 会返回 Rational(`10 ** 0` → 1/1,上游 opal/corelib/number.rb:281 的条件
|
|
31
|
+
# `other > 0` 把 0 也归进了负指数分支),直接用它会让结果变成 -3/1 这种形态。
|
|
32
|
+
def round_to(value, digits)
|
|
33
|
+
digits = digits.to_i
|
|
34
|
+
sign = value.negative? ? -1 : 1
|
|
35
|
+
abs = value.abs
|
|
36
|
+
|
|
37
|
+
if digits.zero?
|
|
38
|
+
return value.to_i if integral?(value)
|
|
39
|
+
|
|
40
|
+
# 取绝对值后交给平台的 round:正数上两个平台的半值方向一致,
|
|
41
|
+
# 且 CRuby 的 Float#round 处理 0.49999999999999994 这类边界比 "+0.5 再 floor" 正确
|
|
42
|
+
abs.round * sign
|
|
43
|
+
elsif digits.positive?
|
|
44
|
+
factor = 10.0**digits
|
|
45
|
+
(abs * factor).round / factor * sign
|
|
46
|
+
else
|
|
47
|
+
# 负精度:按 10 的幂取整,用整数乘法避免浮点误差(1235 * 100 而不是 1235 / 0.01)
|
|
48
|
+
factor = 10**-digits
|
|
49
|
+
(abs / factor.to_f).round * factor * sign
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# 取整到整数(半值远离零):Num.round(-2.5) # => -3
|
|
54
|
+
def round(value)
|
|
55
|
+
round_to(value, 0)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# 整数值判定(显示层常用:2.0 显示成 "2" 而不是 "2.00")
|
|
59
|
+
def integral?(value)
|
|
60
|
+
value.to_f == value.to_f.round
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# 有限数判定(NaN / ±Infinity → false);非 Numeric 一律 false
|
|
64
|
+
def finite?(value)
|
|
65
|
+
return false unless value.is_a?(Numeric)
|
|
66
|
+
|
|
67
|
+
value.to_f.finite?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# 百分比格式化(显示层常用):Num.percent(0.1234, 1) # => "12.3%"
|
|
71
|
+
def percent(ratio, digits = 1)
|
|
72
|
+
"#{round_to(ratio.to_f * 100, digits)}%"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Citrine 打包器(macOS v1):把示例打成双击可运行的 .app
|
|
4
|
+
# 结构:build/<Name>.app/{MacOS/<Name>, Resources/{index.html, <name>.js}, Info.plist}
|
|
5
|
+
require "open3"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require_relative "version"
|
|
8
|
+
|
|
9
|
+
module Citrine
|
|
10
|
+
class Packager
|
|
11
|
+
def self.run!(args)
|
|
12
|
+
if args.empty?
|
|
13
|
+
abort <<~USAGE
|
|
14
|
+
用法: bin/citrine package <示例名>
|
|
15
|
+
示例: bin/citrine package counter # 打包 examples/counter
|
|
16
|
+
USAGE
|
|
17
|
+
end
|
|
18
|
+
new(args.first).package
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(name, dir: "examples")
|
|
22
|
+
@name = name
|
|
23
|
+
@dir = File.expand_path(dir)
|
|
24
|
+
@root = File.expand_path("../..", __dir__)
|
|
25
|
+
@app_name = "Citrine" + @name.split("_").map(&:capitalize).join
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def package
|
|
29
|
+
rb = File.join(@dir, "#{@name}.rb")
|
|
30
|
+
html_path = File.join(@dir, "#{@name}.html")
|
|
31
|
+
abort "找不到 #{rb} 或 #{html_path}" unless File.exist?(rb) && File.exist?(html_path)
|
|
32
|
+
|
|
33
|
+
app_dir = File.join(@root, "build", "#{@app_name}.app")
|
|
34
|
+
FileUtils.rm_rf(app_dir)
|
|
35
|
+
macos_dir = File.join(app_dir, "Contents", "MacOS")
|
|
36
|
+
res_dir = File.join(app_dir, "Contents", "Resources")
|
|
37
|
+
FileUtils.mkdir_p([macos_dir, res_dir])
|
|
38
|
+
|
|
39
|
+
puts "[package] 编译 #{@name}.rb …"
|
|
40
|
+
js = File.join(res_dir, "#{@name}.js")
|
|
41
|
+
compile_js(rb, js)
|
|
42
|
+
|
|
43
|
+
puts "[package] 复制资源 …"
|
|
44
|
+
FileUtils.cp(html_path, File.join(res_dir, "index.html"))
|
|
45
|
+
# S2-5:样式表随包复制(应用声明的 Citrine.css 文件与目录内 *.css)
|
|
46
|
+
Dir.glob(File.join(@dir, "*.css")).each do |css|
|
|
47
|
+
FileUtils.cp(css, res_dir)
|
|
48
|
+
puts " + #{File.basename(css)}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
puts "[package] 编译 Swift 壳 …"
|
|
52
|
+
exe = File.join(macos_dir, @app_name)
|
|
53
|
+
swift_build(exe)
|
|
54
|
+
|
|
55
|
+
File.write(File.join(app_dir, "Contents", "Info.plist"), info_plist)
|
|
56
|
+
puts "[package] 完成 → #{app_dir}"
|
|
57
|
+
puts " 运行: open #{app_dir}"
|
|
58
|
+
puts " 开发模式: #{exe} --dev http://localhost:4402/#{@name}.html(需先 bin/citrine dev)"
|
|
59
|
+
app_dir
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def compile_js(rb, output)
|
|
65
|
+
out, err, status = Open3.capture3(
|
|
66
|
+
opal_executable, "-c",
|
|
67
|
+
"-I#{File.join(@root, 'lib')}", "-I#{@dir}",
|
|
68
|
+
"-o", output, File.basename(rb),
|
|
69
|
+
chdir: @dir
|
|
70
|
+
)
|
|
71
|
+
return if status.success?
|
|
72
|
+
|
|
73
|
+
abort "编译失败:\n#{out}\n#{err}"
|
|
74
|
+
rescue Errno::ENOENT
|
|
75
|
+
abort "找不到 opal 可执行文件:请先 bundle install,并用 bundle exec bin/citrine package 打包"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def swift_build(output)
|
|
79
|
+
out, err, status = Open3.capture3(
|
|
80
|
+
"swiftc", "-framework", "Cocoa", "-framework", "WebKit",
|
|
81
|
+
"-o", output, File.join(@root, "desktop", "main.swift")
|
|
82
|
+
)
|
|
83
|
+
return if status.success?
|
|
84
|
+
|
|
85
|
+
abort "Swift 编译失败:\n#{out}\n#{err}"
|
|
86
|
+
rescue Errno::ENOENT
|
|
87
|
+
abort "找不到 swiftc:打包 macOS .app 需要 Xcode 命令行工具(xcode-select --install)"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A5:经 rubygems 解析 opal 的 binstub(bundler 环境下稳定指向 bundle 内的 opal);
|
|
91
|
+
# 解析不到退化为 PATH 查找——真缺失时由 Errno::ENOENT 分支给出可操作的 abort 提示
|
|
92
|
+
def opal_executable
|
|
93
|
+
Gem.bin_path("opal", "opal")
|
|
94
|
+
rescue Gem::LoadError
|
|
95
|
+
"opal"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def info_plist
|
|
99
|
+
<<~PLIST
|
|
100
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
101
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
102
|
+
<plist version="1.0">
|
|
103
|
+
<dict>
|
|
104
|
+
<key>CFBundleName</key>
|
|
105
|
+
<string>#{@app_name}</string>
|
|
106
|
+
<key>CFBundleDisplayName</key>
|
|
107
|
+
<string>#{@app_name}</string>
|
|
108
|
+
<key>CFBundleIdentifier</key>
|
|
109
|
+
<string>dev.rubyreact.#{@app_name.downcase}</string>
|
|
110
|
+
<key>CFBundleVersion</key>
|
|
111
|
+
<string>#{Citrine::VERSION}</string>
|
|
112
|
+
<key>CFBundleShortVersionString</key>
|
|
113
|
+
<string>#{Citrine::VERSION}</string>
|
|
114
|
+
<key>CFBundlePackageType</key>
|
|
115
|
+
<string>APPL</string>
|
|
116
|
+
<key>CFBundleExecutable</key>
|
|
117
|
+
<string>#{@app_name}</string>
|
|
118
|
+
<key>LSMinimumSystemVersion</key>
|
|
119
|
+
<string>11.0</string>
|
|
120
|
+
<key>NSHighResolutionCapable</key>
|
|
121
|
+
<true/>
|
|
122
|
+
<key>NSPrincipalClass</key>
|
|
123
|
+
<string>NSApplication</string>
|
|
124
|
+
</dict>
|
|
125
|
+
</plist>
|
|
126
|
+
PLIST
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "signal"
|
|
4
|
+
|
|
5
|
+
module Citrine
|
|
6
|
+
# 给**普通 Ruby 类**(领域模型、服务对象、测试替身)用的信号小混入:
|
|
7
|
+
#
|
|
8
|
+
# class Engine
|
|
9
|
+
# include Citrine::Reactive
|
|
10
|
+
#
|
|
11
|
+
# def initialize
|
|
12
|
+
# @tick_signal = signal(0)
|
|
13
|
+
# @quote_signals = {}
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# def quote_signal(code)
|
|
17
|
+
# @quote_signals[code] ||= signal { quote(code) } # 块 = 惰性初值
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# 为什么要有它:
|
|
22
|
+
# - 普通类拿不到组件的 `state` 宏,只能 `Citrine::Signal.new(...)`,冗长;
|
|
23
|
+
# - 更要紧的是**裸写 `Signal` 会撞上 stdlib / Opal corelib 的 `::Signal`(进程信号)**,
|
|
24
|
+
# 拿到的是那个类,报错完全不指向真因(FRICTION F14 为此排查过十几分钟)。
|
|
25
|
+
# `signal(...)` / `Citrine.signal(...)` 让用户永远不必写出那个裸名字。
|
|
26
|
+
#
|
|
27
|
+
# 组件里不要 include 它:组件已有同名的 `signal(name)`(按名字取已声明 state 的
|
|
28
|
+
# 底层信号),组件内要"按 key 取用的信号表"请用 `Component#keyed_signal`。
|
|
29
|
+
module Reactive
|
|
30
|
+
# 造一个新信号;给块则为惰性初值(第一次读取时求值一次)
|
|
31
|
+
def signal(value = nil, &init)
|
|
32
|
+
Signal.new(value, &init)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# 造一个响应式集合(ListSignal):集合自身的每次变更都是一次通知
|
|
36
|
+
def signal_list(items = [])
|
|
37
|
+
ListSignal.new(items)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|