use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use iced::{Element, Theme};
use serde_json::Value;
use crate::PlushieRenderer;
use crate::a11y::A11yOverrides;
use crate::message::Message;
use crate::protocol::{OutgoingEvent, TreeNode};
use crate::render_ctx::RenderCtx;
thread_local! {
static EXPLICIT_NULL_INIT_CONFIGS: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
}
fn init_config_key(config: &Value) -> usize {
config as *const Value as usize
}
struct ExplicitNullInitConfig {
key: usize,
}
impl ExplicitNullInitConfig {
fn mark(config: &Value) -> Self {
let key = init_config_key(config);
EXPLICIT_NULL_INIT_CONFIGS.with(|provided| {
provided.borrow_mut().insert(key);
});
Self { key }
}
}
impl Drop for ExplicitNullInitConfig {
fn drop(&mut self) {
EXPLICIT_NULL_INIT_CONFIGS.with(|provided| {
provided.borrow_mut().remove(&self.key);
});
}
}
#[cfg(debug_assertions)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct PreparedRenderKey {
window_id: String,
node_id: String,
type_name: String,
}
#[cfg(debug_assertions)]
impl PreparedRenderKey {
fn for_node(node: &TreeNode, window_id: &str) -> Self {
Self {
window_id: window_id.to_string(),
node_id: node.id.clone(),
type_name: node.type_name.clone(),
}
}
}
#[cfg(debug_assertions)]
#[derive(Debug, Clone, PartialEq)]
struct PreparedNodeSnapshot {
id: String,
type_name: String,
props: plushie_core::protocol::Props,
children: Vec<PreparedNodeSnapshot>,
}
#[cfg(debug_assertions)]
impl PreparedNodeSnapshot {
fn from_node(node: &TreeNode) -> Self {
Self {
id: node.id.clone(),
type_name: node.type_name.clone(),
props: node.props.clone(),
children: node.children.iter().map(Self::from_node).collect(),
}
}
}
#[derive(Debug)]
pub struct InitCtx<'a> {
pub config: &'a Value,
pub theme: &'a Theme,
pub default_text_size: Option<f32>,
pub default_font: Option<iced::Font>,
}
impl InitCtx<'_> {
pub fn config_was_provided(&self) -> bool {
if !self.config.is_null() {
return true;
}
EXPLICIT_NULL_INIT_CONFIGS
.with(|provided| provided.borrow().contains(&init_config_key(self.config)))
}
pub fn config_as<T: serde::de::DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
T::deserialize(self.config.clone())
}
pub fn config_or_default<T: serde::de::DeserializeOwned + Default>(&self) -> T {
self.config_as::<T>().unwrap_or_default()
}
}
#[derive(Debug, Clone)]
pub struct GenerationCounter {
value: u64,
}
impl GenerationCounter {
pub fn new() -> Self {
Self { value: 0 }
}
pub fn get(&self) -> u64 {
self.value
}
pub fn bump(&mut self) {
self.value = self.value.wrapping_add(1);
}
}
impl Default for GenerationCounter {
fn default() -> Self {
Self::new()
}
}
pub trait PlushieWidget<R: PlushieRenderer = iced::Renderer> {
fn type_names(&self) -> &[&str];
fn namespace(&self) -> &str {
""
}
fn render<'a>(
&'a self,
node: &'a TreeNode,
ctx: &RenderCtx<'a, R>,
) -> Element<'a, Message, Theme, R>;
fn prepare(&mut self, _node: &TreeNode, _window_id: &str, _theme: &Theme) {}
fn handle_message(&mut self, _msg: &Message) -> HandleResult {
HandleResult::Fallthrough
}
fn prune_stale(&mut self, _live_ids: &std::collections::HashSet<(String, String)>) {}
fn init(&mut self, _ctx: &InitCtx<'_>) {}
fn infer_a11y(&self, _node: &TreeNode) -> Option<A11yOverrides> {
None
}
fn handle_widget_op(
&mut self,
_node_id: &str,
_op: &str,
_payload: &Value,
) -> Option<Vec<OutgoingEvent>> {
None
}
fn event_specs(&self) -> Vec<plushie_core::EventSpec> {
vec![]
}
fn command_specs(&self) -> Vec<plushie_core::CommandSpec> {
vec![]
}
fn subscriptions(&self, _node: &TreeNode, _ctx: &SubscribeCtx<'_>) -> Vec<WidgetSubscription> {
vec![]
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<R>>;
}
#[derive(Debug)]
pub enum HandleResult {
Fallthrough,
Handled(Vec<OutgoingEvent>),
}
impl HandleResult {
pub fn consume() -> Self {
HandleResult::Handled(Vec::new())
}
pub fn emit(events: Vec<OutgoingEvent>) -> Self {
HandleResult::Handled(events)
}
}
#[derive(Debug)]
pub struct SubscribeCtx<'a> {
pub window_id: &'a str,
pub theme: &'a Theme,
pub scope: &'a str,
}
#[derive(Debug, Clone)]
pub struct WidgetSubscription {
pub tag: String,
pub kind: String,
pub max_rate: Option<u32>,
}
impl WidgetSubscription {
pub fn new(kind: impl Into<String>, tag: impl Into<String>) -> Self {
Self {
kind: kind.into(),
tag: tag.into(),
max_rate: None,
}
}
pub fn with_max_rate(mut self, rate: u32) -> Self {
self.max_rate = Some(rate);
self
}
}
pub trait PlushieWidgetRender<R: PlushieRenderer = iced::Renderer> {
fn render<'a>(
&'a self,
node: &'a TreeNode,
ctx: &RenderCtx<'a, R>,
) -> Element<'a, Message, Theme, R>;
}
pub trait WidgetSet<R: PlushieRenderer = iced::Renderer> {
fn name(&self) -> &str;
fn create_widgets(&self) -> Vec<Box<dyn PlushieWidget<R>>>;
}
pub struct WidgetRegistry<R: PlushieRenderer = iced::Renderer> {
impls: Vec<Box<dyn PlushieWidget<R>>>,
type_index: HashMap<String, usize>,
node_factory_map: HashMap<String, usize>,
provenance: HashMap<String, String>,
active_widget_subs: HashMap<String, CollectedSubscription>,
#[cfg(debug_assertions)]
prepared_render_snapshots: HashMap<PreparedRenderKey, PreparedNodeSnapshot>,
#[cfg(debug_assertions)]
failed_prepare_render_keys: HashSet<PreparedRenderKey>,
}
#[derive(Debug, Clone)]
pub struct CollectedSubscription {
pub kind: String,
pub max_rate: Option<u32>,
pub node_id: String,
pub window_id: String,
}
type PrepareCollected = (
std::collections::HashSet<String>,
std::collections::HashSet<(String, String)>,
HashMap<String, CollectedSubscription>,
);
pub(crate) struct PrepareTransform<'a, R: PlushieRenderer> {
registry: &'a mut WidgetRegistry<R>,
shared: &'a mut crate::shared_state::SharedState,
theme: &'a Theme,
validate_props: bool,
window_stack: Vec<String>,
live_ids: std::collections::HashSet<String>,
live_keys: std::collections::HashSet<(String, String)>,
widget_subs: HashMap<String, CollectedSubscription>,
}
impl<'a, R: PlushieRenderer> PrepareTransform<'a, R> {
pub(crate) fn new_in_window(
registry: &'a mut WidgetRegistry<R>,
shared: &'a mut crate::shared_state::SharedState,
theme: &'a Theme,
window_id: Option<&str>,
validate_props: bool,
) -> Self {
Self {
registry,
shared,
theme,
validate_props,
window_stack: window_id.map(str::to_string).into_iter().collect(),
live_ids: std::collections::HashSet::new(),
live_keys: std::collections::HashSet::new(),
widget_subs: HashMap::new(),
}
}
pub(crate) fn take_collected(self) -> PrepareCollected {
(self.live_ids, self.live_keys, self.widget_subs)
}
}
impl<R: PlushieRenderer> plushie_core::tree_walk::TreeTransform for PrepareTransform<'_, R> {
fn enter(&mut self, node: &mut TreeNode, _ctx: &mut plushie_core::tree_walk::WalkCtx) {
let current_window_id = self.window_stack.last().cloned().unwrap_or_default();
self.live_ids.insert(node.id.clone());
self.live_keys
.insert((current_window_id.clone(), node.id.clone()));
let window_id_for_this_node = if node.type_name == "window" {
self.window_stack.push(node.id.clone());
node.id.clone()
} else {
current_window_id
};
crate::shared_state::ensure_style_overrides_cache(node, self.shared);
if let Some(&idx) = self.registry.type_index.get(node.type_name.as_str()) {
self.registry.node_factory_map.insert(node.id.clone(), idx);
let type_name = node.type_name.clone();
let window_id_owned = window_id_for_this_node.clone();
let theme_ref = self.theme;
let validate_props = self.validate_props;
let node_ref: &TreeNode = node;
#[cfg(debug_assertions)]
let prepared = self.registry.call_widget_mut(
&type_name,
"prepare",
&node_ref.id,
|widget| {
crate::validate::with_validate_props_enabled(validate_props, || {
widget.prepare(node_ref, &window_id_owned, theme_ref);
});
true
},
|| false,
);
#[cfg(not(debug_assertions))]
self.registry.call_widget_mut(
&type_name,
"prepare",
&node_ref.id,
|widget| {
crate::validate::with_validate_props_enabled(validate_props, || {
widget.prepare(node_ref, &window_id_owned, theme_ref);
});
()
},
|| {},
);
#[cfg(debug_assertions)]
if prepared {
let snapshot = PreparedNodeSnapshot::from_node(node_ref);
self.registry.prepared_render_snapshots.insert(
PreparedRenderKey::for_node(node_ref, &window_id_owned),
snapshot,
);
} else {
self.registry
.failed_prepare_render_keys
.insert(PreparedRenderKey::for_node(node_ref, &window_id_owned));
}
let scope = type_name.clone();
let sub_node_id = node_ref.id.clone();
let sub_window_id = window_id_for_this_node;
let subs = self.registry.call_widget(
&type_name,
"subscriptions",
&node_ref.id,
|widget| {
widget.subscriptions(
node_ref,
&SubscribeCtx {
window_id: &sub_window_id,
theme: theme_ref,
scope: &scope,
},
)
},
Vec::new,
);
for sub in subs {
let full_tag = format!(
"widget:{}#{}/{}:{}",
sub_window_id, scope, sub_node_id, sub.tag,
);
self.widget_subs.insert(
full_tag,
CollectedSubscription {
kind: sub.kind,
max_rate: sub.max_rate,
node_id: sub_node_id.clone(),
window_id: sub_window_id.clone(),
},
);
}
}
}
fn exit(&mut self, node: &mut TreeNode, _ctx: &mut plushie_core::tree_walk::WalkCtx) {
if node.type_name == "window" {
self.window_stack.pop();
}
}
}
impl<R: PlushieRenderer> WidgetRegistry<R> {
pub fn new() -> Self {
Self {
impls: Vec::new(),
type_index: HashMap::new(),
node_factory_map: HashMap::new(),
provenance: HashMap::new(),
active_widget_subs: HashMap::new(),
#[cfg(debug_assertions)]
prepared_render_snapshots: HashMap::new(),
#[cfg(debug_assertions)]
failed_prepare_render_keys: HashSet::new(),
}
}
pub fn register(&mut self, widget: Box<dyn PlushieWidget<R>>) {
self.register_with_set_name(widget, "");
}
pub fn register_strict(&mut self, widget: Box<dyn PlushieWidget<R>>) {
let collisions: Vec<String> = widget
.type_names()
.iter()
.filter(|name| self.type_index.contains_key(**name))
.map(|s| (*s).to_string())
.collect();
if !collisions.is_empty() {
panic!(
"widget registration collides with existing type name(s): [{}]. \
Rename the widget, or use `widget_override` (on the builder) / \
`register` (on the registry) if the override is intentional.",
collisions.join(", "),
);
}
self.register_with_set_name(widget, "");
}
pub fn register_set(&mut self, set: &dyn WidgetSet<R>) {
let set_name = set.name().to_string();
for widget in set.create_widgets() {
self.register_with_set_name(widget, &set_name);
}
}
fn register_with_set_name(&mut self, widget: Box<dyn PlushieWidget<R>>, set_name: &str) {
let idx = self.impls.len();
for &name in widget.type_names() {
if self.type_index.contains_key(name) {
let old_provenance = self.provenance.get(name).map(|s| s.as_str()).unwrap_or("");
let new_provenance = if set_name.is_empty() {
"(individual)"
} else {
set_name
};
log::info!(
"widget type {:?} overridden: {:?} -> {:?}",
name,
old_provenance,
new_provenance,
);
}
self.type_index.insert(name.to_string(), idx);
if set_name.is_empty() {
self.provenance.remove(name);
} else {
self.provenance
.insert(name.to_string(), set_name.to_string());
}
}
self.impls.push(widget);
}
pub fn get_for_type(&self, type_name: &str) -> Option<&dyn PlushieWidget<R>> {
self.type_index
.get(type_name)
.map(|&idx| self.impls[idx].as_ref())
}
pub fn get_for_node_id<'a>(&self, node_id: &'a str) -> Option<(usize, &'a str)> {
if let Some(&idx) = self.node_factory_map.get(node_id) {
return Some((idx, node_id));
}
let mut id = node_id;
while let Some(slash_pos) = id.rfind('/') {
id = &id[..slash_pos];
if let Some(&idx) = self.node_factory_map.get(id) {
return Some((idx, id));
}
}
None
}
pub fn type_names(&self) -> Vec<&str> {
self.type_index.keys().map(|s| s.as_str()).collect()
}
fn active_widget_impls(&self) -> Vec<(&str, &dyn PlushieWidget<R>)> {
let mut active: Vec<(&str, usize)> = self
.type_index
.iter()
.map(|(type_name, &idx)| (type_name.as_str(), idx))
.collect();
active.sort_by(|(type_a, _), (type_b, _)| type_a.cmp(type_b));
let mut seen = HashSet::new();
active
.into_iter()
.filter_map(|(type_name, idx)| {
if seen.insert(idx) {
Some((type_name, self.impls[idx].as_ref()))
} else {
None
}
})
.collect()
}
fn diagnostic_type_name_for_idx(&self, idx: usize) -> String {
self.type_index
.iter()
.filter_map(|(type_name, &mapped_idx)| (mapped_idx == idx).then_some(type_name))
.min()
.cloned()
.or_else(|| {
self.impls
.get(idx)
.and_then(|widget| widget.type_names().first())
.map(|type_name| (*type_name).to_string())
})
.unwrap_or_else(|| format!("widget#{idx}"))
}
pub fn type_names_by_set(&self) -> HashMap<&str, Vec<&str>> {
let mut result: HashMap<&str, Vec<&str>> = HashMap::new();
for type_name in self.type_index.keys() {
let set_name = self
.provenance
.get(type_name)
.map(|s| s.as_str())
.unwrap_or("(none)");
result.entry(set_name).or_default().push(type_name.as_str());
}
result
}
fn is_trusted(&self, type_name: &str) -> bool {
self.provenance.get(type_name).is_some_and(|s| s == "iced")
}
fn is_trusted_idx(&self, idx: usize, type_name: &str) -> bool {
self.type_index.get(type_name) == Some(&idx) && self.is_trusted(type_name)
}
#[cfg(debug_assertions)]
fn assert_prepared_for_render(&self, node: &TreeNode, ctx_window_id: &str) {
let window_id = if node.type_name == "window" {
node.id.as_str()
} else {
ctx_window_id
};
let key = PreparedRenderKey::for_node(node, window_id);
let prepared_snapshot = self.prepared_render_snapshots.get(&key);
let Some(prepared_snapshot) = prepared_snapshot else {
if self.failed_prepare_render_keys.contains(&key) {
return;
}
panic!(
"render_node called for node `{}` of type `{}` before prepare completed for that node and type. \
Call WidgetRegistry::prepare_walk or WidgetRegistry::prepare_and_scan after tree changes before rendering.",
node.id, node.type_name,
);
};
let current_snapshot = PreparedNodeSnapshot::from_node(node);
if prepared_snapshot.props != current_snapshot.props {
panic!(
"render_node called for node `{}` of type `{}` after props changed since prepare completed. \
Call WidgetRegistry::prepare_walk or WidgetRegistry::prepare_and_scan after tree changes before rendering.",
node.id, node.type_name,
);
}
if prepared_snapshot != ¤t_snapshot {
panic!(
"render_node called for node `{}` of type `{}` after node or child changed since prepare completed. \
Call WidgetRegistry::prepare_walk or WidgetRegistry::prepare_and_scan after tree changes before rendering.",
node.id, node.type_name,
);
}
}
fn call_widget<T>(
&self,
type_name: &str,
label: &str,
node_id: &str,
f: impl FnOnce(&dyn PlushieWidget<R>) -> T,
fallback: impl FnOnce() -> T,
) -> T {
let Some(&idx) = self.type_index.get(type_name) else {
return fallback();
};
self.call_widget_by_idx(idx, type_name, label, node_id, f, fallback)
}
fn call_widget_by_idx<T>(
&self,
idx: usize,
type_name: &str,
label: &str,
node_id: &str,
f: impl FnOnce(&dyn PlushieWidget<R>) -> T,
fallback: impl FnOnce() -> T,
) -> T {
let Some(widget) = self.impls.get(idx).map(|widget| widget.as_ref()) else {
return fallback();
};
if self.is_trusted_idx(idx, type_name) {
return f(widget);
}
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(widget))) {
Ok(v) => v,
Err(_) => {
crate::diagnostics::error(plushie_core::Diagnostic::WidgetPanic {
id: node_id.to_string(),
type_name: type_name.to_string(),
label: label.to_string(),
});
fallback()
}
}
}
fn call_widget_mut<T>(
&mut self,
type_name: &str,
label: &str,
node_id: &str,
f: impl FnOnce(&mut Box<dyn PlushieWidget<R>>) -> T,
fallback: impl FnOnce() -> T,
) -> T {
let Some(&idx) = self.type_index.get(type_name) else {
return fallback();
};
self.call_widget_mut_by_idx(idx, type_name, label, node_id, f, fallback)
}
fn call_widget_mut_by_idx<T>(
&mut self,
idx: usize,
type_name: &str,
label: &str,
node_id: &str,
f: impl FnOnce(&mut Box<dyn PlushieWidget<R>>) -> T,
fallback: impl FnOnce() -> T,
) -> T {
if idx >= self.impls.len() {
return fallback();
}
if self.is_trusted_idx(idx, type_name) {
return f(&mut self.impls[idx]);
}
let fresh_replacement = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
self.impls[idx].fresh_for_session()
})) {
Ok(widget) => widget,
Err(_) => {
crate::diagnostics::error(plushie_core::Diagnostic::WidgetPanic {
id: node_id.to_string(),
type_name: type_name.to_string(),
label: format!("{label}:fresh_for_session"),
});
return fallback();
}
};
let widget = &mut self.impls[idx];
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(widget))) {
Ok(v) => v,
Err(_) => {
self.impls[idx] = fresh_replacement;
crate::diagnostics::error(plushie_core::Diagnostic::WidgetPanic {
id: node_id.to_string(),
type_name: type_name.to_string(),
label: label.to_string(),
});
fallback()
}
}
}
pub fn render_node<'a>(
&'a self,
node: &'a TreeNode,
ctx: &crate::render_ctx::RenderCtx<'a, R>,
) -> iced::Element<'a, crate::runtime::Message, iced::Theme, R> {
let type_name = node.type_name.as_str();
let Some(&idx) = self.type_index.get(type_name) else {
log::warn!(
"[id={}] unknown node type `{}`, rendering as empty container",
node.id,
type_name
);
return iced::widget::container(iced::widget::Space::new()).into();
};
#[cfg(debug_assertions)]
self.assert_prepared_for_render(node, ctx.window_id);
if self.is_trusted(type_name) {
self.impls[idx].render(node, ctx)
} else {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
self.impls[idx].render(node, ctx)
})) {
Ok(element) => element,
Err(_) => {
crate::diagnostics::error(plushie_core::Diagnostic::WidgetPanic {
id: node.id.clone(),
type_name: type_name.to_string(),
label: "render".to_string(),
});
iced::widget::text(format!("Widget error: `{type_name}`"))
.color(iced::Color::from_rgb(1.0, 0.0, 0.0))
.into()
}
}
}
}
pub fn handles_type(&self, type_name: &str) -> bool {
self.type_index.contains_key(type_name)
}
pub fn fresh_for_session(&self) -> Self {
let mut cloned_impls: Vec<Box<dyn PlushieWidget<R>>> = Vec::with_capacity(self.impls.len());
let mut new_type_index = HashMap::new();
for (i, widget) in self.impls.iter().enumerate() {
let cloned = widget.fresh_for_session();
let new_idx = cloned_impls.len();
cloned_impls.push(cloned);
for (type_name, &old_idx) in &self.type_index {
if old_idx == i {
new_type_index.insert(type_name.clone(), new_idx);
}
}
}
Self {
impls: cloned_impls,
type_index: new_type_index,
node_factory_map: HashMap::new(),
provenance: self.provenance.clone(),
active_widget_subs: HashMap::new(),
#[cfg(debug_assertions)]
prepared_render_snapshots: HashMap::new(),
#[cfg(debug_assertions)]
failed_prepare_render_keys: HashSet::new(),
}
}
pub fn family_collision_diagnostics(&self) -> Vec<OutgoingEvent> {
use plushie_core::spec::{CommandSpec, EventSpec, PayloadSpec};
let mut out = Vec::new();
let mut events: HashMap<String, (String, PayloadSpec)> = HashMap::new();
let mut commands: HashMap<String, (String, PayloadSpec)> = HashMap::new();
for (type_name, widget) in self.active_widget_impls() {
for EventSpec { family, payload } in widget.event_specs() {
match events.get(&family) {
Some((prev_type, prev_payload)) => {
if format!("{prev_payload:?}") != format!("{payload:?}") {
out.push(OutgoingEvent::generic(
"widget_family_collision",
"",
Some(serde_json::json!({
"kind": "event",
"type_a": prev_type,
"type_b": type_name,
"family": family,
"spec_a": format!("{:?}", prev_payload),
"spec_b": format!("{:?}", payload),
})),
));
}
}
None => {
events.insert(family, (type_name.to_string(), payload));
}
}
}
for CommandSpec { family, payload } in widget.command_specs() {
match commands.get(&family) {
Some((prev_type, prev_payload)) => {
if format!("{prev_payload:?}") != format!("{payload:?}") {
out.push(OutgoingEvent::generic(
"widget_family_collision",
"",
Some(serde_json::json!({
"kind": "command",
"type_a": prev_type,
"type_b": type_name,
"family": family,
"spec_a": format!("{:?}", prev_payload),
"spec_b": format!("{:?}", payload),
})),
));
}
}
None => {
commands.insert(family, (type_name.to_string(), payload));
}
}
}
}
out
}
pub fn init_all(&mut self, ctx: &InitCtx<'_>) {
let per_widget: Vec<(String, String)> = self
.impls
.iter()
.enumerate()
.map(|(idx, widget)| {
let type_name = self.diagnostic_type_name_for_idx(idx);
(type_name, widget.namespace().to_string())
})
.collect();
for (idx, (type_name, ns)) in per_widget.into_iter().enumerate() {
let (ns_config, config_provided) = if ns.is_empty() {
(Value::Null, false)
} else {
match ctx.config.as_object().and_then(|obj| obj.get(&ns)) {
Some(config) => (config.clone(), true),
None => (Value::Null, false),
}
};
self.call_widget_mut_by_idx(
idx,
&type_name,
"init",
&type_name,
|widget| {
let _provided = (config_provided && ns_config.is_null())
.then(|| ExplicitNullInitConfig::mark(&ns_config));
let ns_ctx = InitCtx {
config: &ns_config,
theme: ctx.theme,
default_text_size: ctx.default_text_size,
default_font: ctx.default_font,
};
widget.init(&ns_ctx);
},
|| {},
);
}
}
pub fn prepare_walk(
&mut self,
root: &mut TreeNode,
shared: &mut crate::shared_state::SharedState,
theme: &Theme,
) {
self.prepare_walk_with_base_window(
root,
shared,
theme,
None,
crate::validate::is_validate_props_enabled(),
);
}
pub fn prepare_walk_in_window(
&mut self,
root: &mut TreeNode,
shared: &mut crate::shared_state::SharedState,
theme: &Theme,
window_id: &str,
) {
self.prepare_walk_with_base_window(
root,
shared,
theme,
Some(window_id),
crate::validate::is_validate_props_enabled(),
);
}
pub fn prepare_walk_with_validation(
&mut self,
root: &mut TreeNode,
shared: &mut crate::shared_state::SharedState,
theme: &Theme,
validate_props: bool,
) {
self.prepare_walk_with_base_window(root, shared, theme, None, validate_props);
}
pub fn prepare_and_scan_with_validation(
&mut self,
root: &mut TreeNode,
shared: &mut crate::shared_state::SharedState,
theme: &Theme,
animations: &mut crate::animation::TransitionManager,
validate_props: bool,
) {
self.prepare_and_scan_inner(root, shared, theme, animations, validate_props);
}
fn prepare_walk_with_base_window(
&mut self,
root: &mut TreeNode,
shared: &mut crate::shared_state::SharedState,
theme: &Theme,
window_id: Option<&str>,
validate_props: bool,
) {
self.node_factory_map.clear();
#[cfg(debug_assertions)]
{
self.prepared_render_snapshots.clear();
self.failed_prepare_render_keys.clear();
}
let (live_ids, live_keys, widget_subs) = {
let mut transform =
PrepareTransform::new_in_window(self, shared, theme, window_id, validate_props);
let mut ctx = plushie_core::tree_walk::WalkCtx::default();
plushie_core::tree_walk::walk(root, &mut [&mut transform], &mut ctx);
transform.take_collected()
};
self.finish_prepare(shared, live_ids, live_keys, widget_subs);
}
pub fn prepare_and_scan(
&mut self,
root: &mut TreeNode,
shared: &mut crate::shared_state::SharedState,
theme: &Theme,
animations: &mut crate::animation::TransitionManager,
) {
self.prepare_and_scan_inner(
root,
shared,
theme,
animations,
crate::validate::is_validate_props_enabled(),
);
}
fn prepare_and_scan_inner(
&mut self,
root: &mut TreeNode,
shared: &mut crate::shared_state::SharedState,
theme: &Theme,
animations: &mut crate::animation::TransitionManager,
validate_props: bool,
) {
self.node_factory_map.clear();
#[cfg(debug_assertions)]
{
self.prepared_render_snapshots.clear();
self.failed_prepare_render_keys.clear();
}
let (live_ids, live_keys, widget_subs) = {
let mut prepare =
PrepareTransform::new_in_window(self, shared, theme, None, validate_props);
let mut scan = crate::animation::ScanTransform {
manager: animations,
};
let mut ctx = plushie_core::tree_walk::WalkCtx::default();
plushie_core::tree_walk::walk(root, &mut [&mut prepare, &mut scan], &mut ctx);
prepare.take_collected()
};
animations.prune_to_live_widgets(&live_ids, &mut shared.interpolated_props);
self.finish_prepare(shared, live_ids, live_keys, widget_subs);
}
fn finish_prepare(
&mut self,
shared: &mut crate::shared_state::SharedState,
live_ids: std::collections::HashSet<String>,
live_keys: std::collections::HashSet<(String, String)>,
widget_subs: HashMap<String, CollectedSubscription>,
) {
shared.prune_shared(&live_ids);
let type_names: Vec<String> = self
.impls
.iter()
.enumerate()
.map(|(idx, _widget)| self.diagnostic_type_name_for_idx(idx))
.collect();
for (idx, type_name) in type_names.into_iter().enumerate() {
let live_keys_ref = &live_keys;
self.call_widget_mut_by_idx(
idx,
&type_name,
"prune_stale",
&type_name,
|widget| widget.prune_stale(live_keys_ref),
|| {},
);
}
self.active_widget_subs = widget_subs;
}
pub fn active_widget_subscriptions(&self) -> &HashMap<String, CollectedSubscription> {
&self.active_widget_subs
}
pub fn process_message(&mut self, msg: &Message) -> Vec<OutgoingEvent> {
if let Some(node_id) = msg.node_id()
&& let Some((idx, _)) = self.get_for_node_id(node_id)
{
let type_name = self.diagnostic_type_name_for_idx(idx);
let result = self.call_widget_mut_by_idx(
idx,
&type_name,
"handle_message",
node_id,
|factory| factory.handle_message(msg),
|| HandleResult::Fallthrough,
);
if let HandleResult::Handled(events) = result {
return events;
}
}
match msg {
Message::Diagnostic { .. } => msg.to_outgoing_event().into_iter().collect(),
Message::CanvasElementFocusChanged { .. } => vec![],
Message::TextEditorAction(..) => vec![],
Message::Event {
id, value, family, ..
} => {
self.validate_event_payload(id, family, value);
let value_opt = if value.is_null() {
None
} else {
Some(value.clone())
};
vec![OutgoingEvent::generic(
family.clone(),
id.clone(),
value_opt,
)]
}
Message::PaneFocusCycle(..)
| Message::PaneResized(..)
| Message::PaneDragged(..)
| Message::PaneClicked(..) => vec![],
_ => vec![],
}
}
pub fn has_widget_subscription(&self, kind: &str) -> bool {
self.active_widget_subs.values().any(|sub| sub.kind == kind)
}
pub fn dispatch_widget_subscription(
&mut self,
kind: &str,
window_id: Option<&str>,
msg: &Message,
) -> Vec<OutgoingEvent> {
let targets: Vec<(String, usize)> = self
.active_widget_subs
.values()
.filter(|sub| sub.kind == kind)
.filter(|sub| match window_id {
Some(wid) => sub.window_id == wid,
None => true,
})
.filter_map(|sub| {
self.node_factory_map
.get(&sub.node_id)
.map(|&idx| (sub.node_id.clone(), idx))
})
.collect();
let mut out = Vec::new();
for (node_id, idx) in targets {
let type_name = self.diagnostic_type_name_for_idx(idx);
let result = self.call_widget_mut_by_idx(
idx,
&type_name,
"handle_message",
&node_id,
|widget| widget.handle_message(msg),
|| HandleResult::Fallthrough,
);
if let HandleResult::Handled(events) = result {
out.extend(events);
}
}
out
}
pub fn handle_widget_op(
&mut self,
node_id: &str,
op: &str,
payload: &Value,
) -> Option<Vec<OutgoingEvent>> {
let (idx, _) = self.get_for_node_id(node_id)?;
self.validate_command_payload(idx, node_id, op, payload);
let type_name = self.diagnostic_type_name_for_idx(idx);
self.call_widget_mut_by_idx(
idx,
&type_name,
"handle_widget_op",
node_id,
|widget| widget.handle_widget_op(node_id, op, payload),
|| None,
)
}
pub fn infer_a11y_for_node(&self, node: &TreeNode) -> Option<crate::a11y::A11yOverrides> {
let type_name = node.type_name.as_str();
if !self.type_index.contains_key(type_name) {
return None;
}
self.call_widget(
type_name,
"infer_a11y",
&node.id,
|widget| widget.infer_a11y(node),
|| None,
)
}
pub fn clear_node_map(&mut self) {
self.node_factory_map.clear();
}
pub fn map_node(&mut self, node_id: String, factory_idx: usize) {
self.node_factory_map.insert(node_id, factory_idx);
}
pub fn get_mut(&mut self, idx: usize) -> Option<&mut Box<dyn PlushieWidget<R>>> {
self.impls.get_mut(idx)
}
pub fn index_for_type(&self, type_name: &str) -> Option<usize> {
self.type_index.get(type_name).copied()
}
pub fn len(&self) -> usize {
self.impls.len()
}
pub fn is_empty(&self) -> bool {
self.impls.is_empty()
}
fn validate_event_payload(&self, node_id: &str, family: &str, value: &Value) {
let Some((idx, _)) = self.get_for_node_id(node_id) else {
return;
};
let specs = self.impls[idx].event_specs();
if specs.is_empty() {
return;
}
let Some(spec) = specs.iter().find(|s| s.family == family) else {
return; };
if !spec.payload.validate(value) {
let msg = format!(
"event spec mismatch: widget {node_id} emitted \
family={family:?} with value that doesn't match spec {:?}",
spec.payload
);
debug_assert!(false, "{msg}");
#[cfg(not(debug_assertions))]
log::warn!("{msg}");
}
}
fn validate_command_payload(
&self,
factory_idx: usize,
node_id: &str,
family: &str,
value: &Value,
) {
let specs = self.impls[factory_idx].command_specs();
if specs.is_empty() {
return;
}
let Some(spec) = specs.iter().find(|s| s.family == family) else {
return; };
if !spec.payload.validate(value) {
let msg = format!(
"command spec mismatch: widget {node_id} received \
family={family:?} with value that doesn't match spec {:?}",
spec.payload
);
debug_assert!(false, "{msg}");
#[cfg(not(debug_assertions))]
log::warn!("{msg}");
}
}
}
impl<R: PlushieRenderer> Default for WidgetRegistry<R> {
fn default() -> Self {
Self::new()
}
}
impl<R: PlushieRenderer> std::fmt::Debug for WidgetRegistry<R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WidgetRegistry")
.field("widgets", &self.impls.len())
.field("type_names", &self.type_index.len())
.field("node_mappings", &self.node_factory_map.len())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestWidget {
names: Vec<&'static str>,
}
impl TestWidget {
fn new(names: &[&'static str]) -> Self {
Self {
names: names.to_vec(),
}
}
}
impl PlushieWidget<()> for TestWidget {
fn type_names(&self) -> &[&str] {
&self.names
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("test").into()
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(TestWidget::new(&self.names))
}
}
struct TestSet;
impl WidgetSet<()> for TestSet {
fn name(&self) -> &str {
"test"
}
fn create_widgets(&self) -> Vec<Box<dyn PlushieWidget<()>>> {
vec![
Box::new(TestWidget::new(&["alpha"])),
Box::new(TestWidget::new(&["beta"])),
]
}
}
fn test_render_ctx<'a, R: PlushieRenderer>(
caches: &'a crate::shared_state::SharedState,
images: &'a crate::image_registry::ImageRegistry,
theme: &'a Theme,
registry: &'a WidgetRegistry<R>,
window_id: &'a str,
) -> RenderCtx<'a, R> {
RenderCtx {
caches,
images,
theme,
theme_chrome: crate::theming::ThemeChrome::default(),
registry,
default_text_size: None,
default_font: None,
window_id,
scale_factor: 1.0,
validate_props: crate::validate::is_validate_props_enabled(),
}
}
#[test]
fn register_and_lookup() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TestWidget::new(&["button"])));
assert!(registry.handles_type("button"));
assert!(!registry.handles_type("text"));
}
#[test]
fn register_set() {
let mut registry = WidgetRegistry::<()>::new();
registry.register_set(&TestSet);
assert!(registry.handles_type("alpha"));
assert!(registry.handles_type("beta"));
}
#[test]
fn last_registered_wins() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TestWidget::new(&["button"])));
let first_idx = registry.index_for_type("button").unwrap();
registry.register(Box::new(TestWidget::new(&["button"])));
let second_idx = registry.index_for_type("button").unwrap();
assert_ne!(first_idx, second_idx);
}
#[test]
fn scoped_id_routing_exact_match() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TestWidget::new(&["button"])));
let idx = registry.index_for_type("button").unwrap();
registry.map_node("form/save".into(), idx);
let (found_idx, matched_id) = registry.get_for_node_id("form/save").unwrap();
assert_eq!(found_idx, idx);
assert_eq!(matched_id, "form/save");
}
#[test]
fn scoped_id_routing_prefix_walk() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TestWidget::new(&["gauge"])));
let idx = registry.index_for_type("gauge").unwrap();
registry.map_node("gauge-1".into(), idx);
let (found_idx, matched_id) = registry.get_for_node_id("gauge-1/canvas/element").unwrap();
assert_eq!(found_idx, idx);
assert_eq!(matched_id, "gauge-1");
}
#[test]
fn scoped_id_routing_no_match() {
let registry = WidgetRegistry::<()>::new();
assert!(registry.get_for_node_id("nonexistent/id").is_none());
}
struct CountingWidget {
calls: std::cell::Cell<u32>,
}
impl Default for CountingWidget {
fn default() -> Self {
Self {
calls: std::cell::Cell::new(0),
}
}
}
impl PlushieWidget<()> for CountingWidget {
fn type_names(&self) -> &[&str] {
&["counter"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("count").into()
}
fn prepare(&mut self, _node: &TreeNode, _window_id: &str, _theme: &Theme) {
self.calls.set(self.calls.get() + 1);
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(CountingWidget::default())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum WindowLifecycleCall {
Prepare { window_id: String, node_id: String },
Render { window_id: String, node_id: String },
}
struct WindowLifecycleWidget {
calls: std::rc::Rc<std::cell::RefCell<Vec<WindowLifecycleCall>>>,
}
impl PlushieWidget<()> for WindowLifecycleWidget {
fn type_names(&self) -> &[&str] {
&["window_lifecycle"]
}
fn prepare(&mut self, node: &TreeNode, window_id: &str, _theme: &Theme) {
self.calls.borrow_mut().push(WindowLifecycleCall::Prepare {
window_id: window_id.to_string(),
node_id: node.id.clone(),
});
}
fn render<'a>(
&'a self,
node: &'a TreeNode,
ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
self.calls.borrow_mut().push(WindowLifecycleCall::Render {
window_id: ctx.window_id.to_string(),
node_id: node.id.clone(),
});
iced::widget::text("window lifecycle").into()
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(Self {
calls: self.calls.clone(),
})
}
}
struct InitSpy {
inits: std::rc::Rc<std::cell::RefCell<Vec<(String, Value, bool)>>>,
ns: &'static str,
}
impl PlushieWidget<()> for InitSpy {
fn type_names(&self) -> &[&str] {
&["init_spy"]
}
fn namespace(&self) -> &str {
self.ns
}
fn init(&mut self, ctx: &InitCtx<'_>) {
self.inits.borrow_mut().push((
self.ns.to_string(),
ctx.config.clone(),
ctx.config_was_provided(),
));
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("spy").into()
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(InitSpy {
inits: self.inits.clone(),
ns: self.ns,
})
}
}
#[test]
fn init_runs_for_every_widget_including_empty_namespace() {
let inits = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(InitSpy {
inits: inits.clone(),
ns: "",
}));
let theme = Theme::Dark;
let config = serde_json::json!({ "other_ns": { "x": 1 } });
let ctx = InitCtx {
config: &config,
theme: &theme,
default_text_size: None,
default_font: None,
};
registry.init_all(&ctx);
let calls = inits.borrow().clone();
assert_eq!(calls.len(), 1, "init should fire once for the widget");
assert_eq!(calls[0].0, "", "namespace-less widget should still init");
assert_eq!(
calls[0].1,
Value::Null,
"namespace-less widget receives Value::Null"
);
assert!(
!calls[0].2,
"namespace-less widget should report no config namespace"
);
}
#[test]
fn init_delivers_namespaced_config_slice() {
let inits = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(InitSpy {
inits: inits.clone(),
ns: "gauge",
}));
let theme = Theme::Dark;
let config = serde_json::json!({ "gauge": { "threshold": 42 } });
let ctx = InitCtx {
config: &config,
theme: &theme,
default_text_size: None,
default_font: None,
};
registry.init_all(&ctx);
let calls = inits.borrow().clone();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "gauge");
assert_eq!(calls[0].1, serde_json::json!({ "threshold": 42 }));
assert!(calls[0].2, "matching namespace should report config");
}
#[test]
fn init_reports_missing_namespaced_config() {
let inits = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(InitSpy {
inits: inits.clone(),
ns: "gauge",
}));
let theme = Theme::Dark;
let config = serde_json::json!({ "other": {} });
let ctx = InitCtx {
config: &config,
theme: &theme,
default_text_size: None,
default_font: None,
};
registry.init_all(&ctx);
let calls = inits.borrow().clone();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].1, Value::Null);
assert!(
!calls[0].2,
"missing namespace should report no config even though init runs"
);
}
#[test]
fn init_reports_explicit_empty_namespaced_config() {
let inits = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(InitSpy {
inits: inits.clone(),
ns: "gauge",
}));
let theme = Theme::Dark;
let config = serde_json::json!({ "gauge": {} });
let ctx = InitCtx {
config: &config,
theme: &theme,
default_text_size: None,
default_font: None,
};
registry.init_all(&ctx);
let calls = inits.borrow().clone();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].1, serde_json::json!({}));
assert!(
calls[0].2,
"empty namespace object should count as supplied"
);
}
#[test]
fn init_reports_explicit_null_namespaced_config() {
let inits = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(InitSpy {
inits: inits.clone(),
ns: "gauge",
}));
let theme = Theme::Dark;
let config = serde_json::json!({ "gauge": null });
let ctx = InitCtx {
config: &config,
theme: &theme,
default_text_size: None,
default_font: None,
};
registry.init_all(&ctx);
let calls = inits.borrow().clone();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].1, Value::Null);
assert!(
calls[0].2,
"explicit null namespace should count as supplied"
);
}
#[test]
fn direct_init_ctx_null_reports_config_not_provided() {
let theme = Theme::Dark;
let null = Value::Null;
let ctx = InitCtx {
config: &null,
theme: &theme,
default_text_size: None,
default_font: None,
};
assert!(
!ctx.config_was_provided(),
"direct InitCtx literals cannot represent explicit null presence"
);
}
#[test]
fn direct_init_ctx_non_null_reports_config_provided() {
let theme = Theme::Dark;
let config = serde_json::json!({});
let ctx = InitCtx {
config: &config,
theme: &theme,
default_text_size: None,
default_font: None,
};
assert!(ctx.config_was_provided());
}
#[test]
fn init_ctx_config_or_default_parses_typed_struct() {
#[derive(Default, serde::Deserialize)]
struct Cfg {
#[serde(default)]
threshold: f32,
}
let theme = Theme::Dark;
let config = serde_json::json!({ "threshold": 42.0 });
let ctx = InitCtx {
config: &config,
theme: &theme,
default_text_size: None,
default_font: None,
};
let cfg = ctx.config_or_default::<Cfg>();
assert!((cfg.threshold - 42.0).abs() < f32::EPSILON);
}
#[test]
fn init_ctx_config_or_default_falls_back_on_null() {
#[derive(Default, serde::Deserialize)]
struct Cfg {
#[serde(default)]
threshold: f32,
}
let theme = Theme::Dark;
let null = Value::Null;
let ctx = InitCtx {
config: &null,
theme: &theme,
default_text_size: None,
default_font: None,
};
let cfg = ctx.config_or_default::<Cfg>();
assert_eq!(cfg.threshold, 0.0);
}
#[test]
fn init_ctx_config_as_reports_errors() {
#[derive(serde::Deserialize)]
struct Cfg {
#[allow(dead_code)]
threshold: f32,
}
let theme = Theme::Dark;
let bad = serde_json::json!({ "threshold": "not a number" });
let ctx = InitCtx {
config: &bad,
theme: &theme,
default_text_size: None,
default_font: None,
};
let result = ctx.config_as::<Cfg>();
assert!(result.is_err());
}
#[test]
fn fresh_for_session_isolates_state_between_sessions() {
let mut a = WidgetRegistry::<()>::new();
a.register(Box::new(CountingWidget::default()));
let mut tree_a = tree(vec![leaf("n1", "counter"), leaf("n2", "counter")]);
let mut shared_a = crate::shared_state::SharedState::new();
a.prepare_walk(&mut tree_a, &mut shared_a, &Theme::Dark);
let mut b = a.fresh_for_session();
let mut tree_b = tree(vec![leaf("n3", "counter")]);
let mut shared_b = crate::shared_state::SharedState::new();
b.prepare_walk(&mut tree_b, &mut shared_b, &Theme::Dark);
assert!(a.get_for_node_id("n1").is_some());
assert!(a.get_for_node_id("n2").is_some());
assert!(b.get_for_node_id("n3").is_some());
assert!(
b.get_for_node_id("n1").is_none(),
"session registry must not inherit node->factory map"
);
assert!(
b.get_for_node_id("n2").is_none(),
"session registry must not inherit node->factory map"
);
}
#[test]
fn fresh_for_session_preserves_type_index() {
let mut registry = WidgetRegistry::<()>::new();
registry.register_set(&TestSet);
let cloned = registry.fresh_for_session();
assert!(cloned.handles_type("alpha"));
assert!(cloned.handles_type("beta"));
assert_eq!(cloned.len(), registry.len());
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "before prepare completed for that node")]
fn render_node_panics_without_prepare_in_debug() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TestWidget::new(&["counter"])));
let node = leaf("c1", "counter");
let caches = crate::shared_state::SharedState::new();
let images = crate::image_registry::ImageRegistry::new();
let theme = Theme::Dark;
let ctx = test_render_ctx(&caches, &images, &theme, ®istry, "");
let _elem = registry.render_node(&node, &ctx);
}
#[cfg(debug_assertions)]
#[test]
fn render_node_accepts_prepared_node_in_debug() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TestWidget::new(&["counter"])));
let mut node = leaf("c1", "counter");
let mut caches = crate::shared_state::SharedState::new();
let images = crate::image_registry::ImageRegistry::new();
let theme = Theme::Dark;
registry.prepare_walk(&mut node, &mut caches, &theme);
let ctx = test_render_ctx(&caches, &images, &theme, ®istry, "");
let _elem = registry.render_node(&node, &ctx);
}
#[cfg(debug_assertions)]
#[test]
fn render_node_accepts_node_prepared_in_matching_window_in_debug() {
let mut registry = WidgetRegistry::<()>::new();
let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
registry.register(Box::new(WindowLifecycleWidget {
calls: calls.clone(),
}));
let mut node = leaf("c1", "window_lifecycle");
let mut caches = crate::shared_state::SharedState::new();
let images = crate::image_registry::ImageRegistry::new();
let theme = Theme::Dark;
registry.prepare_walk_in_window(&mut node, &mut caches, &theme, "main");
let ctx = test_render_ctx(&caches, &images, &theme, ®istry, "main");
let _elem = registry.render_node(&node, &ctx);
assert_eq!(
calls.borrow().as_slice(),
[
WindowLifecycleCall::Prepare {
window_id: "main".to_string(),
node_id: "c1".to_string(),
},
WindowLifecycleCall::Render {
window_id: "main".to_string(),
node_id: "c1".to_string(),
},
],
);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "before prepare completed for that node and type")]
fn render_node_panics_when_prepared_in_different_window_in_debug() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TestWidget::new(&["counter"])));
let mut node = leaf("c1", "counter");
let mut caches = crate::shared_state::SharedState::new();
let images = crate::image_registry::ImageRegistry::new();
let theme = Theme::Dark;
registry.prepare_walk_in_window(&mut node, &mut caches, &theme, "main");
let ctx = test_render_ctx(&caches, &images, &theme, ®istry, "secondary");
let _elem = registry.render_node(&node, &ctx);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "before prepare completed for that node and type")]
fn render_node_panics_when_rootless_prepare_renders_in_window_in_debug() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TestWidget::new(&["counter"])));
let mut node = leaf("c1", "counter");
let mut caches = crate::shared_state::SharedState::new();
let images = crate::image_registry::ImageRegistry::new();
let theme = Theme::Dark;
registry.prepare_walk(&mut node, &mut caches, &theme);
let ctx = test_render_ctx(&caches, &images, &theme, ®istry, "main");
let _elem = registry.render_node(&node, &ctx);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "before prepare completed for that node and type")]
fn render_node_panics_after_type_changes_since_prepare_in_debug() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TestWidget::new(&["counter"])));
registry.register(Box::new(TestWidget::new(&["other"])));
let mut node = leaf("c1", "counter");
let mut caches = crate::shared_state::SharedState::new();
let images = crate::image_registry::ImageRegistry::new();
let theme = Theme::Dark;
registry.prepare_walk(&mut node, &mut caches, &theme);
node.type_name = "other".to_string();
let ctx = test_render_ctx(&caches, &images, &theme, ®istry, "");
let _elem = registry.render_node(&node, &ctx);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "after props changed since prepare completed")]
fn render_node_panics_after_props_change_since_prepare_in_debug() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TestWidget::new(&["counter"])));
let mut node = leaf("c1", "counter");
let mut caches = crate::shared_state::SharedState::new();
let images = crate::image_registry::ImageRegistry::new();
let theme = Theme::Dark;
registry.prepare_walk(&mut node, &mut caches, &theme);
node.props = plushie_core::protocol::Props::from_json(serde_json::json!({
"value": 1
}));
let ctx = test_render_ctx(&caches, &images, &theme, ®istry, "");
let _elem = registry.render_node(&node, &ctx);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "after node or child changed since prepare completed")]
fn render_node_panics_after_child_changes_since_prepare_in_debug() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TestWidget::new(&["counter"])));
let mut node = TreeNode {
id: "parent".to_string(),
type_name: "counter".to_string(),
props: plushie_core::protocol::Props::default(),
children: vec![leaf("child", "counter")],
};
let mut caches = crate::shared_state::SharedState::new();
let images = crate::image_registry::ImageRegistry::new();
let theme = Theme::Dark;
registry.prepare_walk(&mut node, &mut caches, &theme);
node.children[0].props = plushie_core::protocol::Props::from_json(serde_json::json!({
"value": 1
}));
let ctx = test_render_ctx(&caches, &images, &theme, ®istry, "");
let _elem = registry.render_node(&node, &ctx);
}
#[test]
fn type_names_by_set_groups_correctly() {
let mut registry = WidgetRegistry::<()>::new();
registry.register_set(&TestSet);
registry.register(Box::new(TestWidget::new(&["custom"])));
let by_set = registry.type_names_by_set();
assert!(by_set.get("test").unwrap().contains(&"alpha"));
assert!(by_set.get("test").unwrap().contains(&"beta"));
assert!(by_set.get("(none)").unwrap().contains(&"custom"));
}
struct SpecWidget;
impl PlushieWidget<()> for SpecWidget {
fn type_names(&self) -> &[&str] {
&["gauge"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("gauge").into()
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(SpecWidget)
}
fn event_specs(&self) -> Vec<plushie_core::EventSpec> {
use plushie_core::spec::*;
vec![
EventSpec {
family: "slide".into(),
payload: PayloadSpec::Value(ValueType::Float),
},
EventSpec {
family: "calibrated".into(),
payload: PayloadSpec::None,
},
]
}
fn command_specs(&self) -> Vec<plushie_core::CommandSpec> {
use plushie_core::spec::*;
vec![
CommandSpec {
family: "set_value".into(),
payload: PayloadSpec::Value(ValueType::Float),
},
CommandSpec {
family: "reset".into(),
payload: PayloadSpec::None,
},
]
}
}
#[test]
fn event_validation_accepts_correct_payload() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(SpecWidget));
let idx = registry.index_for_type("gauge").unwrap();
registry.map_node("g1".into(), idx);
registry.validate_event_payload("g1", "slide", &serde_json::json!(42.0));
registry.validate_event_payload("g1", "calibrated", &serde_json::Value::Null);
}
#[test]
fn command_validation_accepts_correct_payload() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(SpecWidget));
let idx = registry.index_for_type("gauge").unwrap();
registry.map_node("g1".into(), idx);
registry.validate_command_payload(idx, "g1", "set_value", &serde_json::json!(72.0));
registry.validate_command_payload(idx, "g1", "reset", &serde_json::Value::Null);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "event spec mismatch")]
fn event_validation_panics_on_wrong_type_in_debug() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(SpecWidget));
let idx = registry.index_for_type("gauge").unwrap();
registry.map_node("g1".into(), idx);
registry.validate_event_payload("g1", "slide", &serde_json::json!("not a number"));
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "command spec mismatch")]
fn command_validation_panics_on_wrong_type_in_debug() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(SpecWidget));
let idx = registry.index_for_type("gauge").unwrap();
registry.map_node("g1".into(), idx);
registry.validate_command_payload(idx, "g1", "set_value", &serde_json::json!("wrong"));
}
#[test]
fn validation_skipped_for_unknown_family() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(SpecWidget));
let idx = registry.index_for_type("gauge").unwrap();
registry.map_node("g1".into(), idx);
registry.validate_event_payload("g1", "unknown_event", &serde_json::json!("anything"));
registry.validate_command_payload(idx, "g1", "unknown_cmd", &serde_json::json!(true));
}
#[test]
fn validation_skipped_for_widgets_without_specs() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TestWidget::new(&["button"])));
let idx = registry.index_for_type("button").unwrap();
registry.map_node("b1".into(), idx);
registry.validate_event_payload("b1", "click", &serde_json::json!("wrong type"));
registry.validate_command_payload(idx, "b1", "anything", &serde_json::json!(true));
}
#[test]
fn event_specs_validation_uses_active_node_owner_after_override() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(SpecWidget));
registry.register(Box::new(EventSpecStringOverride));
let idx = registry.index_for_type("gauge").unwrap();
registry.map_node("g1".into(), idx);
registry.validate_event_payload("g1", "slide", &serde_json::json!("active"));
}
#[test]
fn command_specs_validation_uses_active_node_owner_after_override() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(SpecWidget));
registry.register(Box::new(CommandSpecStringOverride));
let idx = registry.index_for_type("gauge").unwrap();
registry.map_node("g1".into(), idx);
let events = registry.handle_widget_op("g1", "set_value", &serde_json::json!("active"));
assert!(events.is_none());
}
#[test]
fn alias_partial_override_does_not_steal_widget_op_dispatch() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(AliasDispatchOwner));
registry.register(Box::new(HiddenAliasOverride));
let idx = registry.index_for_type("visible").unwrap();
registry.map_node("node".into(), idx);
let events = registry
.handle_widget_op("node", "run", &Value::Null)
.expect("visible owner should handle the widget op");
assert_eq!(events[0].family, "command_owner");
}
#[test]
fn alias_partial_override_does_not_steal_message_dispatch() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(AliasDispatchOwner));
registry.register(Box::new(HiddenAliasOverride));
let idx = registry.index_for_type("visible").unwrap();
registry.map_node("node".into(), idx);
let events = registry.process_message(&Message::Event {
window_id: String::new(),
id: "node".to_string(),
value: Value::Null,
family: "press".to_string(),
});
assert_eq!(events[0].family, "message_owner");
}
#[test]
fn alias_partial_override_does_not_steal_subscription_dispatch() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(AliasDispatchOwner));
registry.register(Box::new(HiddenAliasOverride));
let mut tree = tree(vec![leaf("node", "visible")]);
let mut shared = crate::shared_state::SharedState::new();
registry.prepare_walk(&mut tree, &mut shared, &Theme::Dark);
let events = registry.dispatch_widget_subscription("alias_tick", None, &Message::NoOp);
assert_eq!(events[0].family, "message_owner");
}
struct AliasDispatchOwner;
impl PlushieWidget<()> for AliasDispatchOwner {
fn type_names(&self) -> &[&str] {
&["hidden", "visible"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("visible").into()
}
fn handle_message(&mut self, _msg: &Message) -> HandleResult {
HandleResult::emit(vec![OutgoingEvent::generic(
"message_owner".to_string(),
"node".to_string(),
None,
)])
}
fn handle_widget_op(
&mut self,
_node_id: &str,
_op: &str,
_payload: &Value,
) -> Option<Vec<OutgoingEvent>> {
Some(vec![OutgoingEvent::generic(
"command_owner".to_string(),
"node".to_string(),
None,
)])
}
fn subscriptions(
&self,
_node: &TreeNode,
_ctx: &SubscribeCtx<'_>,
) -> Vec<WidgetSubscription> {
vec![WidgetSubscription::new("alias_tick", "tick")]
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(AliasDispatchOwner)
}
}
struct HiddenAliasOverride;
impl PlushieWidget<()> for HiddenAliasOverride {
fn type_names(&self) -> &[&str] {
&["hidden"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("hidden").into()
}
fn handle_message(&mut self, _msg: &Message) -> HandleResult {
HandleResult::emit(vec![OutgoingEvent::generic(
"message_override".to_string(),
"node".to_string(),
None,
)])
}
fn handle_widget_op(
&mut self,
_node_id: &str,
_op: &str,
_payload: &Value,
) -> Option<Vec<OutgoingEvent>> {
Some(vec![OutgoingEvent::generic(
"command_override".to_string(),
"node".to_string(),
None,
)])
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(HiddenAliasOverride)
}
}
struct EventSpecStringOverride;
impl PlushieWidget<()> for EventSpecStringOverride {
fn type_names(&self) -> &[&str] {
&["gauge"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("gauge").into()
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(EventSpecStringOverride)
}
fn event_specs(&self) -> Vec<plushie_core::EventSpec> {
use plushie_core::spec::*;
vec![EventSpec {
family: "slide".into(),
payload: PayloadSpec::Value(ValueType::String),
}]
}
}
struct CommandSpecStringOverride;
impl PlushieWidget<()> for CommandSpecStringOverride {
fn type_names(&self) -> &[&str] {
&["gauge"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("gauge").into()
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(CommandSpecStringOverride)
}
fn command_specs(&self) -> Vec<plushie_core::CommandSpec> {
use plushie_core::spec::*;
vec![CommandSpec {
family: "set_value".into(),
payload: PayloadSpec::Value(ValueType::String),
}]
}
}
fn tree(children: Vec<TreeNode>) -> TreeNode {
TreeNode {
id: "root".into(),
type_name: "container".into(),
props: plushie_core::protocol::Props::default(),
children,
}
}
fn leaf(id: &str, type_name: &str) -> TreeNode {
TreeNode {
id: id.to_string(),
type_name: type_name.to_string(),
props: plushie_core::protocol::Props::default(),
children: vec![],
}
}
fn animated_leaf(id: &str, type_name: &str) -> TreeNode {
TreeNode {
id: id.to_string(),
type_name: type_name.to_string(),
props: plushie_core::protocol::Props::from_json(serde_json::json!({
"opacity": {
"type": "transition",
"from": 0.0,
"to": 1.0,
"duration": 1000.0
}
})),
children: vec![],
}
}
use std::sync::Arc;
#[derive(Default)]
struct ContentsSpy {
sizes: std::sync::Mutex<Vec<usize>>,
}
struct SpyingWidget {
contents: std::collections::HashMap<(String, String), String>,
spy: Arc<ContentsSpy>,
}
impl PlushieWidget<()> for SpyingWidget {
fn type_names(&self) -> &[&str] {
&["spying"]
}
fn prepare(&mut self, node: &TreeNode, window_id: &str, _theme: &Theme) {
self.contents
.insert((window_id.to_string(), node.id.clone()), node.id.clone());
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("spy").into()
}
fn prune_stale(&mut self, live_ids: &std::collections::HashSet<(String, String)>) {
self.contents.retain(|k, _| live_ids.contains(k));
self.spy.sizes.lock().unwrap().push(self.contents.len());
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(SpyingWidget {
contents: std::collections::HashMap::new(),
spy: self.spy.clone(),
})
}
}
#[test]
fn prune_stale_removes_keys_for_nodes_not_in_tree() {
let mut registry = WidgetRegistry::<()>::new();
let spy = Arc::new(ContentsSpy::default());
registry.register(Box::new(SpyingWidget {
contents: std::collections::HashMap::new(),
spy: spy.clone(),
}));
let mut shared = crate::shared_state::SharedState::new();
let theme = Theme::Dark;
let mut first_tree = tree(vec![leaf("a", "spying"), leaf("b", "spying")]);
registry.prepare_walk(&mut first_tree, &mut shared, &theme);
let mut second_tree = tree(vec![leaf("a", "spying")]);
registry.prepare_walk(&mut second_tree, &mut shared, &theme);
let sizes = spy.sizes.lock().unwrap().clone();
assert_eq!(
sizes,
vec![2, 1],
"prune_stale should observe 2 contents after first walk and 1 after the second"
);
}
#[test]
fn prepare_and_scan_prunes_animation_state_for_removed_nodes() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TestWidget::new(&["animated"])));
let mut shared = crate::shared_state::SharedState::new();
let mut animations = crate::animation::TransitionManager::new();
let mut first_tree = tree(vec![animated_leaf("gone", "animated")]);
registry.prepare_and_scan(&mut first_tree, &mut shared, &Theme::Dark, &mut animations);
assert!(animations.has_active());
animations.advance_all(iced::time::Instant::now(), &mut shared.interpolated_props);
assert!(shared.interpolated_props.contains_key("gone"));
let mut second_tree = tree(vec![]);
registry.prepare_and_scan(&mut second_tree, &mut shared, &Theme::Dark, &mut animations);
assert!(!animations.has_active());
assert!(!shared.interpolated_props.contains_key("gone"));
animations.advance_all(iced::time::Instant::now(), &mut shared.interpolated_props);
assert!(!shared.interpolated_props.contains_key("gone"));
}
#[test]
fn prepare_walk_caps_depth_and_logs() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TestWidget::new(&["stacked"])));
let mut node = leaf("leaf", "stacked");
for i in 0..(crate::shared_state::MAX_TREE_DEPTH + 20) {
node = TreeNode {
id: format!("n{i}"),
type_name: "stacked".into(),
props: plushie_core::protocol::Props::default(),
children: vec![node],
};
}
let mut shared = crate::shared_state::SharedState::new();
registry.prepare_walk(&mut node, &mut shared, &Theme::Dark);
}
struct PanickingButton;
impl PlushieWidget<iced::Renderer> for PanickingButton {
fn type_names(&self) -> &[&str] {
&["button"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, iced::Renderer>,
) -> Element<'a, Message, Theme, iced::Renderer> {
panic!("intentional render panic");
}
fn init(&mut self, _ctx: &InitCtx<'_>) {
panic!("intentional init panic");
}
fn handle_message(&mut self, _msg: &Message) -> HandleResult {
panic!("intentional handle_message panic");
}
fn handle_widget_op(
&mut self,
_node_id: &str,
_op: &str,
_payload: &Value,
) -> Option<Vec<OutgoingEvent>> {
panic!("intentional handle_widget_op panic");
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<iced::Renderer>> {
Box::new(PanickingButton)
}
}
struct TimerWidget;
impl PlushieWidget<()> for TimerWidget {
fn type_names(&self) -> &[&str] {
&["timer"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("timer").into()
}
fn subscriptions(
&self,
_node: &TreeNode,
_ctx: &SubscribeCtx<'_>,
) -> Vec<WidgetSubscription> {
vec![WidgetSubscription::new("animation_frame", "tick")]
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(TimerWidget)
}
}
#[test]
fn widget_subscriptions_collected_and_namespaced_during_prepare_walk() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TimerWidget));
let mut tree = tree(vec![leaf("t1", "timer")]);
let mut shared = crate::shared_state::SharedState::new();
registry.prepare_walk(&mut tree, &mut shared, &Theme::Dark);
let subs = registry.active_widget_subscriptions();
let expected_key = "widget:#timer/t1:tick";
assert!(
subs.contains_key(expected_key),
"expected namespaced subscription key {expected_key}, got keys: {:?}",
subs.keys().collect::<Vec<_>>()
);
let collected = subs.get(expected_key).unwrap();
assert_eq!(collected.kind, "animation_frame");
assert_eq!(collected.node_id, "t1");
}
#[test]
fn widget_subscriptions_dropped_when_node_leaves_tree() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TimerWidget));
let mut shared = crate::shared_state::SharedState::new();
let mut first = tree(vec![leaf("t1", "timer")]);
registry.prepare_walk(&mut first, &mut shared, &Theme::Dark);
assert!(!registry.active_widget_subscriptions().is_empty());
let mut empty_tree = tree(vec![]);
registry.prepare_walk(&mut empty_tree, &mut shared, &Theme::Dark);
assert!(
registry.active_widget_subscriptions().is_empty(),
"subscription must go away once the owning node leaves the tree"
);
}
struct CountingTickWidget;
impl PlushieWidget<()> for CountingTickWidget {
fn type_names(&self) -> &[&str] {
&["counter"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("count").into()
}
fn subscriptions(
&self,
node: &TreeNode,
_ctx: &SubscribeCtx<'_>,
) -> Vec<WidgetSubscription> {
vec![WidgetSubscription::new("on_animation_frame", &node.id)]
}
fn handle_message(&mut self, _msg: &Message) -> HandleResult {
HandleResult::emit(vec![OutgoingEvent::generic(
"tick".to_string(),
"counter".to_string(),
None,
)])
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(CountingTickWidget)
}
}
#[test]
fn has_widget_subscription_reports_active_kinds() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(CountingTickWidget));
let mut tree = tree(vec![leaf("c1", "counter")]);
let mut shared = crate::shared_state::SharedState::new();
registry.prepare_walk(&mut tree, &mut shared, &Theme::Dark);
assert!(registry.has_widget_subscription("on_animation_frame"));
assert!(!registry.has_widget_subscription("on_key_press"));
}
#[test]
fn dispatch_widget_subscription_calls_handle_message_on_owners() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(CountingTickWidget));
let mut tree = tree(vec![leaf("c1", "counter"), leaf("c2", "counter")]);
let mut shared = crate::shared_state::SharedState::new();
registry.prepare_walk(&mut tree, &mut shared, &Theme::Dark);
let events =
registry.dispatch_widget_subscription("on_animation_frame", None, &Message::NoOp);
assert_eq!(
events.len(),
2,
"each subscribed widget should see the message and emit one event"
);
}
#[test]
fn dispatch_widget_subscription_ignores_non_matching_kinds() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(CountingTickWidget));
let mut tree = tree(vec![leaf("c1", "counter")]);
let mut shared = crate::shared_state::SharedState::new();
registry.prepare_walk(&mut tree, &mut shared, &Theme::Dark);
let events = registry.dispatch_widget_subscription("on_key_press", None, &Message::NoOp);
assert!(events.is_empty());
}
#[test]
#[should_panic(expected = "collides with existing type name")]
fn register_strict_panics_on_type_name_collision() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(TestWidget::new(&["button"])));
registry.register_strict(Box::new(TestWidget::new(&["button"])));
}
#[test]
fn register_strict_succeeds_without_collision() {
let mut registry = WidgetRegistry::<()>::new();
registry.register_strict(Box::new(TestWidget::new(&["one"])));
registry.register_strict(Box::new(TestWidget::new(&["two"])));
assert!(registry.handles_type("one"));
assert!(registry.handles_type("two"));
}
struct SpecFamilyA;
impl PlushieWidget<()> for SpecFamilyA {
fn type_names(&self) -> &[&str] {
&["a"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("a").into()
}
fn event_specs(&self) -> Vec<plushie_core::EventSpec> {
use plushie_core::spec::*;
vec![EventSpec {
family: "select".into(),
payload: PayloadSpec::Value(ValueType::Integer),
}]
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(SpecFamilyA)
}
}
struct SpecFamilyBMatching;
impl PlushieWidget<()> for SpecFamilyBMatching {
fn type_names(&self) -> &[&str] {
&["b"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("b").into()
}
fn event_specs(&self) -> Vec<plushie_core::EventSpec> {
use plushie_core::spec::*;
vec![EventSpec {
family: "select".into(),
payload: PayloadSpec::Value(ValueType::Integer),
}]
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(SpecFamilyBMatching)
}
}
struct SpecFamilyBConflicting;
impl PlushieWidget<()> for SpecFamilyBConflicting {
fn type_names(&self) -> &[&str] {
&["b"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("b").into()
}
fn event_specs(&self) -> Vec<plushie_core::EventSpec> {
use plushie_core::spec::*;
vec![EventSpec {
family: "select".into(),
payload: PayloadSpec::Value(ValueType::String),
}]
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(SpecFamilyBConflicting)
}
}
#[test]
fn family_collision_silent_for_matching_specs() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(SpecFamilyA));
registry.register(Box::new(SpecFamilyBMatching));
assert!(registry.family_collision_diagnostics().is_empty());
}
#[test]
fn family_collision_emitted_for_mismatched_specs() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(SpecFamilyA));
registry.register(Box::new(SpecFamilyBConflicting));
let diags = registry.family_collision_diagnostics();
assert_eq!(
diags.len(),
1,
"mismatched spec must produce one diagnostic"
);
}
#[test]
fn family_collision_ignores_shadowed_event_specs() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(SpecFamilyA));
registry.register(Box::new(SpecFamilyAStringOverride));
registry.register(Box::new(SpecFamilyBConflicting));
assert!(registry.family_collision_diagnostics().is_empty());
}
#[test]
fn family_collision_ignores_shadowed_command_specs() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(CommandFamilyA));
registry.register(Box::new(CommandFamilyAStringOverride));
registry.register(Box::new(CommandFamilyBMatchingOverride));
assert!(registry.family_collision_diagnostics().is_empty());
}
#[test]
fn family_collision_ignores_empty_type_name_specs() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(SpecFamilyA));
registry.register(Box::new(EmptyTypeNameSpecFamily));
assert!(registry.family_collision_diagnostics().is_empty());
}
#[test]
fn family_collision_reports_active_alias_name() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(SpecFamilyWithShadowedAlias));
registry.register(Box::new(SpecFamilyHiddenAliasOverride));
let diags = registry.family_collision_diagnostics();
let value = diags
.first()
.and_then(|event| event.value.as_ref())
.expect("conflicting active specs should produce a diagnostic");
assert_eq!(value["type_a"], "hidden");
assert_eq!(value["type_b"], "visible");
}
struct SpecFamilyAStringOverride;
impl PlushieWidget<()> for SpecFamilyAStringOverride {
fn type_names(&self) -> &[&str] {
&["a"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("a").into()
}
fn event_specs(&self) -> Vec<plushie_core::EventSpec> {
use plushie_core::spec::*;
vec![EventSpec {
family: "select".into(),
payload: PayloadSpec::Value(ValueType::String),
}]
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(SpecFamilyAStringOverride)
}
}
struct CommandFamilyA;
impl PlushieWidget<()> for CommandFamilyA {
fn type_names(&self) -> &[&str] {
&["command_a"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("command a").into()
}
fn command_specs(&self) -> Vec<plushie_core::CommandSpec> {
use plushie_core::spec::*;
vec![CommandSpec {
family: "set".into(),
payload: PayloadSpec::Value(ValueType::Integer),
}]
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(CommandFamilyA)
}
}
struct CommandFamilyAStringOverride;
impl PlushieWidget<()> for CommandFamilyAStringOverride {
fn type_names(&self) -> &[&str] {
&["command_a"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("command a").into()
}
fn command_specs(&self) -> Vec<plushie_core::CommandSpec> {
use plushie_core::spec::*;
vec![CommandSpec {
family: "set".into(),
payload: PayloadSpec::Value(ValueType::String),
}]
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(CommandFamilyAStringOverride)
}
}
struct CommandFamilyBMatchingOverride;
impl PlushieWidget<()> for CommandFamilyBMatchingOverride {
fn type_names(&self) -> &[&str] {
&["command_b"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("command b").into()
}
fn command_specs(&self) -> Vec<plushie_core::CommandSpec> {
use plushie_core::spec::*;
vec![CommandSpec {
family: "set".into(),
payload: PayloadSpec::Value(ValueType::String),
}]
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(CommandFamilyBMatchingOverride)
}
}
struct EmptyTypeNameSpecFamily;
impl PlushieWidget<()> for EmptyTypeNameSpecFamily {
fn type_names(&self) -> &[&str] {
&[]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("empty").into()
}
fn event_specs(&self) -> Vec<plushie_core::EventSpec> {
use plushie_core::spec::*;
vec![EventSpec {
family: "select".into(),
payload: PayloadSpec::Value(ValueType::String),
}]
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(EmptyTypeNameSpecFamily)
}
}
struct SpecFamilyWithShadowedAlias;
impl PlushieWidget<()> for SpecFamilyWithShadowedAlias {
fn type_names(&self) -> &[&str] {
&["hidden", "visible"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("visible").into()
}
fn event_specs(&self) -> Vec<plushie_core::EventSpec> {
use plushie_core::spec::*;
vec![EventSpec {
family: "select".into(),
payload: PayloadSpec::Value(ValueType::String),
}]
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(SpecFamilyWithShadowedAlias)
}
}
struct SpecFamilyHiddenAliasOverride;
impl PlushieWidget<()> for SpecFamilyHiddenAliasOverride {
fn type_names(&self) -> &[&str] {
&["hidden"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("hidden").into()
}
fn event_specs(&self) -> Vec<plushie_core::EventSpec> {
use plushie_core::spec::*;
vec![EventSpec {
family: "select".into(),
payload: PayloadSpec::Value(ValueType::Integer),
}]
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(SpecFamilyHiddenAliasOverride)
}
}
#[test]
fn widget_override_of_iced_type_clears_trusted_provenance() {
let mut registry = WidgetRegistry::<iced::Renderer>::new();
registry.register_set(&crate::widget::widget_set::iced_widget_set());
assert_eq!(
registry.provenance.get("button").map(|s| s.as_str()),
Some("iced"),
"iced set should install provenance for `button`"
);
registry.register(Box::new(PanickingButton));
assert!(
!registry.provenance.contains_key("button"),
"`.widget()` override must drop inherited provenance so panic \
isolation wraps the new widget"
);
}
#[test]
fn trusted_button_override_does_not_trust_stale_button_index_for_init() {
let mut registry = WidgetRegistry::<iced::Renderer>::new();
registry.register(Box::new(PanickingButton));
let stale_idx = registry.index_for_type("button").unwrap();
registry.register_set(&crate::widget::widget_set::iced_widget_set());
assert_ne!(registry.index_for_type("button"), Some(stale_idx));
assert_eq!(
registry.provenance.get("button").map(|s| s.as_str()),
Some("iced")
);
let theme = Theme::Dark;
let config = Value::Null;
let ctx = InitCtx {
config: &config,
theme: &theme,
default_text_size: None,
default_font: None,
};
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
registry.init_all(&ctx);
}));
assert!(
result.is_ok(),
"stale untrusted implementation must remain panic-isolated"
);
}
#[test]
fn trusted_button_override_does_not_trust_stale_button_index_for_message() {
let mut registry = WidgetRegistry::<iced::Renderer>::new();
registry.register(Box::new(PanickingButton));
let stale_idx = registry.index_for_type("button").unwrap();
registry.register_set(&crate::widget::widget_set::iced_widget_set());
assert_ne!(registry.index_for_type("button"), Some(stale_idx));
registry.map_node("stale".to_string(), stale_idx);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
registry.process_message(&Message::Event {
window_id: String::new(),
id: "stale".to_string(),
value: Value::Null,
family: "press".to_string(),
})
}));
let events = result.expect("stale untrusted message panic should be isolated");
assert_eq!(events.len(), 1);
assert_eq!(events[0].family, "press");
assert_eq!(events[0].id, "stale");
}
#[test]
fn trusted_button_override_does_not_trust_stale_button_index_for_widget_op() {
let mut registry = WidgetRegistry::<iced::Renderer>::new();
registry.register(Box::new(PanickingButton));
let stale_idx = registry.index_for_type("button").unwrap();
registry.register_set(&crate::widget::widget_set::iced_widget_set());
assert_ne!(registry.index_for_type("button"), Some(stale_idx));
registry.map_node("stale".to_string(), stale_idx);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
registry.handle_widget_op("stale", "run", &Value::Null)
}));
let events = result.expect("stale untrusted widget op panic should be isolated");
assert!(events.is_none());
}
struct PanickingInPrepare;
impl PlushieWidget<()> for PanickingInPrepare {
fn type_names(&self) -> &[&str] {
&["prepare_panic"]
}
fn prepare(&mut self, _node: &TreeNode, _window_id: &str, _theme: &Theme) {
panic!("intentional prepare panic");
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("noop").into()
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(PanickingInPrepare)
}
}
#[test]
fn untrusted_prepare_panic_does_not_crash_registry() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(PanickingInPrepare));
let mut node = leaf("p1", "prepare_panic");
let mut shared = crate::shared_state::SharedState::new();
let images = crate::image_registry::ImageRegistry::new();
let theme = Theme::Dark;
registry.prepare_walk(&mut node, &mut shared, &theme);
let ctx = test_render_ctx(&shared, &images, &theme, ®istry, "");
let rendered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _elem = registry.render_node(&node, &ctx);
}));
assert!(
rendered.is_ok(),
"prepare failure should not make the debug guard unwind during render"
);
}
struct PanickingInHandleMessage;
impl PlushieWidget<()> for PanickingInHandleMessage {
fn type_names(&self) -> &[&str] {
&["message_panic"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("noop").into()
}
fn handle_message(&mut self, _msg: &Message) -> HandleResult {
panic!("intentional handle_message panic");
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(PanickingInHandleMessage)
}
}
#[test]
fn untrusted_handle_message_panic_falls_through_without_unwinding() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(PanickingInHandleMessage));
let idx = registry.index_for_type("message_panic").unwrap();
registry.map_node("m1".to_string(), idx);
let events = registry.process_message(&Message::Event {
window_id: String::new(),
id: "m1".to_string(),
value: Value::Null,
family: "click".to_string(),
});
assert_eq!(events.len(), 1);
assert_eq!(events[0].family, "click");
assert_eq!(events[0].id, "m1");
}
struct StatefulPanicInHandleMessage {
count: u32,
}
impl PlushieWidget<()> for StatefulPanicInHandleMessage {
fn type_names(&self) -> &[&str] {
&["stateful_message_panic"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("noop").into()
}
fn handle_message(&mut self, msg: &Message) -> HandleResult {
match msg {
Message::Event { family, .. } if family == "panic" => {
self.count += 1;
panic!("intentional handle_message panic after mutation");
}
Message::Event { id, family, .. } if family == "report" => {
HandleResult::emit(vec![OutgoingEvent::generic(
"state".to_string(),
id.clone(),
Some(serde_json::json!(self.count)),
)])
}
_ => HandleResult::Fallthrough,
}
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
Box::new(StatefulPanicInHandleMessage { count: 0 })
}
}
#[test]
fn untrusted_mutable_panic_replaces_widget_with_fresh_instance() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(StatefulPanicInHandleMessage { count: 0 }));
let idx = registry.index_for_type("stateful_message_panic").unwrap();
registry.map_node("s1".to_string(), idx);
let _ = registry.process_message(&Message::Event {
window_id: String::new(),
id: "s1".to_string(),
value: Value::Null,
family: "panic".to_string(),
});
let events = registry.process_message(&Message::Event {
window_id: String::new(),
id: "s1".to_string(),
value: Value::Null,
family: "report".to_string(),
});
assert_eq!(registry.index_for_type("stateful_message_panic"), Some(idx));
assert_eq!(
registry.get_for_node_id("s1").map(|(found, _)| found),
Some(idx)
);
assert_eq!(events.len(), 1);
assert_eq!(events[0].family, "state");
assert_eq!(events[0].id, "s1");
assert_eq!(events[0].value, Some(serde_json::json!(0)));
}
struct PanickingFreshForSession;
impl PlushieWidget<()> for PanickingFreshForSession {
fn type_names(&self) -> &[&str] {
&["fresh_panic"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, ()>,
) -> Element<'a, Message, Theme, ()> {
iced::widget::text("noop").into()
}
fn handle_message(&mut self, _msg: &Message) -> HandleResult {
HandleResult::emit(vec![OutgoingEvent::generic(
"handled".to_string(),
"fresh".to_string(),
None,
)])
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<()>> {
panic!("intentional fresh_for_session panic");
}
}
#[test]
fn untrusted_mutable_dispatch_contains_fresh_for_session_panic() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(PanickingFreshForSession));
let idx = registry.index_for_type("fresh_panic").unwrap();
registry.map_node("fresh".to_string(), idx);
let events = registry.process_message(&Message::Event {
window_id: String::new(),
id: "fresh".to_string(),
value: Value::Null,
family: "click".to_string(),
});
assert_eq!(events.len(), 1);
assert_eq!(events[0].family, "click");
assert_eq!(events[0].id, "fresh");
}
#[test]
fn untrusted_prepare_contains_fresh_for_session_panic_before_render_guard() {
let mut registry = WidgetRegistry::<()>::new();
registry.register(Box::new(PanickingFreshForSession));
let mut node = leaf("fresh", "fresh_panic");
let mut shared = crate::shared_state::SharedState::new();
let images = crate::image_registry::ImageRegistry::new();
let theme = Theme::Dark;
registry.prepare_walk(&mut node, &mut shared, &theme);
let ctx = test_render_ctx(&shared, &images, &theme, ®istry, "");
let rendered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _elem = registry.render_node(&node, &ctx);
}));
assert!(
rendered.is_ok(),
"fresh_for_session failure should not make the debug guard unwind during render"
);
}
#[test]
fn untrusted_widget_override_panic_is_contained_in_render() {
let mut registry = WidgetRegistry::<iced::Renderer>::new();
registry.register_set(&crate::widget::widget_set::iced_widget_set());
registry.register(Box::new(PanickingButton));
let mut node = leaf("b1", "button");
let mut caches = crate::shared_state::SharedState::new();
let images = crate::image_registry::ImageRegistry::new();
let theme = iced::Theme::Dark;
registry.prepare_walk(&mut node, &mut caches, &theme);
let ctx = test_render_ctx(&caches, &images, &theme, ®istry, "");
let _elem = registry.render_node(&node, &ctx);
}
struct FlakyButton {
panicked_once: std::cell::Cell<bool>,
}
impl PlushieWidget<iced::Renderer> for FlakyButton {
fn type_names(&self) -> &[&str] {
&["button"]
}
fn render<'a>(
&'a self,
_node: &'a TreeNode,
_ctx: &RenderCtx<'a, iced::Renderer>,
) -> Element<'a, Message, Theme, iced::Renderer> {
if !self.panicked_once.replace(true) {
panic!("transient render panic");
}
iced::widget::text("recovered").into()
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<iced::Renderer>> {
Box::new(FlakyButton {
panicked_once: std::cell::Cell::new(false),
})
}
}
#[test]
fn widget_render_panic_isolates_and_next_render_still_works() {
let mut registry = WidgetRegistry::<iced::Renderer>::new();
registry.register_set(&crate::widget::widget_set::iced_widget_set());
registry.register(Box::new(FlakyButton {
panicked_once: std::cell::Cell::new(false),
}));
let mut node = leaf("flaky", "button");
let mut caches = crate::shared_state::SharedState::new();
let images = crate::image_registry::ImageRegistry::new();
let theme = iced::Theme::Dark;
registry.prepare_walk(&mut node, &mut caches, &theme);
{
let ctx = test_render_ctx(&caches, &images, &theme, ®istry, "");
let _placeholder = registry.render_node(&node, &ctx);
}
registry.prepare_walk(&mut node, &mut caches, &theme);
{
let ctx = test_render_ctx(&caches, &images, &theme, ®istry, "");
let _recovered = registry.render_node(&node, &ctx);
}
}
#[test]
fn widget_render_panic_does_not_block_other_widgets_in_same_tree() {
let mut registry = WidgetRegistry::<iced::Renderer>::new();
registry.register_set(&crate::widget::widget_set::iced_widget_set());
registry.register(Box::new(PanickingButton));
let mut root = tree(vec![leaf("bad", "button"), leaf("good", "text")]);
let mut caches = crate::shared_state::SharedState::new();
let images = crate::image_registry::ImageRegistry::new();
let theme = iced::Theme::Dark;
registry.prepare_walk(&mut root, &mut caches, &theme);
{
let ctx = test_render_ctx(&caches, &images, &theme, ®istry, "");
let bad_node = &root.children[0];
let good_node = &root.children[1];
let _bad_placeholder = registry.render_node(bad_node, &ctx);
let _good_render = registry.render_node(good_node, &ctx);
}
}
}