DSL internals
Maintainer and extension author guide to the Plushie UI DSL. This covers the runtime architecture, how blocks are evaluated, and how to add new widgets and type structs that participate in the DSL.
For user-facing DSL documentation, see the Plushie::UI module docs.
How the DSL works
The DSL is three layers that compose bottom-up:
Widget structs (data layer)
Plushie::Widget::* modules define typed builders for each widget.
Plushie::Type::* modules define shared property types (padding, border,
color, font, etc.). Plushie::Canvas::Shape::* modules define canvas shape
types. Each builder carries its valid fields, default values, and type
metadata.
Builder methods (construction layer)
Every widget module exposes new(id, opts) (or new(id, content, opts)
for widgets with positional args like Button.new("id", "label", opts)),
with_options(opts), and build. Type modules expose from_opts(opts).
Canvas shapes expose builder methods like Shape.rect(x, y, w, h, opts).
These are plain methods -- no metaprogramming. They can be called anywhere.
Block-based DSL (DSL layer)
Plushie::UI methods provide ergonomic block syntax. They use a
thread-local context stack to collect children during block evaluation.
The methods handle:
- Auto-ID generation from call-site file and line number
- Block-form option parsing
- Container child collection (flattening arrays, filtering nils)
- Canvas context validation
- Option key validation with helpful error messages
The key design constraint: DSL methods produce the same Node objects as
calling builder methods directly. The DSL is sugar, not a separate
representation.
Thread-local context stack
Unlike Elixir's macro-based DSL (which rewrites the AST at compile time), Ruby's DSL uses a thread-local stack at runtime. This is the fundamental mechanism:
# Simplified conceptual model
module Plushie::UI
def column(id = nil, **opts, &block)
node = Widget::Column.new(id || auto_id, opts)
if block
# Push a new children collector onto the thread-local stack
Thread.current[:plushie_dsl_stack] ||= []
Thread.current[:plushie_dsl_stack].push([])
begin
# Evaluate the block -- child DSL calls push onto the stack
instance_exec(&block)
children = Thread.current[:plushie_dsl_stack].pop
ensure
# ensure cleanup even if the block raises
Thread.current[:plushie_dsl_stack].pop if Thread.current[:plushie_dsl_stack].last == children
end
node = node.with_children(children.flatten.compact)
end
# Push this node onto the parent's children collector
push_node(node.build)
end
private
def push_node(node)
stack = Thread.current[:plushie_dsl_stack]
if stack && !stack.empty?
stack.last << node
end
node
end
end
Why thread-local, not instance variables
Thread-local storage ensures DSL evaluation is safe in concurrent contexts. Each thread gets its own stack, so multiple views can be rendered in parallel (e.g. in test suites) without interference. Instance variables would require the DSL module to maintain state on the including class, which creates coupling and concurrency issues.
Why not instance_eval
The DSL does not use instance_eval for block evaluation. Instead,
blocks are evaluated via instance_exec in the context of the app instance
(the object that includes Plushie::App). This means:
- You have access to the app's private methods from within blocks
selfinside a block is the app instance, not a DSL context object- Helper methods like
filtered_todos(model)work naturally
def view(model)
window("main") do
column do
# This calls a private method on the app -- works because
# the block runs in the app's context
filtered_todos(model).each do |todo|
text(todo.text)
end
end
end
end
private
def filtered_todos(model)
# ...
end
Ensure cleanup
The DSL stack uses ensure blocks to guarantee cleanup even when exceptions
occur inside blocks. Without this, an exception would leave stale entries on
the stack, corrupting subsequent renders:
begin
instance_exec(&block)
ensure
# Always pop, even if the block raised
Thread.current[:plushie_dsl_stack]&.pop
end
This is critical for error recovery. The runtime catches exceptions in
view and re-renders with the previous model. If the stack wasn't cleaned
up, the next render would inherit orphaned children from the failed render.
Auto-IDs
When a widget builder is called without an explicit ID, it generates one from the call site:
def auto_id
loc = caller_locations(2, 1)&.first
"auto:#{loc&.path}:#{loc&.lineno}"
end
Auto-IDs are stable across re-renders (same file + line = same ID) but do not create scopes. This matches the Elixir SDK's behaviour where auto-IDs pass through without adding to the scope chain.
How containers collect children
When a container's block contains both option declarations and children, the DSL partitions them at runtime:
Option recognition. Certain method calls inside blocks are recognized as option setters rather than children. For example,
spacing 8inside acolumnblock sets the spacing option.Child collection. Everything else (widget calls, loops, conditionals) produces child nodes that are collected on the thread-local stack.
Merge. Block-form options are merged over keyword options from the call line. Block values win on conflict.
# Both produce the same result:
column(spacing: 16, padding: 8) do
text("hello")
end
column do
spacing 16
padding 8
text("hello")
end
Adding a new widget
1. Create the builder module
Create lib/plushie/widget/my_widget.rb:
module Plushie
module Widget
class MyWidget
OPTION_KEYS = i[width height some_prop a11y].freeze
attr_reader :id, :some_prop, :width, :height, :a11y
def initialize(id, opts = {})
@id = id
@some_prop = opts[:some_prop]
@width = opts[:width]
@height = opts[:height]
@a11y = opts[:a11y]
end
def build
props = {}
props[:some_prop] = @some_prop if @some_prop
props[:width] = @width if @width
props[:height] = @height if @height
Node.new(id: @id, type: "my_widget", props: props, children: [])
end
end
end
end
2. Add a DSL method to Plushie::UI
For a leaf widget (no children):
def (id, **opts)
node = Widget::MyWidget.new(id, opts).build
push_node(node)
end
For a container widget (has children):
def (id = nil, **opts, &block)
# ... follow the column/row pattern with stack push/pop
end
3. Register
Add the new DSL method to the Plushie::UI module and ensure it's
available when include Plushie::App is used.
Adding a new type struct
Type structs represent complex property values (padding, border, font, shadow, etc.).
1. Create the type module
module Plushie
module Type
class MyType
attr_reader :field_a, :field_b
def initialize(field_a: nil, field_b: nil)
@field_a = field_a
@field_b = field_b
end
def self.from_opts(opts)
new(**opts.slice(:field_a, :field_b))
end
def to_encode
h = {}
h["field_a"] = @field_a if @field_a
h["field_b"] = @field_b if @field_b
h
end
end
end
end
2. Add the Encode implementation
Register the type with Plushie::Encode so it serializes correctly:
Plushie::Encode.register(Plushie::Type::MyType) do |obj|
obj.to_encode
end
Control flow in blocks
Multi-expression bodies
Unlike Elixir (where blocks evaluate to their last expression), Ruby
blocks naturally collect all values when each DSL call pushes onto the
stack. There is no multi-expression problem in Ruby -- every text(...),
button(...), etc. call inside a block independently pushes its node.
column do
if show_header?
text("Title") # pushes to stack
text("Subtitle") # also pushes to stack -- both appear
end
end
Loops
each and map work naturally:
column do
items.each do |item|
text(item.name)
end
end
Conditionals
if without else returns nil, which the DSL filters out:
column do
text("Always here")
text("Conditional") if model.show_extra # nil when false, filtered out
end
Prop override semantics
When both keyword arguments on the call line and block-form declarations specify the same option, the block-form value wins:
column(spacing: 8) do
spacing 16 # overrides the keyword arg -- spacing is 16
text("hello")
end