Skip to main content

sim_lib_web_layout/
layout.rs

1//! The layout engine: layout operations over the workspace value.
2//!
3//! Layout operations create, move, resize, dock, undock, and close panes. Each
4//! produces a new workspace value; nothing is mutated in place, so the result is
5//! still a round-trippable value. Operations arrive either directly (as a
6//! [`LayoutOp`]) or as Intents the bridge has already validated.
7
8use sim_kernel::{Error, Expr, Result, Symbol};
9use sim_lib_intent::{field, intent_kind_of};
10
11use crate::pane::{new_pane, pane_id};
12use crate::value::{get, int, set, with_panes};
13
14/// A layout command over the workspace.
15#[derive(Clone, Debug)]
16pub enum LayoutOp {
17    /// Open a resource into a new pane.
18    Open {
19        /// New pane id.
20        id: Symbol,
21        /// The resource value to show.
22        resource: Expr,
23        /// The initial lens id.
24        lens: Symbol,
25        /// The dock to place it in.
26        dock: Symbol,
27    },
28    /// Close a pane.
29    Close {
30        /// The pane to remove.
31        id: Symbol,
32    },
33    /// Move a pane's top-left corner.
34    Move {
35        /// The pane to move.
36        id: Symbol,
37        /// New x.
38        x: i64,
39        /// New y.
40        y: i64,
41    },
42    /// Resize a pane.
43    Resize {
44        /// The pane to resize.
45        id: Symbol,
46        /// New width.
47        w: i64,
48        /// New height.
49        h: i64,
50    },
51    /// Dock a pane to a region.
52    Dock {
53        /// The pane to dock.
54        id: Symbol,
55        /// The dock region symbol.
56        dock: Symbol,
57    },
58    /// Undock a pane (float it).
59    Undock {
60        /// The pane to float.
61        id: Symbol,
62    },
63}
64
65/// Apply a layout operation, returning the updated workspace value.
66pub fn apply_layout_op(workspace: &Expr, op: &LayoutOp) -> Result<Expr> {
67    match op {
68        LayoutOp::Open {
69            id,
70            resource,
71            lens,
72            dock,
73        } => {
74            if find_pane(workspace, id).is_some() {
75                return Err(Error::HostError(format!("pane '{id}' already exists")));
76            }
77            let pane = new_pane(&id.name, resource.clone(), &lens.name, &dock.name);
78            let mut panes = crate::value::panes(workspace);
79            panes.push(pane);
80            Ok(set(
81                &with_panes(workspace, panes),
82                "focus",
83                Expr::Symbol(id.clone()),
84            ))
85        }
86        LayoutOp::Close { id } => {
87            let panes = crate::value::panes(workspace)
88                .into_iter()
89                .filter(|pane| pane_id(pane).as_ref() != Some(id))
90                .collect();
91            let updated = with_panes(workspace, panes);
92            let focus_cleared = match get(&updated, "focus") {
93                Some(Expr::Symbol(focus)) if focus == id => set(&updated, "focus", Expr::Nil),
94                _ => updated,
95            };
96            Ok(focus_cleared)
97        }
98        LayoutOp::Move { id, x, y } => update_pane(workspace, id, |pane| {
99            let rect = set(get(pane, "rect").unwrap_or(&Expr::Nil), "x", int(*x));
100            let rect = set(&rect, "y", int(*y));
101            set(pane, "rect", rect)
102        }),
103        LayoutOp::Resize { id, w, h } => update_pane(workspace, id, |pane| {
104            let rect = set(get(pane, "rect").unwrap_or(&Expr::Nil), "w", int(*w));
105            let rect = set(&rect, "h", int(*h));
106            set(pane, "rect", rect)
107        }),
108        LayoutOp::Dock { id, dock } => update_pane(workspace, id, |pane| {
109            set(pane, "dock", Expr::Symbol(dock.clone()))
110        }),
111        LayoutOp::Undock { id } => update_pane(workspace, id, |pane| {
112            set(pane, "dock", Expr::Symbol(Symbol::new("float")))
113        }),
114    }
115}
116
117fn find_pane(workspace: &Expr, id: &Symbol) -> Option<Expr> {
118    crate::value::panes(workspace)
119        .into_iter()
120        .find(|pane| pane_id(pane).as_ref() == Some(id))
121}
122
123fn update_pane(workspace: &Expr, id: &Symbol, edit: impl Fn(&Expr) -> Expr) -> Result<Expr> {
124    let mut found = false;
125    let panes = crate::value::panes(workspace)
126        .iter()
127        .map(|pane| {
128            if pane_id(pane).as_ref() == Some(id) {
129                found = true;
130                edit(pane)
131            } else {
132                pane.clone()
133            }
134        })
135        .collect();
136    if !found {
137        return Err(Error::HostError(format!("no pane '{id}' to update")));
138    }
139    Ok(with_panes(workspace, panes))
140}
141
142/// Translate a validated layout Intent into a [`LayoutOp`], if it is one.
143///
144/// `intent/open` opens a resource into a pane; `intent/invoke` with a layout
145/// `op` (close/move/resize/dock/undock) carries the remaining ops. Returns
146/// `Ok(None)` for an Intent that is not a layout command.
147pub fn layout_op_from_intent(intent: &Expr) -> Result<Option<LayoutOp>> {
148    let Some(kind) = intent_kind_of(intent) else {
149        return Ok(None);
150    };
151    match &*kind.name {
152        "open" => {
153            let resource = field(intent, "value").cloned().unwrap_or(Expr::Nil);
154            let id = require_symbol(intent, "pane")?;
155            Ok(Some(LayoutOp::Open {
156                id,
157                resource,
158                lens: Symbol::new("view:default"),
159                dock: Symbol::new("center"),
160            }))
161        }
162        "invoke" => layout_op_from_invoke(intent),
163        _ => Ok(None),
164    }
165}
166
167fn layout_op_from_invoke(intent: &Expr) -> Result<Option<LayoutOp>> {
168    let op = match field(intent, "op") {
169        Some(Expr::Symbol(symbol)) => symbol.name.to_string(),
170        _ => return Ok(None),
171    };
172    let id = require_symbol(intent, "target")?;
173    let args = field(intent, "args");
174    let arg_int = |name: &str| {
175        args.and_then(|args| get(args, name))
176            .and_then(crate::value::as_int)
177    };
178    match op.as_str() {
179        "close" => Ok(Some(LayoutOp::Close { id })),
180        "undock" => Ok(Some(LayoutOp::Undock { id })),
181        "dock" => {
182            let dock = match args.and_then(|args| get(args, "dock")) {
183                Some(Expr::Symbol(symbol)) => symbol.clone(),
184                _ => Symbol::new("center"),
185            };
186            Ok(Some(LayoutOp::Dock { id, dock }))
187        }
188        "move" => Ok(Some(LayoutOp::Move {
189            id,
190            x: arg_int("x").unwrap_or(0),
191            y: arg_int("y").unwrap_or(0),
192        })),
193        "resize" => Ok(Some(LayoutOp::Resize {
194            id,
195            w: arg_int("w").unwrap_or(0),
196            h: arg_int("h").unwrap_or(0),
197        })),
198        _ => Ok(None),
199    }
200}
201
202fn require_symbol(intent: &Expr, name: &str) -> Result<Symbol> {
203    match field(intent, name) {
204        Some(Expr::Symbol(symbol)) => Ok(symbol.clone()),
205        _ => Err(Error::HostError(format!(
206            "layout intent field '{name}' must be a symbol"
207        ))),
208    }
209}