Skip to main content

rmux_sdk/
layout.rs

1//! Declarative pane layout builders.
2//!
3//! The layout builder is SDK-side composition. It creates panes through the
4//! public split/spawn surfaces, asks the daemon to spread the resulting tree,
5//! and returns stable [`PaneSet`] handles in declaration order.
6
7use std::path::PathBuf;
8
9use crate::handles::session::unexpected_response;
10use crate::{
11    Pane, PaneSet, ProcessCommandSpec, Result, RmuxError, Session, SplitDirection, WindowRef,
12};
13use rmux_proto::{Request, Response, SelectLayoutTarget, SpreadLayoutRequest};
14
15/// Entry point returned by [`Session::layout`].
16#[derive(Debug)]
17pub struct SessionLayoutBuilder<'a> {
18    session: &'a Session,
19    window_index: u32,
20}
21
22impl<'a> SessionLayoutBuilder<'a> {
23    pub(crate) const fn new(session: &'a Session) -> Self {
24        Self {
25            session,
26            window_index: 0,
27        }
28    }
29
30    /// Selects the window index that will receive the layout.
31    ///
32    /// This layout API mutates one existing window. The target window must
33    /// already exist and must contain exactly one pane when [`GridLayoutBuilder::apply`]
34    /// is called.
35    #[must_use]
36    pub const fn window(mut self, window_index: u32) -> Self {
37        self.window_index = window_index;
38        self
39    }
40
41    /// Starts a grid layout.
42    ///
43    /// The first argument is the maximum number of columns per row, matching
44    /// the grid example `grid(3, 2)` for "three panes on top, two
45    /// panes below". The second argument is the maximum number of rows.
46    #[must_use]
47    pub const fn grid(self, columns: usize, rows: usize) -> GridLayoutBuilder<'a> {
48        GridLayoutBuilder {
49            session: self.session,
50            window_index: self.window_index,
51            columns,
52            rows,
53            replace_existing_root_process: true,
54            replace_existing_panes: false,
55            panes: Vec::new(),
56        }
57    }
58}
59
60/// Builder for a single-window pane grid.
61#[derive(Debug)]
62pub struct GridLayoutBuilder<'a> {
63    session: &'a Session,
64    window_index: u32,
65    columns: usize,
66    rows: usize,
67    replace_existing_root_process: bool,
68    replace_existing_panes: bool,
69    panes: Vec<LayoutPaneSpec>,
70}
71
72impl<'a> GridLayoutBuilder<'a> {
73    /// Controls whether the first pane spec may replace the existing root
74    /// pane process.
75    ///
76    /// The default is `true` because newly-created app sessions already have
77    /// a placeholder shell in pane `0`; a command on the first declared pane
78    /// is expected to replace that placeholder. Set this to `false` when
79    /// applying a layout to an existing session should surface
80    /// [`RmuxError::ProcessStillRunning`] instead of replacing the root
81    /// process.
82    #[must_use]
83    pub const fn replace_existing_root_process(mut self, replace: bool) -> Self {
84        self.replace_existing_root_process = replace;
85        self
86    }
87
88    /// Allows applying the grid to a window that already contains multiple
89    /// panes by closing every pane except the first listed pane before
90    /// creating the requested layout.
91    ///
92    /// The default is `false`, which keeps the builder conservative on
93    /// existing workspaces. When enabled, cleanup happens before any new split
94    /// is created; if cleanup fails, the builder returns that error without
95    /// attempting a partial layout.
96    #[must_use]
97    pub const fn replace_existing_panes(mut self, replace: bool) -> Self {
98        self.replace_existing_panes = replace;
99        self
100    }
101
102    /// Adds one pane declaration with a UX title label.
103    ///
104    /// Titles remain labels only; the returned handles are addressed by
105    /// stable pane id after the layout is applied.
106    #[must_use]
107    pub fn pane(self, title: impl Into<String>) -> LayoutPaneBuilder<'a> {
108        LayoutPaneBuilder {
109            builder: self,
110            spec: LayoutPaneSpec::new(title.into()),
111        }
112    }
113
114    /// Applies the grid and returns stable pane handles in declaration order.
115    pub async fn apply(self) -> Result<PaneSet> {
116        apply_grid(self).await
117    }
118}
119
120/// Builder for the pane most recently declared with
121/// [`GridLayoutBuilder::pane`].
122#[derive(Debug)]
123pub struct LayoutPaneBuilder<'a> {
124    builder: GridLayoutBuilder<'a>,
125    spec: LayoutPaneSpec,
126}
127
128impl<'a> LayoutPaneBuilder<'a> {
129    /// Runs the pane process directly as structured argv.
130    #[must_use]
131    pub fn spawn<I, S>(mut self, command: I) -> Self
132    where
133        I: IntoIterator<Item = S>,
134        S: Into<String>,
135    {
136        self.spec.command = Some(ProcessCommandSpec::Argv(
137            command.into_iter().map(Into::into).collect(),
138        ));
139        self
140    }
141
142    /// Runs pane command text through the configured shell.
143    #[must_use]
144    pub fn shell(mut self, command: impl Into<String>) -> Self {
145        self.spec.command = Some(ProcessCommandSpec::Shell(command.into()));
146        self
147    }
148
149    /// Sets the process working directory for this pane.
150    #[must_use]
151    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
152        self.spec.cwd = Some(cwd.into());
153        self
154    }
155
156    /// Adds one environment override for this pane process.
157    #[must_use]
158    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
159        self.spec.env.push((key.into(), value.into()));
160        self
161    }
162
163    /// Controls whether this pane remains visible after its process exits.
164    #[must_use]
165    pub const fn keep_alive_on_exit(mut self, keep_alive: bool) -> Self {
166        self.spec.keep_alive_on_exit = Some(keep_alive);
167        self
168    }
169
170    /// Starts another pane declaration after finalizing this one.
171    #[must_use]
172    pub fn pane(self, title: impl Into<String>) -> Self {
173        self.finish().pane(title)
174    }
175
176    /// Applies the grid after finalizing this pane declaration.
177    pub async fn apply(self) -> Result<PaneSet> {
178        self.finish().apply().await
179    }
180
181    fn finish(mut self) -> GridLayoutBuilder<'a> {
182        self.builder.panes.push(self.spec);
183        self.builder
184    }
185}
186
187#[derive(Debug, Clone)]
188struct LayoutPaneSpec {
189    title: String,
190    command: Option<ProcessCommandSpec>,
191    cwd: Option<PathBuf>,
192    env: Vec<(String, String)>,
193    keep_alive_on_exit: Option<bool>,
194}
195
196impl LayoutPaneSpec {
197    fn new(title: String) -> Self {
198        Self {
199            title,
200            command: None,
201            cwd: None,
202            env: Vec::new(),
203            keep_alive_on_exit: None,
204        }
205    }
206}
207
208async fn apply_grid(builder: GridLayoutBuilder<'_>) -> Result<PaneSet> {
209    let mut created_panes = Vec::new();
210    let result = apply_grid_inner(builder, &mut created_panes).await;
211    if result.is_err() {
212        rollback_created_panes(created_panes).await;
213    }
214    result
215}
216
217async fn apply_grid_inner(
218    builder: GridLayoutBuilder<'_>,
219    created_panes: &mut Vec<Pane>,
220) -> Result<PaneSet> {
221    let capacity = validate_grid(builder.columns, builder.rows)?;
222    validate_pane_count(builder.panes.len(), capacity)?;
223
224    let window = builder.session.window(builder.window_index);
225    let mut existing = window.panes().await?;
226    if existing.len() != 1 && builder.replace_existing_panes {
227        close_extra_panes(builder.session, &existing).await?;
228        existing = window.panes().await?;
229    }
230    if existing.len() != 1 {
231        return Err(layout_error(format!(
232            "layout builder expects exactly one existing pane in window {}; found {}. \
233             Use replace_existing_panes(true) to close extras first",
234            builder.window_index,
235            existing.len()
236        )));
237    }
238
239    let root_target = &existing[0].target;
240    let root = builder
241        .session
242        .pane(root_target.window_index, root_target.pane_index);
243    let mut panes = vec![None; builder.panes.len()];
244
245    let root_pane = configure_existing_root(
246        builder.session,
247        root,
248        &builder.panes[0],
249        builder.replace_existing_root_process,
250    )
251    .await?;
252    panes[0] = Some(root_pane.clone());
253
254    let row_count = row_count(builder.panes.len(), builder.columns);
255    let mut row_anchors = Vec::with_capacity(row_count);
256    row_anchors.push(root_pane);
257
258    for row in 1..row_count {
259        let spec_index = row * builder.columns;
260        let anchor = row_anchors[row - 1].clone();
261        let pane = split_new_pane(
262            builder.session,
263            &anchor,
264            SplitDirection::Down,
265            &builder.panes[spec_index],
266        )
267        .await?;
268        panes[spec_index] = Some(pane.clone());
269        created_panes.push(pane.clone());
270        row_anchors.push(pane);
271    }
272
273    for (row, row_anchor) in row_anchors.iter().enumerate() {
274        let row_start = row * builder.columns;
275        let row_end = usize::min(row_start + builder.columns, builder.panes.len());
276        let mut previous = row_anchor.clone();
277        for (spec_index, slot) in panes
278            .iter_mut()
279            .enumerate()
280            .take(row_end)
281            .skip(row_start + 1)
282        {
283            let pane = split_new_pane(
284                builder.session,
285                &previous,
286                SplitDirection::Right,
287                &builder.panes[spec_index],
288            )
289            .await?;
290            *slot = Some(pane.clone());
291            created_panes.push(pane.clone());
292            previous = pane;
293        }
294    }
295
296    spread_window(builder.session, builder.window_index).await?;
297    Ok(PaneSet::new(
298        panes
299            .into_iter()
300            .map(|pane| pane.expect("every declared pane is created"))
301            .collect::<Vec<_>>(),
302    ))
303}
304
305async fn rollback_created_panes(mut panes: Vec<Pane>) {
306    while let Some(pane) = panes.pop() {
307        let _ = pane.close().await;
308    }
309}
310
311async fn close_extra_panes(session: &Session, panes: &[crate::WindowPane]) -> Result<()> {
312    for pane in panes.iter().skip(1).rev() {
313        session.pane_by_id(pane.id).await?.close().await?;
314    }
315    Ok(())
316}
317
318async fn configure_existing_root(
319    session: &Session,
320    pane: Pane,
321    spec: &LayoutPaneSpec,
322    replace_existing: bool,
323) -> Result<Pane> {
324    match spec.command.clone() {
325        Some(ProcessCommandSpec::Argv(argv)) => {
326            let mut spawn = pane.spawn(argv).kill_existing(replace_existing);
327            spawn = apply_spawn_options(spawn, spec);
328            spawn.await?;
329        }
330        Some(ProcessCommandSpec::Shell(command)) => {
331            let mut spawn = pane.shell(command).kill_existing(replace_existing);
332            spawn = apply_spawn_options(spawn, spec);
333            spawn.await?;
334        }
335        None => {
336            validate_existing_root_options(spec)?;
337            pane.set_title(spec.title.clone()).await?;
338        }
339    }
340
341    stable_pane(session, &pane).await
342}
343
344fn apply_spawn_options<'a>(
345    mut spawn: crate::PaneSpawnBuilder<'a>,
346    spec: &LayoutPaneSpec,
347) -> crate::PaneSpawnBuilder<'a> {
348    if let Some(cwd) = spec.cwd.clone() {
349        spawn = spawn.cwd(cwd);
350    }
351    for (key, value) in &spec.env {
352        spawn = spawn.env(key.clone(), value.clone());
353    }
354    if let Some(keep_alive) = spec.keep_alive_on_exit {
355        spawn = spawn.keep_alive_on_exit(keep_alive);
356    }
357    spawn.title(spec.title.clone())
358}
359
360async fn split_new_pane(
361    session: &Session,
362    anchor: &Pane,
363    direction: SplitDirection,
364    spec: &LayoutPaneSpec,
365) -> Result<Pane> {
366    let mut split = anchor.split_with(direction);
367    if let Some(cwd) = spec.cwd.clone() {
368        split = split.cwd(cwd);
369    }
370    for (key, value) in &spec.env {
371        split = split.env(key.clone(), value.clone());
372    }
373    if let Some(keep_alive) = spec.keep_alive_on_exit {
374        split = split.keep_alive_on_exit(keep_alive);
375    }
376    split = match spec.command.clone() {
377        Some(ProcessCommandSpec::Argv(argv)) => split.spawn(argv),
378        Some(ProcessCommandSpec::Shell(command)) => split.shell(command),
379        None => split,
380    };
381    split = split.title(spec.title.clone());
382
383    let pane = split.await?;
384    stable_pane(session, &pane).await
385}
386
387async fn stable_pane(session: &Session, pane: &Pane) -> Result<Pane> {
388    let pane_id = pane
389        .id()
390        .await?
391        .ok_or_else(|| layout_error("created pane vanished before its id could be read"))?;
392    session.pane_by_id(pane_id).await
393}
394
395async fn spread_window(session: &Session, window_index: u32) -> Result<()> {
396    let target = WindowRef::new(session.name().clone(), window_index);
397    match session
398        .transport()
399        .request(Request::SpreadLayout(SpreadLayoutRequest {
400            target: SelectLayoutTarget::Window(target.to_proto()),
401        }))
402        .await?
403    {
404        Response::SelectLayout(_) => Ok(()),
405        response => Err(unexpected_response("select-layout -E", response)),
406    }
407}
408
409fn validate_existing_root_options(spec: &LayoutPaneSpec) -> Result<()> {
410    if spec.cwd.is_some() || !spec.env.is_empty() || spec.keep_alive_on_exit.is_some() {
411        return Err(layout_error(
412            "cwd, env, and keep_alive_on_exit on the existing root pane require spawn() or shell()",
413        ));
414    }
415    Ok(())
416}
417
418fn validate_grid(columns: usize, rows: usize) -> Result<usize> {
419    if columns == 0 || rows == 0 {
420        return Err(layout_error(
421            "grid columns and rows must be greater than zero",
422        ));
423    }
424    columns
425        .checked_mul(rows)
426        .ok_or_else(|| layout_error("grid dimensions overflow usize"))
427}
428
429fn validate_pane_count(count: usize, capacity: usize) -> Result<()> {
430    if count == 0 {
431        return Err(layout_error("layout must declare at least one pane"));
432    }
433    if count > capacity {
434        return Err(layout_error(format!(
435            "layout declares {count} panes but grid capacity is {capacity}"
436        )));
437    }
438    Ok(())
439}
440
441fn row_count(pane_count: usize, columns: usize) -> usize {
442    ((pane_count - 1) / columns) + 1
443}
444
445fn layout_error(message: impl Into<String>) -> RmuxError {
446    RmuxError::protocol(rmux_proto::RmuxError::Server(format!(
447        "invalid layout builder request: {}",
448        message.into()
449    )))
450}