fux 0.12.0

A minimal trusted Bevy terminal multiplexer
use bevy_ecs::{entity::MapEntities, prelude::*, reflect::ReflectMapEntities};
use bevy_reflect::{Reflect, ReflectDeserialize, ReflectSerialize, std_traits::ReflectDefault};
use bevy_ui::{FlexDirection, Node, Val};
use serde::{Deserialize, Serialize};

#[derive(Component, Reflect, Default, Clone)]
#[reflect(Component, Default)]
#[require(Node = root_node(), LayoutCache)]
pub struct Workspace;

/// Derived scene data belongs to its workspace and dies with that entity.
/// Not reflected: scene persistence never serializes caches.
#[derive(Component, Default)]
pub struct LayoutCache {
    pub scene: Option<std::sync::Arc<bevy_world_serialization::DynamicWorld>>,
    pub built_at: bevy_ecs::change_detection::Tick,
    /// Each member's component set, minus viewer bookkeeping (see `Shape`).
    pub members: bevy_ecs::entity::EntityHashMap<Shape>,
}

/// The components of a layout entity that are scene content. Viewer
/// relationship targets come and go with focus and must not count as edits.
pub type Shape = Box<[bevy_ecs::component::ComponentId]>;

pub(crate) fn shape(world: &World, entity: Entity) -> Shape {
    let ignored = [
        world.component_id::<Viewers>(),
        world.component_id::<TabViewers>(),
        world.component_id::<FocusedBy>(),
    ];
    let mut ids: Vec<_> = world
        .entity(entity)
        .archetype()
        .components()
        .iter()
        .copied()
        .filter(|id| !ignored.contains(&Some(*id)))
        .collect();
    ids.sort();
    ids.into_boxed_slice()
}

#[derive(Component, Reflect, Default, Clone)]
#[reflect(Component, Default)]
#[require(Node = split_node(FlexDirection::Row))]
pub struct Split;

/// A workspace's ordered children are tabs; processes remain external references.
#[derive(Component, Reflect, Default, Clone)]
#[reflect(Component, Default)]
#[require(Node = tab_node())]
pub struct Tab;

fn root_node() -> Node {
    Node {
        flex_basis: Val::ZERO,
        ..tab_node()
    }
}

pub(crate) fn tab_node() -> Node {
    Node {
        width: Val::Percent(100.0),
        height: Val::Percent(100.0),
        flex_basis: Val::Auto,
        ..leaf_node()
    }
}

fn leaf_node() -> Node {
    Node {
        flex_grow: 1.0,
        flex_basis: Val::ZERO,
        min_width: Val::ZERO,
        min_height: Val::ZERO,
        ..Default::default()
    }
}

/// A pane keeps the 2x2 backing minimum whatever its flex weight: shrinking
/// a sibling can never squeeze it to nothing.
fn pane_node() -> Node {
    Node {
        min_width: Val::Px(2.0),
        min_height: Val::Px(2.0),
        ..leaf_node()
    }
}

pub(crate) fn split_node(direction: FlexDirection) -> Node {
    Node {
        flex_basis: Val::ZERO,
        flex_direction: direction,
        column_gap: Val::Px(1.0),
        row_gap: Val::Px(1.0),
        // A container is at least as large as its panes' minimums, so a
        // viewer too small for them overflows at the end of the axis and
        // never lays a nested container over its neighbour.
        min_width: Val::Auto,
        min_height: Val::Auto,
        ..leaf_node()
    }
}

#[derive(Component, Reflect, Default, Clone, Copy)]
#[reflect(Component, Default)]
pub struct WorkspaceOrder(pub i64);

/// The workspace a viewer looks at. Bevy removes it when the workspace dies,
/// and `navigation::repair` then picks another.
#[derive(Component, Reflect, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Component, Serialize, Deserialize)]
#[serde(transparent)]
#[component(on_insert = crate::navigation::repair_later, on_remove = crate::navigation::repair_later)]
#[relationship(relationship_target = Viewers)]
pub struct Viewing(pub Entity);
/// Unreflected on purpose: it lives on layout entities and must stay out of scenes.
#[derive(Component, Default)]
#[relationship_target(relationship = Viewing)]
pub struct Viewers(Vec<Entity>);

