omarchy-ui 0.0.1-x86_64-linux
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/App.qml +112 -0
- data/BarWidget.qml +47 -0
- data/Components/README.md +40 -0
- data/Components/Sparkline.qml +36 -0
- data/ControlNode.qml +745 -0
- data/LICENSE +22 -0
- data/Panel.qml +106 -0
- data/README.md +362 -0
- data/Service.qml +422 -0
- data/bin/omarchy_ui +7 -0
- data/lib/omarchy_ui/animation.rb +24 -0
- data/lib/omarchy_ui/application.rb +282 -0
- data/lib/omarchy_ui/builder.rb +230 -0
- data/lib/omarchy_ui/cli.rb +199 -0
- data/lib/omarchy_ui/command.rb +67 -0
- data/lib/omarchy_ui/component_registry.rb +87 -0
- data/lib/omarchy_ui/components.rb +43 -0
- data/lib/omarchy_ui/node.rb +30 -0
- data/lib/omarchy_ui/project.rb +127 -0
- data/lib/omarchy_ui/protocol.rb +29 -0
- data/lib/omarchy_ui/runtime.rb +31 -0
- data/lib/omarchy_ui/scheduler.rb +136 -0
- data/lib/omarchy_ui/state_store.rb +100 -0
- data/lib/omarchy_ui/value.rb +44 -0
- data/lib/omarchy_ui.rb +29 -0
- data/manifest.json +27 -0
- data/vendor/runtime/x86_64-linux/omarchy-ui-runtime +0 -0
- metadata +71 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json" unless Object.const_defined?(:JSON)
|
|
4
|
+
require "thread" unless Object.const_defined?(:Mutex)
|
|
5
|
+
|
|
6
|
+
module OmarchyUI
|
|
7
|
+
class Application
|
|
8
|
+
ANIMATION_DEFAULTS = { "opacity" => 1.0, "scale" => 1.0, "rotation" => 0.0, "z" => 0.0 }.freeze
|
|
9
|
+
attr_reader :state, :surfaces, :surface_options, :components
|
|
10
|
+
|
|
11
|
+
def initialize(components: DEFAULT_COMPONENTS, &definition)
|
|
12
|
+
@surfaces = {}
|
|
13
|
+
@surface_options = {}
|
|
14
|
+
@nodes = {}
|
|
15
|
+
@bindings = []
|
|
16
|
+
@structures = []
|
|
17
|
+
@handlers = {}
|
|
18
|
+
@sequence = 0
|
|
19
|
+
@output = nil
|
|
20
|
+
@error = $stderr
|
|
21
|
+
@running = false
|
|
22
|
+
@write_lock = Mutex.new
|
|
23
|
+
@state_change_lock = Mutex.new
|
|
24
|
+
@components = components.dup
|
|
25
|
+
@builder = Builder.new(self)
|
|
26
|
+
@state = StateStore.new(method(:state_changed))
|
|
27
|
+
@scheduler = Scheduler.new(evaluator: method(:evaluate), on_error: method(:report_internal_error))
|
|
28
|
+
@builder.instance_eval(&definition) if definition
|
|
29
|
+
raise ArgumentError, "plugin defines no surfaces" if @surfaces.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def define_state(name, initial) = @state.define(name, initial)
|
|
33
|
+
|
|
34
|
+
def build_node(type, explicit_id: nil, props: {})
|
|
35
|
+
definition = @components.fetch(type)
|
|
36
|
+
@sequence += 1
|
|
37
|
+
id = explicit_id ? explicit_id.to_s : "#{type}.#{@sequence}"
|
|
38
|
+
validate_id!(id)
|
|
39
|
+
raise ArgumentError, "duplicate control id: #{id}" if @nodes.key?(id)
|
|
40
|
+
|
|
41
|
+
normalized_props = normalize_props(props)
|
|
42
|
+
unknown = normalized_props.keys - definition.properties.map(&:to_s)
|
|
43
|
+
raise ArgumentError, "unsupported properties for #{type}: #{unknown.join(', ')}" unless unknown.empty?
|
|
44
|
+
Node.new(type:, id:, props: normalized_props).tap { |node| @nodes[id] = node }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def add_surface(name, node, options: {})
|
|
48
|
+
key = name.to_s
|
|
49
|
+
validate_id!(key)
|
|
50
|
+
raise ArgumentError, "duplicate surface: #{key}" if @surfaces.key?(key)
|
|
51
|
+
@surfaces[key] = node
|
|
52
|
+
@surface_options[key] = options.transform_keys(&:to_s).transform_values { |value| normalize_value(value) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def register_binding(node, property, reader, animation: nil)
|
|
56
|
+
property = property.to_s
|
|
57
|
+
unless @components.fetch(node.type).properties.map(&:to_s).include?(property)
|
|
58
|
+
raise ArgumentError, "unsupported bound property for #{node.type}: #{property}"
|
|
59
|
+
end
|
|
60
|
+
value = normalize_value(evaluate(reader), property)
|
|
61
|
+
node.props[property] = value
|
|
62
|
+
@bindings << Binding.new(node:, property:, reader:, last_value: value, animation:)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def register_structure(node, renderer)
|
|
66
|
+
@structures << StructuralBinding.new(node:, renderer:, last_children: node.children.map(&:to_h))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def register_handler(control_id, event, handler)
|
|
70
|
+
raise ArgumentError, "handler requires a block" unless handler
|
|
71
|
+
node = @nodes.fetch(control_id.to_s) { raise ArgumentError, "unknown event control: #{control_id}" }
|
|
72
|
+
event_name = event.to_s
|
|
73
|
+
declared = @components.fetch(node.type).events.map(&:to_s)
|
|
74
|
+
unless declared.include?(event_name) || %w[mount unmount].include?(event_name)
|
|
75
|
+
raise ArgumentError, "#{node.type} does not declare event: #{event_name}"
|
|
76
|
+
end
|
|
77
|
+
node.subscribe(event_name)
|
|
78
|
+
key = [control_id.to_s, event_name]
|
|
79
|
+
raise ArgumentError, "duplicate handler for #{key.join('/')}" if @handlers.key?(key)
|
|
80
|
+
@handlers[key] = handler
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def emit_effect(name, payload = {})
|
|
84
|
+
emit("v" => PROTOCOL_VERSION, "type" => "effect", "name" => name.to_s, "payload" => payload)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def animate(node, properties, animation)
|
|
88
|
+
emit_animation(node, animation_tracks(node, properties, animation))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def animation_tracks(node, properties, animation)
|
|
92
|
+
definition = @components.fetch(node.type)
|
|
93
|
+
properties.map do |property, value|
|
|
94
|
+
property_name = property.to_s
|
|
95
|
+
unless definition.properties.map(&:to_s).include?(property_name)
|
|
96
|
+
raise ArgumentError, "unsupported animated property for #{node.type}: #{property_name}"
|
|
97
|
+
end
|
|
98
|
+
normalized = normalize_value(value, property_name)
|
|
99
|
+
track = {
|
|
100
|
+
"property" => property_name,
|
|
101
|
+
"from" => node.props.fetch(property_name, ANIMATION_DEFAULTS[property_name]),
|
|
102
|
+
"to" => normalized
|
|
103
|
+
}.merge(animation.to_h)
|
|
104
|
+
node.props[property_name] = normalized
|
|
105
|
+
track
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def emit_animation(node, tracks)
|
|
110
|
+
raise ArgumentError, "animation requires at least one property" if tracks.empty?
|
|
111
|
+
emit("v" => PROTOCOL_VERSION, "type" => "patch", "op" => "animate", "id" => node.id, "tracks" => tracks)
|
|
112
|
+
node
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def tree = @surfaces.transform_values(&:to_h)
|
|
116
|
+
def normalize_value(value, property = nil) = Value.normalize(value, property:)
|
|
117
|
+
|
|
118
|
+
def start(output: $stdout, error: $stderr)
|
|
119
|
+
@output = output
|
|
120
|
+
@error = error
|
|
121
|
+
@output.sync = true if @output.respond_to?(:sync=)
|
|
122
|
+
@error.sync = true if @error.respond_to?(:sync=)
|
|
123
|
+
@running = true
|
|
124
|
+
pid = Object.const_defined?(:Process) && Process.respond_to?(:pid) ? Process.pid : 0
|
|
125
|
+
emit("v" => PROTOCOL_VERSION, "type" => "ready", "pid" => pid, "surfaces" => @surfaces.keys)
|
|
126
|
+
emit("v" => PROTOCOL_VERSION, "type" => "render", "components" => @components.protocol_schema,
|
|
127
|
+
"surfaces" => tree, "surface_options" => @surface_options)
|
|
128
|
+
@scheduler.start
|
|
129
|
+
self
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def run(input: $stdin, output: $stdout, error: $stderr)
|
|
133
|
+
start(output:, error:)
|
|
134
|
+
input.each_line do |line|
|
|
135
|
+
receive(line)
|
|
136
|
+
rescue StandardError => exception
|
|
137
|
+
report_internal_error(exception)
|
|
138
|
+
end
|
|
139
|
+
ensure
|
|
140
|
+
stop
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def stop
|
|
144
|
+
@running = false
|
|
145
|
+
@scheduler.stop
|
|
146
|
+
self
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def schedule(kind, interval: 0, immediate: false, &block)
|
|
150
|
+
@scheduler.schedule(kind, interval:, immediate:, &block)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def receive(raw_line)
|
|
154
|
+
raise ProtocolError, "message exceeds #{MAX_MESSAGE_BYTES} bytes" if raw_line.bytesize > MAX_MESSAGE_BYTES
|
|
155
|
+
message = JSON.parse(raw_line)
|
|
156
|
+
if message.is_a?(Hash) && message["v"] == PROTOCOL_VERSION && message["type"] == "tick"
|
|
157
|
+
@scheduler.tick
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
validate_message!(message)
|
|
161
|
+
dispatch_event(message)
|
|
162
|
+
rescue JSON::ParserError => exception
|
|
163
|
+
emit_protocol_error("invalid_json", exception.message)
|
|
164
|
+
rescue ProtocolError => exception
|
|
165
|
+
emit_protocol_error("invalid_message", exception.message)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def state_changed(_name, _previous, _value)
|
|
171
|
+
@state_change_lock.synchronize do
|
|
172
|
+
@structures.dup.each do |structure|
|
|
173
|
+
reconcile_structure(structure) if @structures.include?(structure)
|
|
174
|
+
end
|
|
175
|
+
@bindings.dup.each { |binding| update_binding(binding) }
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def update_binding(binding)
|
|
180
|
+
value = normalize_value(evaluate(binding.reader), binding.property)
|
|
181
|
+
return if value == binding.last_value
|
|
182
|
+
binding.last_value = value
|
|
183
|
+
binding.node.props[binding.property] = value
|
|
184
|
+
patch = {
|
|
185
|
+
"v" => PROTOCOL_VERSION, "type" => "patch", "op" => "set",
|
|
186
|
+
"id" => binding.node.id, "property" => binding.property, "value" => value
|
|
187
|
+
}
|
|
188
|
+
patch["animation"] = binding.animation.to_h if binding.animation
|
|
189
|
+
emit(patch)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def reconcile_structure(structure)
|
|
193
|
+
structure.node.children.dup.each { |child| unregister_subtree(child) }
|
|
194
|
+
structure.node.children.clear
|
|
195
|
+
@builder.rebuild(structure.node, &structure.renderer)
|
|
196
|
+
children = structure.node.children.map(&:to_h)
|
|
197
|
+
return if children == structure.last_children
|
|
198
|
+
structure.last_children = children
|
|
199
|
+
emit("v" => PROTOCOL_VERSION, "type" => "patch", "op" => "replace_children",
|
|
200
|
+
"id" => structure.node.id, "children" => children)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def unregister_subtree(node)
|
|
204
|
+
node.children.each { |child| unregister_subtree(child) }
|
|
205
|
+
@nodes.delete(node.id)
|
|
206
|
+
@bindings.delete_if { |binding| binding.node.equal?(node) }
|
|
207
|
+
@structures.delete_if { |structure| structure.node.equal?(node) }
|
|
208
|
+
@handlers.delete_if { |(control_id, _event), _handler| control_id == node.id }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def dispatch_event(message)
|
|
212
|
+
key = [message.fetch("id"), message.fetch("event")]
|
|
213
|
+
handler = @handlers[key]
|
|
214
|
+
raise ProtocolError, "unknown event target: #{key.join('/')}" unless handler
|
|
215
|
+
@builder.instance_exec(message["payload"] || {}, &handler)
|
|
216
|
+
acknowledgement = {
|
|
217
|
+
"v" => PROTOCOL_VERSION, "type" => "ack", "seq" => message["seq"],
|
|
218
|
+
"id" => message.fetch("id"), "event" => message.fetch("event")
|
|
219
|
+
}
|
|
220
|
+
acknowledgement["rss_kib"] = process_rss_kib if message.dig("payload", "diagnostics") == true
|
|
221
|
+
emit(acknowledgement)
|
|
222
|
+
rescue StandardError => exception
|
|
223
|
+
emit("v" => PROTOCOL_VERSION, "type" => "handler_error", "seq" => message["seq"],
|
|
224
|
+
"id" => message["id"], "message" => exception.message.to_s[0, 500])
|
|
225
|
+
@error.puts("omarchy-ui handler error: #{exception.class}: #{exception.message}")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def validate_message!(message)
|
|
229
|
+
raise ProtocolError, "message must be an object" unless message.is_a?(Hash)
|
|
230
|
+
raise ProtocolError, "unsupported protocol version" unless message["v"] == PROTOCOL_VERSION
|
|
231
|
+
raise ProtocolError, "unsupported message type" unless message["type"] == "event"
|
|
232
|
+
raise ProtocolError, "invalid surface" unless VALID_ID.match?(message["surface"].to_s)
|
|
233
|
+
raise ProtocolError, "unknown surface" unless @surfaces.key?(message["surface"])
|
|
234
|
+
raise ProtocolError, "invalid control id" unless VALID_ID.match?(message["id"].to_s)
|
|
235
|
+
unless surface_contains?(@surfaces.fetch(message["surface"]), message["id"])
|
|
236
|
+
raise ProtocolError, "control does not belong to surface"
|
|
237
|
+
end
|
|
238
|
+
raise ProtocolError, "invalid event" unless VALID_EVENT.match?(message["event"].to_s)
|
|
239
|
+
unless message["payload"].nil? || message["payload"].is_a?(Hash)
|
|
240
|
+
raise ProtocolError, "payload must be an object"
|
|
241
|
+
end
|
|
242
|
+
raise ProtocolError, "seq must be an integer" unless message["seq"].nil? || message["seq"].is_a?(Integer)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def surface_contains?(node, control_id)
|
|
246
|
+
node.id == control_id || node.children.any? { |child| surface_contains?(child, control_id) }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def evaluate(callable) = @builder.instance_exec(&callable)
|
|
250
|
+
|
|
251
|
+
def normalize_props(props)
|
|
252
|
+
props.each_with_object({}) { |(key, value), result| result[key.to_s] = normalize_value(value, key) }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def validate_id!(id)
|
|
256
|
+
raise ArgumentError, "invalid id: #{id.inspect}" unless VALID_ID.match?(id)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def emit(message)
|
|
260
|
+
return unless @running && @output
|
|
261
|
+
encoded = JSON.generate(message)
|
|
262
|
+
@write_lock.synchronize { @output.puts(encoded) }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def emit_protocol_error(code, message)
|
|
266
|
+
emit("v" => PROTOCOL_VERSION, "type" => "protocol_error", "code" => code,
|
|
267
|
+
"message" => message.to_s[0, 500])
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def report_internal_error(exception)
|
|
271
|
+
@error.puts("omarchy-ui runtime error: #{exception.class}: #{exception.message}")
|
|
272
|
+
emit("v" => PROTOCOL_VERSION, "type" => "runtime_error", "message" => exception.message.to_s[0, 500])
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def process_rss_kib
|
|
276
|
+
line = File.read("/proc/self/status").each_line.find { |entry| entry.start_with?("VmRSS:") }
|
|
277
|
+
line ? line.split[1].to_i : 0
|
|
278
|
+
rescue SystemCallError
|
|
279
|
+
0
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmarchyUI
|
|
4
|
+
class Builder
|
|
5
|
+
UNSET = Object.new.freeze
|
|
6
|
+
CONTAINERS = %i[row column container grid stack scroll rectangle].freeze
|
|
7
|
+
VALUE_INPUTS = {
|
|
8
|
+
text_field: :text,
|
|
9
|
+
number_field: :value,
|
|
10
|
+
slider: :value,
|
|
11
|
+
dropdown: :value,
|
|
12
|
+
multi_select: :values,
|
|
13
|
+
button_group: :value
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(application)
|
|
17
|
+
@application = application
|
|
18
|
+
@stack = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def component(type, id: nil, **props, &block)
|
|
22
|
+
definition = @application.components.fetch(type)
|
|
23
|
+
node = @application.build_node(type, explicit_id: id, props:)
|
|
24
|
+
append(node)
|
|
25
|
+
if block
|
|
26
|
+
raise ArgumentError, "#{type} is not a container" unless definition.container
|
|
27
|
+
within(node, &block)
|
|
28
|
+
end
|
|
29
|
+
node
|
|
30
|
+
end
|
|
31
|
+
alias widget component
|
|
32
|
+
alias qml_component component
|
|
33
|
+
|
|
34
|
+
CONTAINERS.each do |type|
|
|
35
|
+
define_method(type) { |id: nil, **props, &block| component(type, id:, **props, &block) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def register_component(name, qml:, properties: [], events: [], property_map: {}, event_map: {}, container: false, auto_bind: true)
|
|
39
|
+
unless @stack.empty? && @application.surfaces.empty?
|
|
40
|
+
raise ArgumentError, "components must be registered before a surface"
|
|
41
|
+
end
|
|
42
|
+
@application.components.register(name, qml:, properties:, events:, property_map:, event_map:, container:, auto_bind:)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def state(name = nil, initial = UNSET)
|
|
46
|
+
return @application.state if name.nil?
|
|
47
|
+
raise ArgumentError, "state requires an initial value" if initial.equal?(UNSET)
|
|
48
|
+
@application.define_state(name, initial)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
APP_OPTIONS = %i[title width height min_width min_height max_width max_height color visible maximized fullscreen].freeze
|
|
52
|
+
|
|
53
|
+
def bar_widget(&block) = surface("bar", id: "bar", &block)
|
|
54
|
+
def panel(name, &block) = surface(name.to_s, id: "panel.#{name}", &block)
|
|
55
|
+
|
|
56
|
+
def app(name = :main, **options, &block)
|
|
57
|
+
unknown = options.keys - APP_OPTIONS
|
|
58
|
+
raise ArgumentError, "unsupported app options: #{unknown.join(', ')}" unless unknown.empty?
|
|
59
|
+
surface(name.to_s, id: "app.#{name}", options:, &block)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def dynamic(type: :column, id: nil, **props, &renderer)
|
|
63
|
+
raise ArgumentError, "dynamic requires a block" unless renderer
|
|
64
|
+
definition = @application.components.fetch(type)
|
|
65
|
+
raise ArgumentError, "dynamic component must be a container: #{type}" unless definition.container
|
|
66
|
+
|
|
67
|
+
node = component(type, id:, **props, &renderer)
|
|
68
|
+
@application.register_structure(node, renderer)
|
|
69
|
+
node
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def text(value = UNSET, id: nil, **props, &reader)
|
|
73
|
+
node = component(:text, id:, **props)
|
|
74
|
+
bind_or_set(node, "text", value, reader)
|
|
75
|
+
node
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def icon(name, id: nil, **props) = component(:icon, id:, name: name.to_s, **props)
|
|
79
|
+
def image(source, id: nil, **props) = component(:image, id:, source: source.to_s, **props)
|
|
80
|
+
def spacer(id: nil, **props) = component(:spacer, id:, **props)
|
|
81
|
+
def separator(id: nil, **props) = component(:separator, id:, **props)
|
|
82
|
+
def section_header(value, id: nil, **props) = component(:section_header, id:, text: value.to_s, **props)
|
|
83
|
+
|
|
84
|
+
def button(label, id: nil, **props, &handler)
|
|
85
|
+
action_component(:button, :text, label, id:, props:, handler:)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def action_button(icon, id: nil, **props, &handler)
|
|
89
|
+
action_component(:action_button, :icon, icon, id:, props:, handler:)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def toggle(label = "", id: nil, checked: UNSET, **props, &handler)
|
|
93
|
+
input_component(:toggle, :checked, checked, id:, props: props.merge(label: label.to_s), handler:)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def toggle_switch(id: nil, checked: UNSET, **props, &handler)
|
|
97
|
+
input_component(:toggle_switch, :checked, checked, id:, props:, handler:)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
VALUE_INPUTS.each do |type, property|
|
|
101
|
+
define_method(type) do |value = UNSET, id: nil, **props, &handler|
|
|
102
|
+
input_component(type, property, value, id:, props:, handler:)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def progress(value = UNSET, id: nil, **props)
|
|
107
|
+
input_component(:progress, :value, value, id:, props:)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def on_click(&handler)
|
|
111
|
+
raise ArgumentError, "on_click must be inside a surface or control" if @stack.empty?
|
|
112
|
+
on(@stack.last, :click, &handler)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def on(target_or_event, event = nil, &handler)
|
|
116
|
+
raise ArgumentError, "on requires a block" unless handler
|
|
117
|
+
if target_or_event.is_a?(Node)
|
|
118
|
+
raise ArgumentError, "on(node, event) requires an event" unless event
|
|
119
|
+
node, event_name = target_or_event, event
|
|
120
|
+
else
|
|
121
|
+
raise ArgumentError, "on must be inside a surface or control" if @stack.empty?
|
|
122
|
+
node, event_name = @stack.last, target_or_event
|
|
123
|
+
end
|
|
124
|
+
@application.register_handler(node.id, event_name, handler)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def property(name, value = UNSET, &reader)
|
|
128
|
+
raise ArgumentError, "property must be inside a control" if @stack.empty?
|
|
129
|
+
bind_or_set(@stack.last, name.to_s, value, reader)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def bind(node, property, animation: nil, &reader)
|
|
133
|
+
raise ArgumentError, "bind requires a node returned by a component method" unless node.is_a?(Node)
|
|
134
|
+
raise ArgumentError, "bind requires a block" unless reader
|
|
135
|
+
transition = normalize_animation(animation)
|
|
136
|
+
@application.register_binding(node, property.to_s, reader, animation: transition)
|
|
137
|
+
node
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def animation(**options) = Animation.new(**options)
|
|
141
|
+
|
|
142
|
+
def animate(node, properties, duration: 200, easing: :in_out_quad, delay: 0)
|
|
143
|
+
transition = Animation.new(duration:, easing:, delay:)
|
|
144
|
+
@application.animate(node, properties, transition)
|
|
145
|
+
node
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def animate_sequence(node, steps)
|
|
149
|
+
elapsed = 0
|
|
150
|
+
tracks = steps.flat_map do |step|
|
|
151
|
+
options = step.transform_keys(&:to_sym)
|
|
152
|
+
properties = options.fetch(:to)
|
|
153
|
+
transition = Animation.new(
|
|
154
|
+
duration: options.fetch(:duration, 200),
|
|
155
|
+
easing: options.fetch(:easing, :in_out_quad),
|
|
156
|
+
delay: elapsed + options.fetch(:delay, 0)
|
|
157
|
+
)
|
|
158
|
+
elapsed = transition.delay + transition.duration + options.fetch(:pause, 0)
|
|
159
|
+
@application.animation_tracks(node, properties, transition)
|
|
160
|
+
end
|
|
161
|
+
@application.emit_animation(node, tracks)
|
|
162
|
+
node
|
|
163
|
+
end
|
|
164
|
+
def transaction(&block) = @application.state.transaction { instance_eval(&block) }
|
|
165
|
+
def after(seconds, &block) = @application.schedule(:after, interval: seconds, &block)
|
|
166
|
+
def every(seconds, immediate: false, &block) = @application.schedule(:every, interval: seconds, immediate:, &block)
|
|
167
|
+
def async(&block) = @application.schedule(:async, &block)
|
|
168
|
+
def run_command(argv, **options) = Command.run(argv, **options)
|
|
169
|
+
def rebuild(node, &renderer) = within(node, &renderer)
|
|
170
|
+
def open_panel(name) = @application.emit_effect("open_panel", "surface" => name.to_s)
|
|
171
|
+
|
|
172
|
+
def close_panel(name = nil)
|
|
173
|
+
@application.emit_effect("close_panel", name.nil? ? {} : { "surface" => name.to_s })
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
def surface(name, id:, options: {}, &block)
|
|
179
|
+
raise ArgumentError, "surface requires a block" unless block
|
|
180
|
+
node = @application.build_node(:container, explicit_id: id)
|
|
181
|
+
@application.add_surface(name, node, options:)
|
|
182
|
+
within(node, &block)
|
|
183
|
+
node
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def action_component(type, property, value, id:, props:, handler:)
|
|
187
|
+
node = component(type, id:, **props.merge(property => value.to_s))
|
|
188
|
+
@application.register_handler(node.id, :click, handler) if handler
|
|
189
|
+
node
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def input_component(type, property, value, id:, props:, handler: nil)
|
|
193
|
+
props = props.merge(property => value) unless value.equal?(UNSET)
|
|
194
|
+
node = component(type, id:, **props)
|
|
195
|
+
@application.register_handler(node.id, :change, handler) if handler
|
|
196
|
+
node
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def within(node, &block)
|
|
200
|
+
@stack.push(node)
|
|
201
|
+
instance_eval(&block)
|
|
202
|
+
ensure
|
|
203
|
+
@stack.pop
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def append(node)
|
|
207
|
+
raise ArgumentError, "#{node.type} must be inside a surface or container" if @stack.empty?
|
|
208
|
+
@stack.last.children << node
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def bind_or_set(node, property, value, reader)
|
|
212
|
+
if reader
|
|
213
|
+
raise ArgumentError, "pass a value or a reactive block, not both" unless value.equal?(UNSET)
|
|
214
|
+
@application.register_binding(node, property, reader)
|
|
215
|
+
else
|
|
216
|
+
raise ArgumentError, "#{property} requires a value or block" if value.equal?(UNSET)
|
|
217
|
+
node.props[property] = @application.normalize_value(value, property)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def normalize_animation(animation)
|
|
222
|
+
case animation
|
|
223
|
+
when nil then nil
|
|
224
|
+
when Animation then animation
|
|
225
|
+
when Hash then Animation.new(**animation)
|
|
226
|
+
else raise ArgumentError, "animation must be an OmarchyUI::Animation or options hash"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|