use plushie_core::protocol::TreeNode;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
pub const OVERLAY_PREFIX: &str = "__plushie_dev__";
pub const DISMISS_DELAY: Duration = Duration::from_millis(1500);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Rebuilding,
Success,
Failed,
Frozen,
}
impl Status {
pub fn message(self) -> &'static str {
match self {
Status::Rebuilding => "Rebuilding...",
Status::Success => "Rebuild succeeded.",
Status::Failed => "Rebuild failed.",
Status::Frozen => "UI frozen: view/1 is failing repeatedly.",
}
}
pub fn icon(self) -> &'static str {
match self {
Status::Rebuilding => "...",
Status::Success => "ok",
Status::Failed | Status::Frozen => "!!",
}
}
}
#[derive(Debug, Clone)]
pub struct RebuildingOverlay {
pub status: Status,
pub detail: String,
pub expanded: bool,
pub success_at: Option<Instant>,
}
impl Default for RebuildingOverlay {
fn default() -> Self {
Self {
status: Status::Rebuilding,
detail: String::new(),
expanded: false,
success_at: None,
}
}
}
impl RebuildingOverlay {
pub fn should_dismiss(&self) -> bool {
matches!(self.status, Status::Success)
&& self
.success_at
.map(|t| t.elapsed() >= DISMISS_DELAY)
.unwrap_or(false)
}
}
#[derive(Debug, Clone, Default)]
pub struct DevOverlayHandle {
inner: Arc<Mutex<Option<RebuildingOverlay>>>,
}
impl DevOverlayHandle {
pub fn new() -> Self {
Self::default()
}
pub fn set(&self, overlay: Option<RebuildingOverlay>) {
if let Ok(mut guard) = self.inner.lock() {
*guard = overlay;
}
}
pub fn snapshot(&self) -> Option<RebuildingOverlay> {
let mut guard = self.inner.lock().ok()?;
if let Some(o) = guard.as_ref()
&& o.should_dismiss()
{
*guard = None;
return None;
}
guard.clone()
}
pub fn publish(&self, status: Status, detail: impl Into<String>) {
let detail = detail.into();
let expanded = matches!(status, Status::Failed | Status::Frozen);
let success_at = matches!(status, Status::Success).then(Instant::now);
self.set(Some(RebuildingOverlay {
status,
detail,
expanded,
success_at,
}));
}
}
pub fn is_overlay_id(id: &str) -> bool {
id == OVERLAY_PREFIX || id.starts_with(&format!("{OVERLAY_PREFIX}/"))
}
pub fn inject(tree: TreeNode, overlay: Option<&RebuildingOverlay>) -> TreeNode {
let Some(overlay) = overlay else {
return tree;
};
let overlay_node = build_overlay(overlay);
wrap_windows(tree, &overlay_node)
}
fn wrap_windows(mut node: TreeNode, overlay_node: &TreeNode) -> TreeNode {
if node.type_name == "window" {
let existing = std::mem::take(&mut node.children);
let wrapped = stack_with_overlay(existing, overlay_node.clone());
node.children = vec![wrapped];
return node;
}
node.children = node
.children
.into_iter()
.map(|c| wrap_windows(c, overlay_node))
.collect();
node
}
fn stack_with_overlay(window_children: Vec<TreeNode>, overlay_node: TreeNode) -> TreeNode {
let mut children = window_children;
children.push(overlay_node);
TreeNode {
id: format!("{OVERLAY_PREFIX}/stack"),
type_name: "stack".to_string(),
props: Default::default(),
children,
}
}
fn build_overlay(overlay: &RebuildingOverlay) -> TreeNode {
let bar = build_bar(overlay);
let mut column_children = vec![bar];
if overlay.expanded {
column_children.push(build_drawer(overlay));
}
let column = TreeNode {
id: format!("{OVERLAY_PREFIX}/column"),
type_name: "column".to_string(),
props: Default::default(),
children: column_children,
};
TreeNode {
id: format!("{OVERLAY_PREFIX}/anchor"),
type_name: "container".to_string(),
props: Default::default(),
children: vec![column],
}
}
fn build_bar(overlay: &RebuildingOverlay) -> TreeNode {
let icon_node = simple_text(
&format!("{OVERLAY_PREFIX}/icon"),
&format!("[{}]", overlay.status.icon()),
);
let status_node = simple_text(
&format!("{OVERLAY_PREFIX}/status"),
overlay.status.message(),
);
let mut row_children = vec![icon_node, status_node];
if !matches!(overlay.status, Status::Frozen) {
let toggle_label = if overlay.expanded { "^" } else { "v" };
row_children.insert(
0,
simple_button(&format!("{OVERLAY_PREFIX}/toggle"), toggle_label),
);
}
if matches!(overlay.status, Status::Failed | Status::Frozen) {
row_children.push(simple_button(&format!("{OVERLAY_PREFIX}/dismiss"), "x"));
}
let row = TreeNode {
id: format!("{OVERLAY_PREFIX}/bar_row"),
type_name: "row".to_string(),
props: Default::default(),
children: row_children,
};
TreeNode {
id: format!("{OVERLAY_PREFIX}/bar"),
type_name: "container".to_string(),
props: Default::default(),
children: vec![row],
}
}
fn build_drawer(overlay: &RebuildingOverlay) -> TreeNode {
let content = if overlay.detail.is_empty() {
"(waiting for output)".to_string()
} else {
overlay.detail.clone()
};
let text_node = simple_text(&format!("{OVERLAY_PREFIX}/output"), &content);
let scrollable = TreeNode {
id: format!("{OVERLAY_PREFIX}/scrollable"),
type_name: "scrollable".to_string(),
props: Default::default(),
children: vec![text_node],
};
TreeNode {
id: format!("{OVERLAY_PREFIX}/drawer"),
type_name: "container".to_string(),
props: Default::default(),
children: vec![scrollable],
}
}
fn simple_text(id: &str, content: &str) -> TreeNode {
TreeNode {
id: id.to_string(),
type_name: "text".to_string(),
props: plushie_core::protocol::Props::from_json(serde_json::json!({ "content": content })),
children: vec![],
}
}
fn simple_button(id: &str, label: &str) -> TreeNode {
TreeNode {
id: id.to_string(),
type_name: "button".to_string(),
props: plushie_core::protocol::Props::from_json(serde_json::json!({ "label": label })),
children: vec![],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_overlay_id_matches_prefix_and_sub_ids() {
assert!(is_overlay_id("__plushie_dev__"));
assert!(is_overlay_id("__plushie_dev__/bar"));
assert!(is_overlay_id("__plushie_dev__/bar/toggle"));
assert!(!is_overlay_id("__plushie_dev"));
assert!(!is_overlay_id("app/button"));
}
#[test]
fn status_message_and_icon_are_distinct() {
assert_ne!(Status::Rebuilding.message(), Status::Success.message());
assert_ne!(Status::Failed.icon(), Status::Rebuilding.icon());
}
#[test]
fn should_dismiss_only_for_expired_success() {
let mut overlay = RebuildingOverlay {
status: Status::Success,
detail: String::new(),
expanded: false,
success_at: Some(Instant::now() - Duration::from_secs(5)),
};
assert!(overlay.should_dismiss());
overlay.success_at = Some(Instant::now());
assert!(!overlay.should_dismiss());
overlay.status = Status::Failed;
overlay.success_at = Some(Instant::now() - Duration::from_secs(5));
assert!(!overlay.should_dismiss());
}
#[test]
fn handle_publishes_and_dismisses() {
let handle = DevOverlayHandle::new();
assert!(handle.snapshot().is_none());
handle.publish(Status::Rebuilding, "");
assert_eq!(handle.snapshot().unwrap().status, Status::Rebuilding);
handle.publish(Status::Success, "done");
assert_eq!(handle.snapshot().unwrap().status, Status::Success);
}
#[test]
fn inject_wraps_window_children_in_stack() {
let mut window = TreeNode {
id: "main".to_string(),
type_name: "window".to_string(),
props: Default::default(),
children: vec![],
};
window.children.push(simple_text("hello", "hi"));
let overlay = RebuildingOverlay {
status: Status::Rebuilding,
detail: String::new(),
expanded: false,
success_at: None,
};
let wrapped = inject(window, Some(&overlay));
assert_eq!(wrapped.type_name, "window");
assert_eq!(wrapped.children.len(), 1);
let stack = &wrapped.children[0];
assert_eq!(stack.type_name, "stack");
assert_eq!(stack.id, format!("{OVERLAY_PREFIX}/stack"));
assert_eq!(stack.children.len(), 2);
assert_eq!(stack.children[0].id, "hello");
assert_eq!(stack.children[1].id, format!("{OVERLAY_PREFIX}/anchor"));
}
#[test]
fn inject_none_returns_tree_unchanged() {
let window = TreeNode {
id: "main".to_string(),
type_name: "window".to_string(),
props: Default::default(),
children: vec![],
};
let wrapped = inject(window.clone(), None);
assert_eq!(wrapped.id, window.id);
assert!(wrapped.children.is_empty());
}
#[test]
fn build_overlay_failed_includes_dismiss_button() {
let overlay = RebuildingOverlay {
status: Status::Failed,
detail: "boom".to_string(),
expanded: true,
success_at: None,
};
let node = build_overlay(&overlay);
let mut ids = Vec::new();
collect_ids(&node, &mut ids);
assert!(
ids.iter()
.any(|i| i == &format!("{OVERLAY_PREFIX}/dismiss")),
"expected dismiss button, got ids: {ids:?}"
);
assert!(ids.iter().any(|i| i == &format!("{OVERLAY_PREFIX}/drawer")));
}
fn collect_ids(n: &TreeNode, out: &mut Vec<String>) {
out.push(n.id.clone());
for c in &n.children {
collect_ids(c, out);
}
}
}