/// The tab a viewer shows within its workspace.
#[derive(Component, Reflect, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Component, Serialize, Deserialize)]
#[serde(transparent)]
#[component(on_insert = crate::navigation::remember_tab, on_remove = crate::navigation::repair_later)]
#[relationship(relationship_target = TabViewers)]
pub struct OnTab(pub Entity);
#[derive(Component, Default)]
#[relationship_target(relationship = OnTab)]
pub struct TabViewers(Vec<Entity>);

/// The pane view a viewer's input goes to.
#[derive(Component, Reflect, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Component, Serialize, Deserialize)]
#[serde(transparent)]
#[component(on_insert = crate::navigation::remember_focus, on_remove = crate::navigation::repair_later)]
#[relationship(relationship_target = FocusedBy)]
pub struct Focused(pub Entity);
#[derive(Component, Default)]
#[relationship_target(relationship = Focused)]
pub struct FocusedBy(Vec<Entity>);

/// Viewer-local memory of where it was: the tab per workspace, the focused pane
/// per tab and the previously focused pane per tab. Hooks record relationship
/// changes; server observers prune entries as their layout entities die.
#[derive(Component, Default)]
pub struct Memory {
    pub tabs: bevy_ecs::entity::EntityHashMap<Entity>,
    pub focus: bevy_ecs::entity::EntityHashMap<Entity>,
    pub previous: bevy_ecs::entity::EntityHashMap<Entity>,
}

/// Reflected through serde so a partial BRP payload is a typed rejection
/// (`missing field`) rather than a reflect fallback; see `registry_audit`.
#[derive(Component, Reflect, Clone, Serialize, Deserialize)]
#[reflect(Component, Serialize, Deserialize)]
#[require(ProcessState)]
pub struct Launch {
    pub argv: Vec<String>,
    pub cwd: String,
    pub history_lines: usize,
}

/// One process lifecycle. A running process may carry its last I/O error
/// without leaving `Running`; only spawn and termination failures are `Failed`.
#[derive(Reflect, Clone, PartialEq, Default, Debug, Serialize, Deserialize)]
#[reflect(Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum Status {
    #[default]
    Starting,
    Running {
        pid: u32,
        error: Option<String>,
    },
    Exited {
        code: i32,
    },
    Failed {
        error: String,
    },
}

#[derive(Component, Reflect, Clone, PartialEq)]
#[reflect(Component, Default)]
pub struct ProcessState {
    pub status: Status,
    pub rows: u16,
    pub cols: u16,
    pub revision: u64,
}

impl Default for ProcessState {
    fn default() -> Self {
        Self {
            status: Status::Starting,
            rows: 24,
            cols: 80,
            revision: 0,
        }
    }
}

/// A layout leaf refers to a live process, not a serialized process recipe.
#[derive(Component, Reflect, Clone, MapEntities, Serialize, Deserialize)]
#[reflect(Component, MapEntities, Serialize, Deserialize)]
#[require(Node = pane_node())]
#[relationship(relationship_target = PaneViews)]
pub struct PaneView {
    #[entities]
    pub pane: Entity,
}

/// Bevy maintains the inverse relation; no independent pane-view index.
#[derive(Component, Reflect, Default)]
#[reflect(Component, Default)]
#[relationship_target(relationship = PaneView)]
pub struct PaneViews(Vec<Entity>);

/// The components that make an entity a layout node. A layout node is never a
/// viewer: `navigation::reject_viewer_on_layout` removes a `Viewer` inserted
/// onto one, whichever arrives first. `LayoutRole` and `NotLayout` below name
/// the same four components and must change together with this bundle.
pub type LayoutNode = (Workspace, Tab, Split, PaneView);
/// Matches an entity carrying any `LayoutNode` component.
pub type LayoutRole = Or<(With<Workspace>, With<Tab>, With<Split>, With<PaneView>)>;
/// Matches an entity carrying no `LayoutNode` component.
pub type NotLayout = (
    Without<Workspace>,
    Without<Tab>,
    Without<Split>,
    Without<PaneView>,
);
/// A genuine viewer. Every pass that treats viewers as viewers selects these,
/// so a `Viewer` on a layout node is never repaired or painted, even in the
/// moment before normalization removes it.
pub type IsViewer = (With<Viewer>, NotLayout);

