use std::collections::HashMap;
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
use serde::Deserialize;
use crate::{TmuxClient, TmuxError};
#[derive(Debug, Clone, Deserialize)]
pub struct Layout {
pub name: String,
pub root: Slot,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Slot {
pub role: SlotRole,
pub size: String,
pub direction: SplitDirection,
#[serde(default)]
pub children: Vec<Slot>,
#[serde(default = "default_count")]
pub count: u8,
}
fn default_count() -> u8 {
1
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SlotRole {
Agent,
Editor,
Shell,
AtmPanel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SplitDirection {
Horizontal,
Vertical,
}
#[derive(Debug, Clone)]
pub enum LayoutTarget {
CurrentPane(String),
NewWindow(Option<String>),
NewSession(String),
}
#[derive(Debug, Clone)]
pub struct LayoutResult {
pub panes: HashMap<SlotRole, Vec<String>>,
}
#[derive(Debug, thiserror::Error)]
pub enum LayoutConfigError {
#[error("failed to parse layout TOML: {0}")]
Parse(String),
#[error("layout not found: {0}")]
NotFound(String),
#[error("failed to read config file: {0}")]
Io(#[from] std::io::Error),
}
pub fn parse_layout(toml_str: &str) -> Result<Layout, LayoutConfigError> {
#[derive(Deserialize)]
struct Wrapper {
layout: Layout,
}
let wrapper: Wrapper =
toml::from_str(toml_str).map_err(|e| LayoutConfigError::Parse(e.to_string()))?;
Ok(wrapper.layout)
}
pub fn preset_solo() -> Layout {
Layout {
name: "solo".to_string(),
root: Slot {
role: SlotRole::Agent,
size: "100%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
}
}
pub fn preset_pair() -> Layout {
Layout {
name: "pair".to_string(),
root: Slot {
role: SlotRole::Shell,
size: "100%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![
Slot {
role: SlotRole::Shell,
size: "75%".to_string(),
direction: SplitDirection::Vertical,
children: vec![
Slot {
role: SlotRole::Agent,
size: "50%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
Slot {
role: SlotRole::Agent,
size: "50%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
],
count: 1,
},
Slot {
role: SlotRole::AtmPanel,
size: "25%".to_string(),
direction: SplitDirection::Vertical,
children: vec![],
count: 1,
},
],
count: 1,
},
}
}
pub fn preset_squad() -> Layout {
Layout {
name: "squad".to_string(),
root: Slot {
role: SlotRole::Shell,
size: "100%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![
Slot {
role: SlotRole::Shell,
size: "75%".to_string(),
direction: SplitDirection::Vertical,
children: vec![
Slot {
role: SlotRole::Shell,
size: "66%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![
Slot {
role: SlotRole::Agent,
size: "50%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
Slot {
role: SlotRole::Agent,
size: "50%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
],
count: 1,
},
Slot {
role: SlotRole::Agent,
size: "33%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
],
count: 1,
},
Slot {
role: SlotRole::AtmPanel,
size: "25%".to_string(),
direction: SplitDirection::Vertical,
children: vec![],
count: 1,
},
],
count: 1,
},
}
}
pub fn preset_grid() -> Layout {
Layout {
name: "grid".to_string(),
root: Slot {
role: SlotRole::Shell,
size: "100%".to_string(),
direction: SplitDirection::Vertical,
children: vec![
Slot {
role: SlotRole::Shell,
size: "50%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![
Slot {
role: SlotRole::Agent,
size: "50%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
Slot {
role: SlotRole::Agent,
size: "50%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
],
count: 1,
},
Slot {
role: SlotRole::Shell,
size: "50%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![
Slot {
role: SlotRole::Agent,
size: "50%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
Slot {
role: SlotRole::Agent,
size: "50%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
],
count: 1,
},
],
count: 1,
},
}
}
pub fn preset_workspace() -> Layout {
Layout {
name: "workspace".to_string(),
root: Slot {
role: SlotRole::Shell,
size: "100%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![
Slot {
role: SlotRole::Shell,
size: "100%".to_string(),
direction: SplitDirection::Vertical,
children: vec![
Slot {
role: SlotRole::Agent,
size: "80%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
Slot {
role: SlotRole::Shell,
size: "20%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
],
count: 1,
},
Slot {
role: SlotRole::AtmPanel,
size: "30".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
],
count: 1,
},
}
}
pub fn preset_workspace_editor() -> Layout {
Layout {
name: "workspace-editor".to_string(),
root: Slot {
role: SlotRole::Shell,
size: "100%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![
Slot {
role: SlotRole::Shell,
size: "100%".to_string(),
direction: SplitDirection::Vertical,
children: vec![
Slot {
role: SlotRole::Shell,
size: "80%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![
Slot {
role: SlotRole::Editor,
size: "50%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
Slot {
role: SlotRole::Agent,
size: "50%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
],
count: 1,
},
Slot {
role: SlotRole::Shell,
size: "20%".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
],
count: 1,
},
Slot {
role: SlotRole::AtmPanel,
size: "30".to_string(),
direction: SplitDirection::Horizontal,
children: vec![],
count: 1,
},
],
count: 1,
},
}
}
pub fn preset_by_name(name: &str) -> Option<Layout> {
match name {
"solo" => Some(preset_solo()),
"pair" => Some(preset_pair()),
"squad" => Some(preset_squad()),
"grid" => Some(preset_grid()),
"workspace" => Some(preset_workspace()),
"workspace-editor" => Some(preset_workspace_editor()),
_ => None,
}
}
pub async fn apply_layout(
client: &(dyn TmuxClient + Send + Sync),
layout: &Layout,
target: LayoutTarget,
) -> Result<LayoutResult, TmuxError> {
let root_pane = match &target {
LayoutTarget::CurrentPane(pane_id) => pane_id.clone(),
LayoutTarget::NewWindow(_name) => {
let current_pane = std::env::var("TMUX_PANE").unwrap_or_default();
let session = if !current_pane.is_empty() {
let panes = client.list_panes().await.unwrap_or_default();
panes
.iter()
.find(|p| p.pane_id == current_pane)
.map(|p| p.session_name.clone())
.unwrap_or_else(|| "0".to_string())
} else {
"0".to_string()
};
client.new_window(&session, None).await?
}
LayoutTarget::NewSession(name) => client.new_session(name).await?,
};
let mut result = LayoutResult {
panes: HashMap::new(),
};
apply_slot(client, &layout.root, &root_pane, &mut result).await?;
Ok(result)
}
fn apply_slot<'a>(
client: &'a (dyn TmuxClient + Send + Sync),
slot: &'a Slot,
pane_id: &'a str,
result: &'a mut LayoutResult,
) -> Pin<Box<dyn Future<Output = Result<(), TmuxError>> + Send + 'a>> {
Box::pin(async move {
if slot.children.is_empty() {
result
.panes
.entry(slot.role)
.or_default()
.push(pane_id.to_string());
return Ok(());
}
let first = slot
.children
.first()
.ok_or_else(|| TmuxError::ParseError("container slot has no children".to_string()))?;
apply_slot(client, first, pane_id, result).await?;
for child in slot.children.iter().skip(1) {
let direction = match slot.direction {
SplitDirection::Vertical => crate::PaneDirection::Below,
SplitDirection::Horizontal => crate::PaneDirection::Right,
};
let new_pane = client
.split_window(pane_id, &child.size, direction, None)
.await?;
apply_slot(client, child, &new_pane, result).await?;
}
Ok(())
})
}
pub fn load_layout(name: &str, project_root: Option<&Path>) -> Result<Layout, LayoutConfigError> {
if let Some(root) = project_root {
let config_path = root.join(".atm").join("layout.toml");
if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
let layout = parse_layout(&content)?;
if layout.name == name {
return Ok(layout);
}
}
}
if let Some(config_dir) = dirs::config_dir() {
let global_path = config_dir.join("atm").join("config.toml");
if global_path.exists() {
let content = std::fs::read_to_string(&global_path)?;
if let Ok(layout) = parse_layout(&content) {
if layout.name == name {
return Ok(layout);
}
}
}
}
preset_by_name(name).ok_or_else(|| LayoutConfigError::NotFound(name.to_string()))
}
#[cfg(test)]
mod tests {
fn count_agent_slots(slot: &super::Slot) -> usize {
let self_count = if slot.role == super::SlotRole::Agent {
1
} else {
0
};
let child_count: usize = slot.children.iter().map(count_agent_slots).sum();
self_count + child_count
}
use super::*;
#[test]
fn parse_valid_layout() {
let toml_str = r#"
[layout]
name = "my-layout"
[layout.root]
role = "shell"
size = "100%"
direction = "horizontal"
[[layout.root.children]]
role = "agent"
size = "75%"
direction = "vertical"
[[layout.root.children]]
role = "atm_panel"
size = "25%"
direction = "vertical"
"#;
let layout = parse_layout(toml_str).unwrap();
assert_eq!(layout.name, "my-layout");
assert_eq!(layout.root.role, SlotRole::Shell);
assert_eq!(layout.root.children.len(), 2);
assert_eq!(layout.root.children[0].role, SlotRole::Agent);
assert_eq!(layout.root.children[1].role, SlotRole::AtmPanel);
}
#[test]
fn parse_minimal_layout() {
let toml_str = r#"
[layout]
name = "minimal"
[layout.root]
role = "agent"
size = "100%"
direction = "horizontal"
"#;
let layout = parse_layout(toml_str).unwrap();
assert_eq!(layout.name, "minimal");
assert_eq!(layout.root.role, SlotRole::Agent);
assert!(layout.root.children.is_empty());
assert_eq!(layout.root.count, 1); }
#[test]
fn parse_defaults_applied() {
let toml_str = r#"
[layout]
name = "defaults-test"
[layout.root]
role = "agent"
size = "100%"
direction = "horizontal"
"#;
let layout = parse_layout(toml_str).unwrap();
assert_eq!(layout.root.count, 1);
assert!(layout.root.children.is_empty());
}
#[test]
fn parse_explicit_count() {
let toml_str = r#"
[layout]
name = "counted"
[layout.root]
role = "agent"
size = "100%"
direction = "horizontal"
count = 3
"#;
let layout = parse_layout(toml_str).unwrap();
assert_eq!(layout.root.count, 3);
}
#[test]
fn parse_invalid_toml_returns_error() {
let result = parse_layout("this is not valid toml [[[");
assert!(result.is_err());
match result {
Err(LayoutConfigError::Parse(msg)) => {
assert!(!msg.is_empty());
}
_ => panic!("expected Parse error"),
}
}
#[test]
fn parse_missing_layout_table_returns_error() {
let toml_str = r#"
[something_else]
name = "nope"
"#;
assert!(parse_layout(toml_str).is_err());
}
#[test]
fn preset_solo_structure() {
let layout = preset_solo();
assert_eq!(layout.name, "solo");
assert_eq!(layout.root.role, SlotRole::Agent);
assert!(layout.root.children.is_empty());
assert_eq!(count_agent_slots(&layout.root), 1);
}
#[test]
fn preset_pair_structure() {
let layout = preset_pair();
assert_eq!(layout.name, "pair");
assert_eq!(layout.root.role, SlotRole::Shell);
assert_eq!(layout.root.children.len(), 2);
assert_eq!(count_agent_slots(&layout.root), 2);
assert_eq!(layout.root.children[1].role, SlotRole::AtmPanel);
}
#[test]
fn preset_squad_structure() {
let layout = preset_squad();
assert_eq!(layout.name, "squad");
assert_eq!(count_agent_slots(&layout.root), 3);
assert_eq!(layout.root.children[1].role, SlotRole::AtmPanel);
}
#[test]
fn preset_grid_structure() {
let layout = preset_grid();
assert_eq!(layout.name, "grid");
assert_eq!(count_agent_slots(&layout.root), 4);
assert_eq!(layout.root.children.len(), 2);
assert_eq!(layout.root.children[0].role, SlotRole::Shell);
assert_eq!(layout.root.children[1].role, SlotRole::Shell);
}
#[test]
fn preset_workspace_structure() {
let layout = preset_workspace();
assert_eq!(layout.name, "workspace");
assert_eq!(count_agent_slots(&layout.root), 1);
assert_eq!(layout.root.children.len(), 2);
let ws = &layout.root.children[0];
assert_eq!(ws.children.len(), 2);
assert_eq!(ws.children[0].role, SlotRole::Agent);
assert_eq!(ws.children[1].role, SlotRole::Shell);
assert_eq!(layout.root.children[1].role, SlotRole::AtmPanel);
}
#[test]
fn preset_workspace_editor_structure() {
let layout = preset_workspace_editor();
assert_eq!(layout.name, "workspace-editor");
assert_eq!(count_agent_slots(&layout.root), 1);
assert_eq!(layout.root.children.len(), 2);
let ws = &layout.root.children[0];
assert_eq!(ws.children.len(), 2);
let main = &ws.children[0];
assert_eq!(main.children.len(), 2);
assert_eq!(main.children[0].role, SlotRole::Editor);
assert_eq!(main.children[1].role, SlotRole::Agent);
assert_eq!(ws.children[1].role, SlotRole::Shell);
assert_eq!(layout.root.children[1].role, SlotRole::AtmPanel);
}
#[test]
fn preset_by_name_known() {
assert!(preset_by_name("solo").is_some());
assert!(preset_by_name("pair").is_some());
assert!(preset_by_name("squad").is_some());
assert!(preset_by_name("grid").is_some());
assert!(preset_by_name("workspace").is_some());
assert!(preset_by_name("workspace-editor").is_some());
}
#[test]
fn preset_by_name_unknown() {
assert!(preset_by_name("nonexistent").is_none());
assert!(preset_by_name("").is_none());
}
#[test]
fn preset_by_name_returns_correct_layout() {
let solo = preset_by_name("solo").unwrap();
assert_eq!(solo.name, "solo");
let pair = preset_by_name("pair").unwrap();
assert_eq!(pair.name, "pair");
}
use crate::mock::{MockCall, MockTmuxClient};
#[tokio::test]
async fn apply_layout_solo_current_pane_no_splits() {
let mock = MockTmuxClient::new();
let layout = preset_solo();
let target = LayoutTarget::CurrentPane("%1".to_string());
let result = apply_layout(&mock, &layout, target).await.unwrap();
let calls = mock.calls();
assert!(
calls.is_empty(),
"solo layout should not produce any splits"
);
let agent_panes = result.panes.get(&SlotRole::Agent).unwrap();
assert_eq!(agent_panes, &["%1".to_string()]);
}
#[tokio::test]
async fn apply_layout_pair_current_pane_splits_and_roles() {
let mock = MockTmuxClient::new();
mock.set_next_pane_id("%2"); mock.set_next_pane_id("%3");
let layout = preset_pair();
let target = LayoutTarget::CurrentPane("%1".to_string());
let result = apply_layout(&mock, &layout, target).await.unwrap();
let calls = mock.calls();
assert_eq!(calls.len(), 2, "pair layout should produce 2 splits");
assert!(matches!(
&calls[0],
MockCall::SplitWindow {
target,
size,
direction: crate::PaneDirection::Below,
command: None,
} if target == "%1" && size == "50%"
));
assert!(matches!(
&calls[1],
MockCall::SplitWindow {
target,
size,
direction: crate::PaneDirection::Right,
command: None,
} if target == "%1" && size == "25%"
));
let agent_panes = result.panes.get(&SlotRole::Agent).unwrap();
assert_eq!(agent_panes.len(), 2);
assert_eq!(agent_panes[0], "%1");
assert_eq!(agent_panes[1], "%2");
let atm_panes = result.panes.get(&SlotRole::AtmPanel).unwrap();
assert_eq!(atm_panes, &["%3".to_string()]);
}
#[tokio::test]
async fn apply_layout_new_window_calls_new_window_first() {
let mock = MockTmuxClient::new();
mock.set_next_pane_id("%10");
let layout = preset_solo();
let target = LayoutTarget::NewWindow(Some("test-win".to_string()));
let result = apply_layout(&mock, &layout, target).await.unwrap();
let calls = mock.calls();
let new_window_call = calls
.iter()
.find(|c| matches!(c, MockCall::NewWindow { .. }));
assert!(
new_window_call.is_some(),
"expected NewWindow call in {calls:?}"
);
let agent_panes = result.panes.get(&SlotRole::Agent).unwrap();
assert_eq!(agent_panes, &["%10".to_string()]);
}
#[tokio::test]
async fn apply_layout_new_session_calls_new_session_first() {
let mock = MockTmuxClient::new();
mock.set_next_pane_id("%20");
let layout = preset_solo();
let target = LayoutTarget::NewSession("my-session".to_string());
let result = apply_layout(&mock, &layout, target).await.unwrap();
let calls = mock.calls();
assert_eq!(calls.len(), 1);
assert!(matches!(
&calls[0],
MockCall::NewSession { name } if name == "my-session"
));
let agent_panes = result.panes.get(&SlotRole::Agent).unwrap();
assert_eq!(agent_panes, &["%20".to_string()]);
}
#[test]
fn load_layout_returns_builtin_preset() {
let layout = load_layout("solo", None).unwrap();
assert_eq!(layout.name, "solo");
}
#[test]
fn load_layout_not_found_returns_error() {
let result = load_layout("nonexistent", None);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
LayoutConfigError::NotFound(name) if name == "nonexistent"
));
}
#[test]
fn load_layout_project_config_overrides_preset() {
let tmp = std::env::temp_dir().join("atm-test-load-layout");
let atm_dir = tmp.join(".atm");
let _ = std::fs::create_dir_all(&atm_dir);
let toml_content = r#"
[layout]
name = "solo"
[layout.root]
role = "editor"
size = "100%"
direction = "horizontal"
count = 1
"#;
let config_path = atm_dir.join("layout.toml");
std::fs::write(&config_path, toml_content).unwrap();
let layout = load_layout("solo", Some(&tmp)).unwrap();
assert_eq!(layout.name, "solo");
assert_eq!(layout.root.role, SlotRole::Editor);
let _ = std::fs::remove_dir_all(&tmp);
}
}