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.
@@ -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
@@ -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
@@ -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