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