Module: Plushie::CanvasWidget
- Defined in:
- lib/plushie/canvas_widget.rb
Overview
Canvas widget extension system.
Canvas widgets are pure Ruby widgets that render via canvas shapes with runtime-managed internal state and event transformation. They sit between the renderer and the app, intercepting events in the scope chain and emitting semantic events.
Defining a canvas widget
class StarRating include Plushie::CanvasWidget
:star_rating
def self.init = {hover: nil}
def self.view(id, props, state) = ...
def self.handle_event(event, state) = [:ignored, state]
def self.subscribe(props, state) = []
end
How it works
build creates a placeholder canvas node tagged with metadata.
During tree normalization, the runtime detects the tag, looks up
the widget's state from the registry, calls render, and
recursively normalizes the output. The normalized tree carries
metadata for registry derivation after each render cycle.
Events flow through the scope chain before reaching app.update.
Each canvas widget in the chain gets a chance to handle the event:
:ignored passes through, :consumed stops the chain, and
[:emit, kind, data] replaces the event with a Widget event and
continues. The runtime fills in id, scope, and window_id
automatically from the widget's position in the tree.
Defined Under Namespace
Modules: ClassMethods Classes: RegistryEntry
Constant Summary collapse
- META_KEY =
Metadata key for the widget module reference in Node#meta.
:__canvas_widget__- PROPS_KEY =
Metadata key for widget input props in Node#meta.
:__canvas_widget_props__- STATE_KEY =
Metadata key for widget state snapshot in Node#meta.
:__canvas_widget_state__- CW_TAG_PREFIX =
Subscription tag namespace prefix for canvas widgets.
"__cw:"
Class Method Summary collapse
-
.build(widget_module, id, props = {}) ⇒ Node
Build a placeholder node for a canvas widget.
-
.collect_subscriptions(registry) ⇒ Array<Subscription::Sub>
Collect subscriptions from all canvas widgets in the registry.
-
.derive_registry(tree) ⇒ Hash{String => RegistryEntry}
Derive the registry from a normalized tree.
-
.dispatch_through_widgets(registry, event) ⇒ Array(Object, Hash)
Route an event through canvas widget handlers in the scope chain.
-
.handle_widget_timer(registry, tag) ⇒ Array(Object, Hash)?
Route a timer event to the correct canvas widget.
-
.parse_widget_tag(tag) ⇒ Array(String, String)?
Parse a namespaced tag into [widget_key, inner_tag].
-
.placeholder?(node) ⇒ Boolean
Check if a node is a canvas widget placeholder.
-
.render_placeholder(node, window_id, scoped_id, local_id, registry) ⇒ Array(Node, RegistryEntry)?
Render a canvas widget placeholder during normalization.
-
.widget_key(window_id, local_id) ⇒ String
Build a registry key from a window ID and a local widget path.
-
.widget_tag?(tag) ⇒ Boolean
Check if a subscription tag is namespaced for a canvas widget.
Class Method Details
.build(widget_module, id, props = {}) ⇒ Node
Build a placeholder node for a canvas widget.
The returned node has type "canvas" and carries metadata that the runtime uses during normalization to render the real canvas tree with the widget's current state.
97 98 99 100 101 102 103 |
# File 'lib/plushie/canvas_widget.rb', line 97 def self.build(, id, props = {}) = { META_KEY => , PROPS_KEY => props }.freeze Node.new(id: id, type: "widget_placeholder", props: {}, meta: ) end |
.collect_subscriptions(registry) ⇒ Array<Subscription::Sub>
Collect subscriptions from all canvas widgets in the registry.
Each subscription's tag is namespaced with the widget's scoped ID so the runtime can route timer events back to the correct widget.
167 168 169 170 171 172 |
# File 'lib/plushie/canvas_widget.rb', line 167 def self.collect_subscriptions(registry) registry.flat_map do |, entry| subs = entry..subscribe(entry.props, entry.state) Array(subs).map { |sub| namespace_tag(sub, ) } end end |
.derive_registry(tree) ⇒ Hash{String => RegistryEntry}
Derive the registry from a normalized tree.
Walks the tree and extracts canvas widget metadata from nodes. Returns a hash mapping window-aware widget keys to RegistryEntry values.
129 130 131 132 133 134 135 |
# File 'lib/plushie/canvas_widget.rb', line 129 def self.derive_registry(tree) return {} if tree.nil? registry = {} collect_entries(tree, registry, nil) registry end |
.dispatch_through_widgets(registry, event) ⇒ Array(Object, Hash)
Route an event through canvas widget handlers in the scope chain.
Returns [event_or_nil, updated_registry]. If no handler captures, returns the original event. If a handler consumes, returns nil.
147 148 149 150 151 152 153 154 155 156 |
# File 'lib/plushie/canvas_widget.rb', line 147 def self.(registry, event) scope = extract_scope(event) event_id = extract_id(event) window_id = extract_window_id(event) chain = build_handler_chain(registry, window_id, scope, event_id) return [event, registry] if chain.empty? walk_chain(registry, event, chain) end |
.handle_widget_timer(registry, tag) ⇒ Array(Object, Hash)?
Route a timer event to the correct canvas widget.
If the timer tag is namespaced, look up the widget, create a Timer event with the inner tag, dispatch through the widget's handler. Returns [event_or_nil, registry] for widget timers, or nil for non-widget timers (caller handles those).
216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 |
# File 'lib/plushie/canvas_widget.rb', line 216 def self.(registry, tag) parsed = (tag) return nil unless parsed , inner_tag = parsed entry = registry[] return [nil, registry] unless entry timer_event = Event::Timer.new( tag: inner_tag.to_sym, timestamp: Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) ) action, new_state = safe_handle_event(entry, timer_event, ) new_entry = RegistryEntry.new(widget_module: entry., state: new_state, props: entry.props) registry = registry.merge( => new_entry) case action in :ignored | :consumed | :update_state [nil, registry] in [:emit, kind, data] window_id, id, scope = resolve_emit_identity(timer_event, ) emitted = Event::Widget.new( type: kind.to_sym, id: id, window_id: window_id, scope: scope, value: normalize_emit_data(data, entry., kind) ) (registry, emitted) end end |
.parse_widget_tag(tag) ⇒ Array(String, String)?
Parse a namespaced tag into [widget_key, inner_tag]. Returns nil if the tag isn't namespaced.
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'lib/plushie/canvas_widget.rb', line 187 def self.(tag) tag_str = tag.to_s return nil unless tag_str.start_with?(CW_TAG_PREFIX) rest = tag_str[CW_TAG_PREFIX.length..] return nil if rest.nil? || rest.empty? first = rest.index(":") return nil unless first second = rest.index(":", first + 1) return nil unless second window_id = rest[0...first].to_s = rest[(first + 1)...second].to_s inner_tag = rest[(second + 1)..].to_s [(window_id, ), inner_tag] end |
.placeholder?(node) ⇒ Boolean
Check if a node is a canvas widget placeholder.
109 110 111 |
# File 'lib/plushie/canvas_widget.rb', line 109 def self.placeholder?(node) node.&.key?(META_KEY) || false end |
.render_placeholder(node, window_id, scoped_id, local_id, registry) ⇒ Array(Node, RegistryEntry)?
Render a canvas widget placeholder during normalization.
Looks up existing state from the registry, calls render, and returns the rendered node with widget metadata attached.
262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 |
# File 'lib/plushie/canvas_widget.rb', line 262 def self.render_placeholder(node, window_id, scoped_id, local_id, registry) = node.[META_KEY] = node.[PROPS_KEY] || {} return nil unless if window_id.nil? || window_id.empty? raise ArgumentError, "canvas widget #{local_id.inspect} must be rendered inside a window" end # Look up existing state or create initial. # scoped_id is already in "window#path" format from normalization. existing = registry[scoped_id] state = if existing existing.state else .init end entry = RegistryEntry.new(widget_module: , state: state, props: ) # View with local ID: scoping applied by caller rendered = .view(local_id, , state) # Auto-apply standard options (a11y, event_rate) from caller props # into the rendered node so widget authors don't need to forward them. auto_props = {} auto_props[:a11y] = [:a11y] if .key?(:a11y) auto_props[:event_rate] = [:event_rate] if .key?(:event_rate) rendered = rendered.with(props: rendered.props.merge(auto_props)) unless auto_props.empty? # Attach metadata for registry derivation = { META_KEY => , PROPS_KEY => , STATE_KEY => state }.freeze final_node = rendered.with(id: scoped_id, meta: ) [final_node, entry] end |
.widget_key(window_id, local_id) ⇒ String
Build a registry key from a window ID and a local widget path.
Produces the same format as normalized scoped IDs (window#path). Use this when you have the window and local ID as separate values. When you already have a full scoped ID (e.g., from node.id after normalization), use it directly as the registry key.
59 60 61 |
# File 'lib/plushie/canvas_widget.rb', line 59 def self.(window_id, local_id) "#{window_id}##{local_id}" end |
.widget_tag?(tag) ⇒ Boolean
Check if a subscription tag is namespaced for a canvas widget.
178 179 180 |
# File 'lib/plushie/canvas_widget.rb', line 178 def self.(tag) tag.to_s.start_with?(CW_TAG_PREFIX) end |