Current section
Files
Jump to
Current section
Files
lib/plushie/widget.ex
defmodule Plushie.Widget do
@moduledoc """
Macro-based DSL for declaring Plushie widgets.
Supports two kinds of widget:
- `:native_widget` -- backed by a Rust crate implementing the
`WidgetExtension` trait. Requires `rust_crate` and `rust_constructor`
declarations.
- `:widget` -- pure Elixir widget. Features are detected at compile
time based on what callbacks are defined:
- Has `state` declarations -> stateful (deferred view, state
persistence via the runtime).
- No `state` -> stateless (immediate view in `new/2`).
- Has `handle_event/2` -> participates in event dispatch.
- Has `subscribe/2` -> widget-scoped subscriptions.
## Usage
defmodule MyApp.Gauge do
use Plushie.Widget, :native_widget
widget :gauge
prop :value, :number
prop :min, :number, default: 0
prop :max, :number, default: 100
prop :color, :color, default: :blue
prop :width, :length
prop :height, :length
rust_crate "native/my_gauge"
rust_constructor "my_gauge::GaugeWidget::new()"
event :value_changed, data: [value: :number]
command :set_value, value: :number
end
## Generated code
The macro generates:
- `type_names/0` -- returns `[:gauge]` (from the `widget` declaration)
- `native_crate/0` -- returns the `rust_crate` path (native_widget only)
- `rust_constructor/0` -- returns the Rust expression (native_widget only)
- `new/2` -- creates a `%Module{}` struct ()
- Setter functions per prop for pipeline composition
- `with_options/2` -- applies keyword options via setters
- `build/1` -- converts the struct to a `ui_node()` map
- `@type t`, `@type option` -- typespecs for dialyzer
- `Plushie.Widget` protocol implementation
- `__event_specs__/0`, `__event_spec__/1` -- typed event metadata
- Command functions (native_widget only) that wrap
`Plushie.Command.widget_command/3`
## Prop types
Supported prop types. Values are stored raw; `Tree.normalize/1`
handles wire encoding in a single pass.
- `:number`, `:string`, `:boolean` -- pass through
- `:color` -- normalized via `Plushie.Type.Color.cast/1` (input casting)
- `:length` -- pass through (encoded by `Tree.normalize`)
- `:padding` -- pass through (encoded by `Tree.normalize`)
- `:alignment` -- pass through (encoded by `Tree.normalize`)
- `:font` -- pass through
- `:style` -- pass through (atom or StyleMap)
- `:atom` -- pass through (encoded by `Tree.normalize`)
- `:map`, `:any` -- pass through
- `{:list, _}` -- pass through
## Composite widgets
If the using module defines `view/2` (leaf) or `view/3` (container),
`new/2` delegates to it after resolving props:
defmodule MyApp.LabeledInput do
use Plushie.Widget
widget :labeled_input
prop :label, :string
def view(id, props) do
import Plushie.UI
column id: id do
text(props.label)
end
end
end
### view/2 vs view/3
Use `view/2` for simple widgets:
def view(id, props) do
%{id: id, type: "text", props: %{content: props.label}, children: []}
end
Use `view/3` when the widget has state (declared via `state`).
The third argument is the widget's internal state map:
def view(id, props, state) do
fill = if state.hover, do: "#ff0", else: "#ccc"
...
end
## Special options
All widgets automatically support:
- `:a11y` -- accessibility overrides (see `Plushie.Type.A11y`)
- `:event_rate` -- maximum events per second for coalescable events
from this widget (see the event throttling design doc)
These do not need to be declared via `prop` -- they are always
available on `new/2`.
"""
# -- Behaviour callbacks ---------------------------------------------------
@doc "Node type atoms this widget handles."
@callback type_names() :: [atom()]
@doc "Path to the Rust crate relative to the package root."
@callback native_crate() :: String.t()
@doc "Full Rust constructor expression for the widget."
@callback rust_constructor() :: String.t()
@optional_callbacks [native_crate: 0, rust_constructor: 0]
# -- Protocol delegation ---------------------------------------------------
# Delegate common protocol functions so users don't need to reference
# Plushie.Widget.WidgetProtocol directly.
@typedoc "A UI tree node map. Every widget builder returns this shape."
@type ui_node :: Plushie.Widget.WidgetProtocol.ui_node()
@typedoc "A child element: either an already-resolved node map or a widget struct."
@type child :: ui_node() | struct()
@doc "Converts a widget struct to a `ui_node()` map via the WidgetProtocol."
@spec to_node(struct()) :: ui_node()
defdelegate to_node(widget), to: Plushie.Widget.WidgetProtocol
# -- __using__ -------------------------------------------------------------
@valid_kinds [:native_widget, :widget]
defmacro __using__(opts) when is_list(opts) do
quote do: unquote(__using_kind__(:widget))
end
defmacro __using__(kind) when kind not in [:native_widget, :widget] do
raise ArgumentError,
"Plushie.Widget kind must be one of #{inspect(@valid_kinds)}, got: #{inspect(kind)}"
end
defmacro __using__(kind) when kind in [:native_widget, :widget] do
__using_kind__(kind)
end
defp __using_kind__(kind) do
common =
quote do
@behaviour Plushie.Widget
@_widget_kind unquote(kind)
Module.register_attribute(__MODULE__, :_widget_props, accumulate: true)
Module.register_attribute(__MODULE__, :_widget_commands, accumulate: true)
Module.register_attribute(__MODULE__, :_widget_event_familys, accumulate: true)
Module.register_attribute(__MODULE__, :_widget_event_family_specs, accumulate: true)
Module.put_attribute(__MODULE__, :_widget_type_name, nil)
Module.put_attribute(__MODULE__, :_widget_container, false)
end
widget_attrs =
if kind == :widget do
quote do
Module.register_attribute(__MODULE__, :_widget_state_fields, accumulate: true)
end
end
imports =
case kind do
:native_widget ->
quote do
import Plushie.Widget,
only: [
widget: 1,
widget: 2,
prop: 2,
prop: 3,
event: 1,
event: 2,
command: 1,
command: 2,
rust_crate: 1,
rust_constructor: 1
]
Module.put_attribute(__MODULE__, :_rust_crate, nil)
Module.put_attribute(__MODULE__, :_rust_constructor, nil)
end
:widget ->
quote do
import Plushie.Widget,
only: [widget: 1, widget: 2, prop: 2, prop: 3, state: 1, event: 1, event: 2]
import Plushie.UI
end
end
before_compile =
quote do
@before_compile Plushie.Widget
end
[common, widget_attrs, imports, before_compile]
end
# -- DSL macros ------------------------------------------------------------
@doc "Declares the widget type name. Pass `container: true` for container widgets."
defmacro widget(type_name, opts \\ []) do
unless is_atom(type_name) do
raise CompileError,
file: __CALLER__.file,
line: __CALLER__.line,
description: "widget type name must be an atom, got: #{inspect(type_name)}"
end
quote do
if @_widget_type_name do
IO.warn(
"widget type already declared as #{inspect(@_widget_type_name)}, overwriting with #{inspect(unquote(type_name))}"
)
end
@_widget_type_name unquote(type_name)
@_widget_container unquote(Keyword.get(opts, :container, false))
end
end
@doc "Declares a prop with name, type, and optional default."
defmacro prop(name, type, opts \\ []) do
quote do
@_widget_props {unquote(name), unquote(type), unquote(opts)}
end
end
@doc """
Declares a typed event emitted by a native or canvas widget.
Supports three forms:
## No payload
event :cleared
## Typed value (goes in `WidgetEvent.value`)
event :select, value: :number
## Structured data (goes in `WidgetEvent.data` with atom keys)
event :change, data: [hue: :number, saturation: :number]
## Block form
event :change do
data do
field :hue, :number
field :saturation, :number
end
end
`value:` and `data:` are mutually exclusive.
Type identifiers can be built-in atoms (`:number`, `:string`,
`:boolean`, `:any`) or modules implementing `Plushie.Event.EventType`.
"""
defmacro event(name, opts_or_block \\ [])
defmacro event(name, do: block) do
caller = __CALLER__
validate_event_name!(name, caller)
block = expand_type_aliases_in_ast(block, caller)
spec = parse_event_block(block, caller)
validate_event_spec!(name, spec, caller)
quote bind_quoted: [name: name, spec: Macro.escape(spec)] do
@_widget_event_familys name
@_widget_event_family_specs {name, spec}
end
end
defmacro event(name, opts) do
caller = __CALLER__
validate_event_name!(name, caller)
opts = expand_type_aliases(opts, caller)
spec = parse_event_opts(opts, caller)
validate_event_spec!(name, spec, caller)
quote bind_quoted: [name: name, spec: Macro.escape(spec)] do
@_widget_event_familys name
@_widget_event_family_specs {name, spec}
end
end
@doc """
Declares internal state fields for a stateful widget.
State fields are managed by the runtime, not the app model.
They persist across renders and are passed to `view/3` and
`handle_event/2`. Declaring state fields makes the widget
stateful: the view is deferred to tree normalization and the
`WidgetHandler` behaviour is injected automatically.
state hover: nil, drag: :none, animation_progress: 0.0
"""
defmacro state(fields) when is_list(fields) do
quote do
for {name, default} <- unquote(fields) do
@_widget_state_fields {name, default}
end
end
end
@doc "Declares a command (native_widget only) with optional typed params."
defmacro command(name, params \\ []) do
quote do
@_widget_commands {unquote(name), unquote(params)}
end
end
@doc "Declares the path to the Rust crate (native_widget only)."
defmacro rust_crate(path) do
quote do
@_rust_crate unquote(path)
end
end
@doc "Declares the Rust constructor expression (native_widget only)."
defmacro rust_constructor(expr) do
quote do
@_rust_constructor unquote(expr)
end
end
# -- __before_compile__ ----------------------------------------------------
@known_prop_types [
:number,
:string,
:boolean,
:color,
:length,
:padding,
:alignment,
:style,
:font,
:atom,
:map,
:any
]
defmacro __before_compile__(env) do
kind = Module.get_attribute(env.module, :_widget_kind)
widget_type = Module.get_attribute(env.module, :_widget_type_name)
container = Module.get_attribute(env.module, :_widget_container)
props = Module.get_attribute(env.module, :_widget_props) |> Enum.reverse()
commands = Module.get_attribute(env.module, :_widget_commands) |> Enum.reverse()
events = Module.get_attribute(env.module, :_widget_event_familys) |> Enum.reverse()
event_specs = Module.get_attribute(env.module, :_widget_event_family_specs) |> Enum.reverse()
validate_declarations!(env, kind, widget_type, events)
validate_prop_types!(env, props)
validate_command_types!(commands)
warn_duplicate_props(env, props)
warn_duplicate_events(env, events)
validate_reserved_names!(env, props)
rust_crate_val = Module.get_attribute(env.module, :_rust_crate)
rust_constructor_val = Module.get_attribute(env.module, :_rust_constructor)
has_view_3 = Module.defines?(env.module, {:view, 3})
has_view = Module.defines?(env.module, {:view, 2}) or has_view_3
if has_view do
validate_widget_callbacks!(env)
end
type_string = Atom.to_string(widget_type)
behaviour_fns =
generate_behaviour_fns(
kind,
widget_type,
events,
event_specs,
rust_crate_val,
rust_constructor_val
)
widget_code =
if has_view do
# All widgets with view go through the unified path:
# struct + placeholder to_node + deferred view.
state_fields =
(Module.get_attribute(env.module, :_widget_state_fields) || [])
|> Enum.reverse()
prop_validation = generate_prop_validation(props)
generate_widget_new(
env.module,
widget_type,
type_string,
events,
event_specs,
props,
state_fields,
prop_validation
)
else
# Struct-only widgets (native_widget, no view callback).
generate_struct_widget(
env.module,
widget_type,
type_string,
container,
props,
events,
event_specs
)
end
# Inject @behaviour WidgetHandler for stateful widgets (detected by state fields).
# Inject WidgetHandler behaviour for all widgets with view callbacks.
# This must happen in __before_compile__ since we detect view at this point.
widget_handler_behaviour =
if has_view do
quote do
@behaviour Plushie.Widget.Handler
end
end
command_fns = generate_commands(commands)
prop_names_fn = generate_prop_names(props)
dsl_macro = generate_dsl_macro(widget_type, props)
quote do
unquote(widget_handler_behaviour)
unquote(behaviour_fns)
unquote(widget_code)
unquote(command_fns)
unquote(prop_names_fn)
unquote(dsl_macro)
end
end
# -- DSL macro generation ----------------------------------------------------
@doc false
def generate_dsl_macro(widget_type, _props) do
macro_name = widget_type
quote do
@doc """
Creates a `#{inspect(unquote(macro_name))}` widget.
Shorthand for `new/2`. Import this macro to use the widget name
directly in view functions:
import #{inspect(__MODULE__)}, only: [#{unquote(Atom.to_string(macro_name))}: 2]
#{unquote(Atom.to_string(macro_name))}("my-id", prop: value)
"""
defmacro unquote(macro_name)(id, opts \\ []) do
mod = __MODULE__
quote do: unquote(mod).new(unquote(id), unquote(opts))
end
end
end
# -- Event spec parsing (called at compile time from macros) ----------------
# Expand module aliases in keyword option values at compile time.
@doc false
def expand_type_aliases(opts, caller) when is_list(opts) do
Enum.map(opts, fn
{:value, type} ->
{:value, maybe_expand_alias(type, caller)}
{:data, fields} when is_list(fields) ->
{:data, Enum.map(fields, fn {k, v} -> {k, maybe_expand_alias(v, caller)} end)}
other ->
other
end)
end
@doc false
def expand_type_aliases_in_ast(block, caller) do
Macro.prewalk(block, fn
{:field, meta, [name, type]} ->
{:field, meta, [name, maybe_expand_alias(type, caller)]}
other ->
other
end)
end
defp maybe_expand_alias({:__aliases__, _, _} = ast, caller) do
Macro.expand(ast, caller)
end
defp maybe_expand_alias(other, _caller), do: other
@doc false
def validate_event_name!(name, caller) do
unless is_atom(name) do
raise CompileError,
file: caller.file,
line: caller.line,
description: "event name must be an atom, got: #{inspect(name)}"
end
end
@doc false
def parse_event_opts([], _caller), do: %{carrier: :none}
def parse_event_opts(opts, caller) when is_list(opts) do
has_value = Keyword.has_key?(opts, :value)
has_data = Keyword.has_key?(opts, :data)
if has_value and has_data do
raise CompileError,
file: caller.file,
line: caller.line,
description: "event cannot declare both value: and data: -- they are mutually exclusive"
end
cond do
has_value ->
%{carrier: :value, type: Keyword.fetch!(opts, :value)}
has_data ->
fields = Keyword.fetch!(opts, :data)
unless is_list(fields) and Keyword.keyword?(fields) do
raise CompileError,
file: caller.file,
line: caller.line,
description:
"event data: must be a keyword list of [field: type], got: #{inspect(fields)}"
end
%{carrier: :data, fields: fields}
true ->
raise CompileError,
file: caller.file,
line: caller.line,
description: "event options must include value: or data:, got: #{inspect(opts)}"
end
end
@doc false
@spec parse_event_block(block :: Macro.t(), caller :: Macro.Env.t()) ::
Plushie.Event.BuiltinSpecs.t()
def parse_event_block(block, caller) do
stmts = block_to_list(block)
Enum.reduce(stmts, %{carrier: :none}, fn
{:value, _meta, [type]}, %{carrier: :none} ->
%{carrier: :value, type: type}
{:data, _meta, [[do: inner_block]]}, %{carrier: :none} ->
%{carrier: :data, fields: parse_data_fields(inner_block, caller)}
{:data, _meta, [fields]}, %{carrier: :none} when is_list(fields) ->
unless Keyword.keyword?(fields) do
raise CompileError,
file: caller.file,
line: caller.line,
description: "event data: must be a keyword list of [field: type]"
end
%{carrier: :data, fields: fields}
_other, acc ->
acc
end)
end
defp block_to_list({:__block__, _, stmts}), do: stmts
defp block_to_list(stmt), do: [stmt]
defp parse_data_fields({:__block__, _, stmts}, caller), do: parse_data_stmts(stmts, caller)
defp parse_data_fields(stmt, caller), do: parse_data_stmts([stmt], caller)
defp parse_data_stmts(stmts, caller) do
Enum.map(stmts, fn
{:field, _meta, [name, type]} when is_atom(name) ->
{name, type}
other ->
raise CompileError,
file: caller.file,
line: caller.line,
description:
"expected `field :name, :type` inside data block, got: #{Macro.to_string(other)}"
end)
end
@doc false
@spec validate_event_spec!(
name :: atom(),
spec :: Plushie.Event.BuiltinSpecs.t(),
caller :: Macro.Env.t()
) :: :ok
def validate_event_spec!(name, spec, caller) do
case spec do
%{carrier: :value, type: type} ->
validate_event_field_type!(name, nil, type, caller)
%{carrier: :data, fields: fields} ->
Enum.each(fields, fn {field_name, type} ->
validate_event_field_type!(name, field_name, type, caller)
end)
%{carrier: :none} ->
:ok
end
end
defp validate_event_field_type!(event_name, field_name, type, caller) do
unless Plushie.Event.EventType.valid_type?(type) do
context =
if field_name,
do: "field #{inspect(field_name)} has",
else: "has"
raise CompileError,
file: caller.file,
line: caller.line,
description:
"event #{inspect(event_name)} #{context} invalid type #{inspect(type)}. " <>
"Use a built-in type (:number, :string, :boolean, :any) or a module implementing Plushie.Event.EventType."
end
end
defp validate_declarations!(env, kind, widget_type, _events) do
unless widget_type do
raise CompileError,
file: env.file,
line: 0,
description: "missing `widget :type_name` declaration in #{inspect(env.module)}"
end
if kind == :native_widget do
unless Module.get_attribute(env.module, :_rust_crate) do
raise CompileError,
file: env.file,
line: 0,
description: "missing `rust_crate \"path\"` in #{inspect(env.module)}"
end
unless Module.get_attribute(env.module, :_rust_constructor) do
raise CompileError,
file: env.file,
line: 0,
description: "missing `rust_constructor \"expr\"` in #{inspect(env.module)}"
end
end
# All widget kinds can declare events via the event macro.
end
defp validate_prop_types!(env, props) do
for {name, type, _opts} <- props do
unless valid_type?(type) do
raise CompileError,
file: env.file,
line: 0,
description:
"unsupported prop type #{inspect(type)} for prop #{inspect(name)} in #{inspect(env.module)}. " <>
"Supported: #{inspect(@known_prop_types ++ [{:list, :type}])}"
end
end
end
defp validate_command_types!(commands) do
for {cmd_name, params} <- commands, {param_name, type} <- params do
unless valid_type?(type) do
raise CompileError,
description:
"unsupported command param type #{inspect(type)} for param #{inspect(param_name)} in command #{inspect(cmd_name)}"
end
end
end
defp warn_duplicate_props(env, props) do
prop_names = Enum.map(props, fn {name, _, _} -> name end)
dupes = prop_names -- Enum.uniq(prop_names)
if dupes != [] do
IO.warn(
"duplicate prop names in #{inspect(env.module)}: #{inspect(Enum.uniq(dupes))}",
Macro.Env.stacktrace(env)
)
end
end
defp warn_duplicate_events(env, events) do
dupes = events -- Enum.uniq(events)
if dupes != [] do
IO.warn(
"duplicate event names in #{inspect(env.module)}: #{inspect(Enum.uniq(dupes))}",
Macro.Env.stacktrace(env)
)
end
end
@reserved_prop_names [:id, :type, :children, :a11y, :event_rate, :do]
defp validate_reserved_names!(env, props) do
for {name, _type, _opts} <- props, name in @reserved_prop_names do
raise CompileError,
file: env.file,
line: 0,
description:
"prop name #{inspect(name)} is reserved in #{inspect(env.module)}. " <>
"Reserved names: #{inspect(@reserved_prop_names)}"
end
end
defp validate_widget_callbacks!(env) do
has_view_2 = Module.defines?(env.module, {:view, 2})
has_view_3 = Module.defines?(env.module, {:view, 3})
unless has_view_2 or has_view_3 do
raise CompileError,
file: env.file,
line: 0,
description: "#{inspect(env.module)} must define view/2 or view/3."
end
end
defp generate_widget_new(
module,
widget_type,
_type_string,
events,
event_specs,
props,
state_fields,
prop_extract
) do
prop_struct_fields =
for {name, _type, opts} <- props do
default = Keyword.get(opts, :default)
{name, default}
end
# Struct fields: :id + declared props + standard widget options
struct_fields = [{:id, nil} | prop_struct_fields] ++ [{:event_rate, nil}, {:a11y, nil}]
state_defaults = Macro.escape(Map.new(state_fields))
has_handle_event = Module.defines?(module, {:handle_event, 2})
has_view_3 = Module.defines?(module, {:view, 3})
has_view_2 = Module.defines?(module, {:view, 2})
participates_in_dispatch = participates_in_dispatch?(has_handle_event, events, state_fields)
default_handle_event =
unless has_handle_event do
# Widgets with event declarations are opaque by default (consume all
# events). Render-only widgets without events are transparent (events
# pass through to the app's update/2).
default_action = if events != [], do: :consumed, else: :ignored
quote do
@doc false
def handle_event(_event, _state), do: unquote(default_action)
end
end
# Composites with view/2 need a view/3 wrapper so the unified
# normalization path can call view(id, props, state) uniformly.
view_adapter =
if has_view_2 and not has_view_3 do
quote do
@doc false
def view(id, props, _state), do: view(id, props)
end
end
quote do
unquote(view_adapter)
@doc "Returns the initial internal state for this widget."
@spec __initial_state__() :: map()
def __initial_state__, do: unquote(state_defaults)
@doc "Returns true for widget modules with view callbacks."
@spec __widget__?() :: true
def __widget__?, do: true
unquote(default_handle_event)
defstruct unquote(Macro.escape(struct_fields))
defimpl Plushie.Widget.WidgetProtocol do
@doc """
Converts the stateful widget struct to a placeholder node.
The placeholder carries the module and props as metadata tags.
During tree normalization, the runtime detects these tags and
renders the widget with the appropriate internal state (stored
from a previous cycle, or initial defaults for new widgets).
"""
def to_node(widget) do
props =
widget
|> Map.from_struct()
|> Map.delete(:id)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
%{
id: widget.id,
type: "widget_placeholder",
props: %{
__widget__: unquote(module),
__widget_props__: props,
__widget_type__: unquote(widget_type),
__widget_events__: unquote(events),
__widget_event_specs__: unquote(Macro.escape(event_specs)),
__widget_handles_events__: unquote(participates_in_dispatch)
},
children: []
}
end
end
@doc """
Creates a new canvas widget instance.
Returns a struct that participates in the standard Widget protocol
pipeline: struct → to_node → normalize. The widget is rendered
during tree normalization with stored internal state (or initial
defaults on first render).
"""
@spec new(id :: String.t(), opts :: keyword()) :: %__MODULE__{}
def new(id, opts \\ []) when is_binary(id) do
{event_rate_val, opts} = Keyword.pop(opts, :event_rate)
{a11y_val, opts} = Keyword.pop(opts, :a11y)
prop_defaults = unquote(Macro.escape(prop_struct_fields))
unquote(prop_extract)
props_map =
prop_defaults
|> Enum.map(fn {name, default} ->
{name, Keyword.get(opts, name, default)}
end)
|> Enum.reject(fn {_name, val} -> is_nil(val) end)
|> Map.new()
props_map =
if event_rate_val,
do: Map.put(props_map, :event_rate, event_rate_val),
else: props_map
props_map =
if a11y_val, do: Map.put(props_map, :a11y, a11y_val), else: props_map
struct!(__MODULE__, Map.put(props_map, :id, id))
end
end
end
# Widget participates in event dispatch if it declares events,
# has state fields, or defines handle_event/2 explicitly.
defp participates_in_dispatch?(has_handle_event, events, state_fields) do
has_handle_event or events != [] or state_fields != []
end
defp valid_type?(type) when type in @known_prop_types, do: true
defp valid_type?({:list, inner}) when inner in @known_prop_types, do: true
defp valid_type?(_), do: false
# -- Code generation helpers (called at compile time) ----------------------
@doc false
def generate_behaviour_fns(
kind,
widget_type,
events,
event_specs,
rust_crate_val,
rust_constructor_val
) do
# Build a map of event_name => spec for __event_spec__/1 lookups.
specs_map = Map.new(event_specs, fn {name, spec} -> {name, spec} end)
base =
quote do
@impl Plushie.Widget
def type_names, do: [unquote(widget_type)]
@doc false
@spec __widget_type__() :: atom()
def __widget_type__, do: unquote(widget_type)
@doc false
@spec __events__() :: [atom()]
def __events__, do: unquote(events)
@doc false
@spec __event_specs__() :: [{atom(), Plushie.Event.BuiltinSpecs.t()}]
def __event_specs__, do: unquote(Macro.escape(event_specs))
@doc false
@spec __event_spec__(name :: atom()) :: Plushie.Event.BuiltinSpecs.t() | nil
def __event_spec__(name), do: Map.get(unquote(Macro.escape(specs_map)), name)
end
if kind == :native_widget do
quote do
unquote(base)
@impl Plushie.Widget
def native_crate, do: unquote(rust_crate_val)
@impl Plushie.Widget
def rust_constructor, do: unquote(rust_constructor_val)
end
else
base
end
end
@doc false
def generate_prop_names(props) do
known =
Enum.map(props, fn {name, _type, _opts} -> name end)
|> Kernel.++([:event_rate, :a11y])
quote do
@doc false
def __prop_names__, do: unquote(known)
end
end
@known_type_mappings %{
a11y: Plushie.Type.A11y,
padding: Plushie.Type.Padding,
style: Plushie.Type.StyleMap,
border: Plushie.Type.Border,
shadow: Plushie.Type.Shadow,
font: Plushie.Type.Font
}
@doc false
def generate_struct_widget(
module,
widget_type,
type_string,
container,
props,
events,
event_specs \\ []
) do
struct_def = generate_struct_and_types(props, container)
new_fn = generate_struct_new(container)
with_options_fn = generate_with_options(props)
setters = generate_setters(props)
dsl_fns = generate_dsl_buildable(props)
build_fn = generate_build()
protocol_impl =
generate_widget_protocol(
module,
widget_type,
type_string,
container,
props,
events,
event_specs
)
quote do
unquote(struct_def)
unquote(new_fn)
unquote(with_options_fn)
unquote_splicing(setters)
unquote(dsl_fns)
unquote(build_fn)
unquote(protocol_impl)
end
end
# -- Struct-based widget generation -----------------------------------------
#
# For struct-only widgets (native_widget) (no view/2 or view/3), we generate:
# - defstruct with all props + :id + :event_rate + :a11y
# - @type t with proper field types
# - @type option union of keyword tuples
# - new/2 that creates a struct and applies keyword options
# - with_options/2 that reduces options through setter functions
# - A setter function per prop with encoding and guards
# - a11y/2 setter for accessibility overrides
# - build/1 convenience that calls the protocol
# - Plushie.Widget protocol implementation (to_node)
defp generate_struct_and_types(props, container) do
prop_fields =
Enum.map(props, fn {name, _type, opts} -> {name, Keyword.get(opts, :default)} end)
struct_fields =
[{:id, nil} | prop_fields] ++
if(container, do: [{:children, []}], else: []) ++
[{:event_rate, nil}, {:a11y, nil}]
prop_type_fields = Enum.map(props, &prop_type_ast/1)
type_fields =
[{:id, quote(do: String.t())} | prop_type_fields] ++
if(container,
do: [{:children, quote(do: [Plushie.Widget.ui_node()])}],
else: []
) ++
[
{:event_rate, quote(do: pos_integer() | nil)},
{:a11y, quote(do: Plushie.Type.A11y.t() | nil)}
]
option_variants =
Enum.map(props, fn {name, type, _opts} ->
quote(do: {unquote(name), unquote(elixir_type_for(type))})
end) ++
[
quote(do: {:event_rate, pos_integer()}),
quote(do: {:a11y, Plushie.Type.A11y.t()})
]
quote do
@enforce_keys [:id]
defstruct unquote(struct_fields)
@type t :: %__MODULE__{unquote_splicing(type_fields)}
@type option :: unquote(union_type(option_variants))
end
end
defp prop_type_ast({name, :color, _opts}) do
{name, quote(do: Plushie.Type.Color.t() | nil)}
end
defp prop_type_ast({name, type, _opts}) do
{name, quote(do: unquote(elixir_type_for(type)) | nil)}
end
defp elixir_type_for(:number), do: quote(do: number())
defp elixir_type_for(:string), do: quote(do: String.t())
defp elixir_type_for(:boolean), do: quote(do: boolean())
defp elixir_type_for(:color), do: quote(do: Plushie.Type.Color.input())
defp elixir_type_for(:length), do: quote(do: Plushie.Type.Length.t())
defp elixir_type_for(:padding), do: quote(do: Plushie.Type.Padding.t())
defp elixir_type_for(:alignment), do: quote(do: Plushie.Type.Alignment.t())
defp elixir_type_for(:font), do: quote(do: Plushie.Type.Font.t())
defp elixir_type_for(:style), do: quote(do: atom() | Plushie.Type.StyleMap.t())
defp elixir_type_for(:atom), do: quote(do: atom())
defp elixir_type_for(:map), do: quote(do: map())
defp elixir_type_for(:any), do: quote(do: term())
defp elixir_type_for({:list, inner}), do: quote(do: [unquote(elixir_type_for(inner))])
defp union_type([single]), do: single
defp union_type([head | tail]) do
Enum.reduce(tail, head, fn variant, acc ->
quote(do: unquote(acc) | unquote(variant))
end)
end
defp generate_struct_new(container) do
if container do
quote do
@doc "Creates a new widget struct with the given ID and keyword options."
@spec new(id :: String.t(), opts :: [option()]) :: t()
def new(id, opts \\ []) when is_binary(id) do
{children, opts} = Keyword.pop(opts, :do, [])
widget = %__MODULE__{id: id} |> with_options(opts)
%{widget | children: List.wrap(children)}
end
end
else
quote do
@doc "Creates a new widget struct with the given ID and keyword options."
@spec new(id :: String.t(), opts :: [option()]) :: t()
def new(id, opts \\ []) when is_binary(id) do
%__MODULE__{id: id} |> with_options(opts)
end
end
end
end
defp generate_with_options(props) do
v = Macro.var(:v, __MODULE__)
acc = Macro.var(:acc, __MODULE__)
key = Macro.var(:key, __MODULE__)
prop_clauses =
Enum.map(props, fn {name, _type, _opts} ->
{:->, [],
[
[{:{}, [], [name, v]}, acc],
quote(do: __MODULE__.unquote(name)(unquote(acc), unquote(v)))
]}
end)
event_rate_clause =
{:->, [],
[
[{:{}, [], [:event_rate, v]}, acc],
quote(do: __MODULE__.event_rate(unquote(acc), unquote(v)))
]}
a11y_clause =
{:->, [],
[
[{:{}, [], [:a11y, v]}, acc],
quote(do: __MODULE__.a11y(unquote(acc), unquote(v)))
]}
unknown_v = Macro.var(:_, __MODULE__)
unknown_acc = Macro.var(:_, __MODULE__)
unknown_clause =
{:->, [],
[
[{:{}, [], [key, unknown_v]}, unknown_acc],
quote do
raise ArgumentError,
"unknown option #{inspect(unquote(key))} for #{inspect(__MODULE__)}.new"
end
]}
all_clauses = prop_clauses ++ [event_rate_clause, a11y_clause, unknown_clause]
reducer_fn = {:fn, [], all_clauses}
quote do
@doc "Applies keyword options to an existing widget struct."
@spec with_options(widget :: t(), opts :: [option()]) :: t()
def with_options(%__MODULE__{} = widget, []), do: widget
def with_options(%__MODULE__{} = widget, opts) do
Enum.reduce(opts, widget, unquote(reducer_fn))
end
end
end
defp generate_dsl_buildable(props) do
prop_names = Enum.map(props, fn {name, _type, _opts} -> name end)
field_types_map =
for {name, _type, _opts} <- props,
mod = Map.get(@known_type_mappings, name),
into: %{},
do: {name, mod}
quote do
@behaviour Plushie.DSL.Buildable
@impl Plushie.DSL.Buildable
def from_opts(opts), do: with_options(%__MODULE__{id: Keyword.fetch!(opts, :id)}, opts)
@impl Plushie.DSL.Buildable
def __field_keys__, do: unquote(prop_names)
@impl Plushie.DSL.Buildable
def __field_types__, do: unquote(Macro.escape(field_types_map))
def __option_keys__, do: unquote(prop_names)
def __option_types__, do: unquote(Macro.escape(field_types_map))
end
end
defp generate_setters(props) do
prop_setters =
Enum.map(props, fn {name, type, opts} ->
doc = Keyword.get(opts, :doc, "Sets the `#{name}` prop.")
encoder = encoder_for_type(type)
value_type = elixir_type_for(type)
case setter_guard(type) do
nil ->
quote do
@doc unquote(doc)
@spec unquote(name)(widget :: t(), value :: unquote(value_type)) :: t()
def unquote(name)(%__MODULE__{} = widget, value) do
%{widget | unquote(name) => unquote(encoder).(value)}
end
end
guard ->
quote do
@doc unquote(doc)
@spec unquote(name)(widget :: t(), value :: unquote(value_type)) :: t()
def unquote(name)(%__MODULE__{} = widget, value) when unquote(guard) do
%{widget | unquote(name) => unquote(encoder).(value)}
end
end
end
end)
event_rate_setter =
quote do
@doc "Sets the maximum event rate (events per second) for this widget's coalescable events."
@spec event_rate(widget :: t(), rate :: pos_integer()) :: t()
def event_rate(%__MODULE__{} = widget, rate)
when is_integer(rate) and rate >= 0 do
%{widget | event_rate: rate}
end
end
a11y_setter =
quote do
@doc "Sets accessibility annotations."
@spec a11y(widget :: t(), a11y :: Plushie.Type.A11y.t()) :: t()
def a11y(%__MODULE__{} = widget, a11y) do
%{widget | a11y: Plushie.Type.A11y.cast(a11y)}
end
end
prop_setters ++ [event_rate_setter, a11y_setter]
end
defp setter_guard(:number), do: quote(do: is_number(value))
defp setter_guard(:string), do: quote(do: is_binary(value))
defp setter_guard(:boolean), do: quote(do: is_boolean(value))
defp setter_guard(:atom), do: quote(do: is_atom(value))
defp setter_guard(:map), do: quote(do: is_map(value))
defp setter_guard({:list, _}), do: quote(do: is_list(value))
defp setter_guard(_), do: nil
defp generate_build do
quote do
@doc "Converts this widget struct to a `ui_node()` map."
@spec build(widget :: t()) :: Plushie.Widget.ui_node()
def build(%__MODULE__{} = widget), do: Plushie.Widget.to_node(widget)
end
end
defp generate_widget_protocol(
_module,
widget_type,
type_string,
container,
props,
events,
event_specs
) do
put_calls =
Enum.map(props, fn {name, type, _opts} ->
# Color needs casting in to_node because struct defaults bypass setters.
# All other types store raw values -- Tree.normalize handles encoding.
if type == :color do
quote do
props =
Plushie.Widget.Build.put_if(
props,
widget.unquote(name),
unquote(name),
fn val -> Plushie.Type.Color.cast(val) end
)
end
else
quote do
props =
Plushie.Widget.Build.put_if(
props,
widget.unquote(name),
unquote(name)
)
end
end
end)
event_rate_put =
quote do
props = Plushie.Widget.Build.put_if(props, widget.event_rate, :event_rate)
end
a11y_put =
quote do
props = Plushie.Widget.Build.put_if(props, widget.a11y, :a11y)
end
children =
if container do
quote(do: Plushie.Widget.Build.children_to_nodes(widget.children))
else
quote(do: [])
end
# defimpl must be defined at the top level of the module, not inside a
# function. We generate the AST here; it's injected via __before_compile__.
quote do
defimpl Plushie.Widget.WidgetProtocol do
def to_node(widget) do
props = %{}
unquote_splicing(put_calls)
unquote(event_rate_put)
unquote(a11y_put)
props =
props
|> Map.put(:__widget_type__, unquote(widget_type))
|> Map.put(:__widget_events__, unquote(events))
|> Map.put(:__widget_event_specs__, unquote(Macro.escape(event_specs)))
%{
id: widget.id,
type: unquote(type_string),
props: props,
children: unquote(children)
}
end
end
end
end
@doc false
def generate_prop_validation(props) do
known_names =
Enum.map(props, fn {name, _type, _opts} -> name end) ++ [:event_rate, :a11y]
quote do
unknown_keys = Keyword.keys(opts) -- unquote(known_names)
if unknown_keys != [] do
raise ArgumentError,
"unknown option(s) #{inspect(unknown_keys)} for #{inspect(__MODULE__)}.new"
end
end
end
defp encoder_for_type(:color) do
quote do
fn val -> Plushie.Type.Color.cast(val) end
end
end
defp encoder_for_type(_type) do
# All other types store raw values. Tree.normalize handles wire encoding.
quote do
fn val -> val end
end
end
@doc false
def generate_commands(commands) do
fns =
Enum.map(commands, fn {name, params} ->
param_names = Keyword.keys(params)
args =
[quote(do: widget_id) | Enum.map(param_names, fn p -> to_var(p) end)]
payload_map =
{:%{}, [],
Enum.map(param_names, fn p ->
{p, to_var(p)}
end)}
guards = build_command_guards(params)
op_string = Atom.to_string(name)
spec_ast = build_command_spec(name, params)
if param_names == [] do
quote do
@doc "Sends the `#{unquote(op_string)}` command to the native widget."
@spec unquote(spec_ast)
def unquote(name)(widget_id) when is_binary(widget_id) do
Plushie.Command.widget_command(widget_id, unquote(op_string), %{})
end
end
else
quote do
@doc "Sends the `#{unquote(op_string)}` command to the native widget."
@spec unquote(spec_ast)
def unquote(name)(unquote_splicing(args))
when is_binary(widget_id) and unquote(guards) do
Plushie.Command.widget_command(
widget_id,
unquote(op_string),
unquote(payload_map)
)
end
end
end
end)
quote do
(unquote_splicing(fns))
end
end
defp build_command_spec(name, params) do
param_types = [quote(do: String.t()) | Enum.map(params, fn {_n, t} -> elixir_type_for(t) end)]
return_type = quote(do: Plushie.Command.t())
quote do
unquote(name)(unquote_splicing(param_types)) :: unquote(return_type)
end
end
defp build_command_guards([]), do: quote(do: true)
defp build_command_guards(params) do
guards =
Enum.map(params, fn {name, type} ->
var = to_var(name)
guard_for_type(var, type)
end)
Enum.reduce(guards, fn right, left ->
quote(do: unquote(left) and unquote(right))
end)
end
defp guard_for_type(var, :number), do: quote(do: is_number(unquote(var)))
defp guard_for_type(var, :string), do: quote(do: is_binary(unquote(var)))
defp guard_for_type(var, :boolean), do: quote(do: is_boolean(unquote(var)))
defp guard_for_type(var, :atom), do: quote(do: is_atom(unquote(var)))
defp guard_for_type(var, :map), do: quote(do: is_map(unquote(var)))
defp guard_for_type(var, :list), do: quote(do: is_list(unquote(var)))
defp guard_for_type(var, {:list, _}), do: quote(do: is_list(unquote(var)))
defp guard_for_type(_var, _), do: quote(do: true)
# Build a simple AST variable reference from an atom name.
defp to_var(name) when is_atom(name), do: {name, [], nil}
end