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

canvas_widget :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

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.

Parameters:

  • widget_module (Module)

    the canvas widget module

  • id (String)

    widget identifier

  • props (Hash) (defaults to: {})

    widget input props

Returns:



97
98
99
100
101
102
103
# File 'lib/plushie/canvas_widget.rb', line 97

def self.build(widget_module, id, props = {})
  meta = {
    META_KEY => widget_module,
    PROPS_KEY => props
  }.freeze
  Node.new(id: id, type: "widget_placeholder", props: {}, meta: 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.

Parameters:

Returns:



167
168
169
170
171
172
# File 'lib/plushie/canvas_widget.rb', line 167

def self.collect_subscriptions(registry)
  registry.flat_map do |widget_key, entry|
    subs = entry.widget_module.subscribe(entry.props, entry.state)
    Array(subs).map { |sub| namespace_tag(sub, widget_key) }
  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.

Parameters:

Returns:



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.

Parameters:

  • registry (Hash{String => RegistryEntry})
  • event (Object)

    the event to dispatch

Returns:

  • (Array(Object, Hash))


147
148
149
150
151
152
153
154
155
156
# File 'lib/plushie/canvas_widget.rb', line 147

def self.dispatch_through_widgets(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).

Parameters:

Returns:

  • (Array(Object, Hash), nil)


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.handle_widget_timer(registry, tag)
  parsed = parse_widget_tag(tag)
  return nil unless parsed

  widget_key, inner_tag = parsed
  entry = registry[widget_key]
  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, widget_key)
  new_entry = RegistryEntry.new(widget_module: entry.widget_module, state: new_state, props: entry.props)
  registry = registry.merge(widget_key => new_entry)

  case action
  in :ignored | :consumed | :update_state
    [nil, registry]
  in [:emit, kind, data]
    window_id, id, scope = resolve_emit_identity(timer_event, widget_key)
    emitted = Event::Widget.new(
      type: kind.to_sym,
      id: id,
      window_id: window_id,
      scope: scope,
      value: normalize_emit_data(data, entry.widget_module, kind)
    )
    dispatch_through_widgets(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.

Parameters:

  • tag (String, Symbol)

Returns:

  • (Array(String, String), nil)


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.parse_widget_tag(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
  widget_id = rest[(first + 1)...second].to_s
  inner_tag = rest[(second + 1)..].to_s
  [widget_key(window_id, widget_id), inner_tag]
end

.placeholder?(node) ⇒ Boolean

Check if a node is a canvas widget placeholder.

Parameters:

  • node (Node)

    the node to check

Returns:

  • (Boolean)


109
110
111
# File 'lib/plushie/canvas_widget.rb', line 109

def self.placeholder?(node)
  node.meta&.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.

Parameters:

  • node (Node)

    the placeholder node

  • window_id (String, nil)

    containing window ID

  • scoped_id (String)

    the normalized scoped ID

  • local_id (String)

    the pre-scoped local ID

  • registry (Hash{String => RegistryEntry})

Returns:



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)
  widget_module = node.meta[META_KEY]
  widget_props = node.meta[PROPS_KEY] || {}
  return nil unless widget_module
  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
    widget_module.init
  end

  entry = RegistryEntry.new(widget_module: widget_module, state: state, props: widget_props)

  # View with local ID: scoping applied by caller
  rendered = widget_module.view(local_id, widget_props, 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] = widget_props[:a11y] if widget_props.key?(:a11y)
  auto_props[:event_rate] = widget_props[:event_rate] if widget_props.key?(:event_rate)
  rendered = rendered.with(props: rendered.props.merge(auto_props)) unless auto_props.empty?

  # Attach metadata for registry derivation
  widget_meta = {
    META_KEY => widget_module,
    PROPS_KEY => widget_props,
    STATE_KEY => state
  }.freeze

  final_node = rendered.with(id: scoped_id, meta: widget_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.

Parameters:

  • window_id (String)

    window that contains the widget

  • local_id (String)

    widget path within the window

Returns:

  • (String)

    registry key



59
60
61
# File 'lib/plushie/canvas_widget.rb', line 59

def self.widget_key(window_id, local_id)
  "#{window_id}##{local_id}"
end

.widget_tag?(tag) ⇒ Boolean

Check if a subscription tag is namespaced for a canvas widget.

Parameters:

  • tag (String, Symbol)

Returns:

  • (Boolean)


178
179
180
# File 'lib/plushie/canvas_widget.rb', line 178

def self.widget_tag?(tag)
  tag.to_s.start_with?(CW_TAG_PREFIX)
end