#[derive(Component, Reflect, Clone, Serialize, Deserialize)]
#[reflect(Component, Serialize, Deserialize)]
#[require(Memory, crate::paste::Ownership)]
pub struct Viewer {
    pub rows: u16,
    pub cols: u16,
    pub zoom: bool,
    pub scrollback: usize,
    pub notice: Option<Notice>,
}

impl Viewer {
    pub(crate) fn reset_view(&mut self) {
        self.zoom = false;
        self.scrollback = 0;
    }
}

/// The bar's right zone shows the latest notice until further input clears it.
#[derive(Reflect, Clone, PartialEq, Debug, Serialize, Deserialize)]
#[reflect(Serialize, Deserialize)]
pub struct Notice {
    pub text: String,
    pub error: bool,
}
impl Notice {
    pub fn info(text: impl Into<String>) -> Option<Self> {
        Some(Self {
            text: text.into(),
            error: false,
        })
    }
    pub fn error(text: impl Into<String>) -> Option<Self> {
        Some(Self {
            text: text.into(),
            error: true,
        })
    }
}

pub const DETACHED: &str = "viewer no longer attached";

/// The workspace, tab and pane view a viewer currently relates to.
pub(crate) fn viewing(world: &World, id: Entity) -> Option<Entity> {
    world.get::<Viewing>(id).map(|v| v.0)
}
pub(crate) fn on_tab(world: &World, id: Entity) -> Option<Entity> {
    world.get::<OnTab>(id).map(|t| t.0)
}
pub(crate) fn focused(world: &World, id: Entity) -> Option<Entity> {
    world.get::<Focused>(id).map(|f| f.0)
}

/// Notify a viewer that may already be gone; a missing viewer has nobody to tell.
/// `None` clears the bar.
pub(crate) fn notify(world: &mut World, id: Entity, notice: Option<Notice>) {
    if let Some(mut v) = world.get_mut::<Viewer>(id) {
        v.notice = notice;
    }
}

#[derive(Resource, Clone)]
pub struct Wake(pub std::thread::Thread);

impl Wake {
    pub fn notify(&self) {
        self.0.unpark();
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testing::*;

    #[test]
    fn required_layout_nodes_preserve_defaults_and_explicit_overrides() -> Outcome {
        let mut world = World::new();
        let pane = world.spawn_empty().id();
        let root = world.spawn(Workspace).id();
        let tab = world.spawn(Tab).id();
        let leaf = world.spawn(PaneView { pane }).id();
        let row = world.spawn(Split).id();
        assert_eq!(*world.get::<Node>(root).need()?, root_node());
        assert_eq!(*world.get::<Node>(tab).need()?, tab_node());
        assert_eq!(*world.get::<Node>(leaf).need()?, pane_node());
        assert_eq!(pane_node().min_height, Val::Px(2.0));
        assert_eq!(
            *world.get::<Node>(row).need()?,
            split_node(FlexDirection::Row)
        );
        let column = split_node(FlexDirection::Column);
        let custom = Node {
            width: Val::Px(37.0),
            padding: bevy_ui::UiRect::all(Val::Px(3.0)),
            ..column.clone()
        };
        let split = world.spawn((Split, column.clone())).id();
        let explicit = world.spawn((custom.clone(), Tab)).id();
        assert_eq!(*world.get::<Node>(split).need()?, column);
        world.entity_mut(explicit).insert(Split);
        assert_eq!(*world.get::<Node>(explicit).need()?, custom);
        world.entity_mut(explicit).remove::<Node>();
        assert!(world.get::<Node>(explicit).is_none());
        Ok(())
    }

    #[test]
    fn layout_relationships_do_not_own_shared_processes() -> crate::testing::Outcome {
        let mut world = World::new();
        let process = world
            .spawn(Launch {
                argv: vec!["/bin/sh".into()],
                cwd: String::new(),
                history_lines: 0,
            })
            .id();
        let left = world.spawn(Workspace).id();
        let right = world.spawn(Workspace).id();
        let removed = world
            .spawn((PaneView { pane: process }, ChildOf(left)))
            .id();
        let retained = world
            .spawn((PaneView { pane: process }, ChildOf(right)))
            .id();
        world.despawn(left);
        assert!(world.get_entity(removed).is_err());
        assert_eq!(world.get::<PaneView>(retained).need()?.pane, process);
        assert!(world.get::<Launch>(process).is_some());
        world.despawn(right);
        assert!(world.get::<Launch>(process).is_some());
        Ok(())
    }
}