use std::collections::HashMap;
use crate::buffer_ref::BufferRef;
use crate::editor::Editor;
use super::{App, Toast};
pub type PaneId = u32;
#[allow(
clippy::large_enum_variant,
reason = "held one-per-pane in a small HashMap, never in bulk, and the \
active editor isn't stored here — boxing would only add \
indirection to cold inactive-pane paths"
)]
pub enum PaneContent {
Editor(Editor),
Agent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplitDir {
Vertical,
Horizontal,
}
pub use crate::action::FocusDir;
#[derive(Debug, Clone)]
pub enum PaneLayout {
Leaf(PaneId),
Split {
dir: SplitDir,
children: Vec<PaneLayout>,
ratios: Vec<f32>,
},
}
impl PaneLayout {
pub fn find_leaf_mut(&mut self, id: PaneId) -> Option<&mut PaneLayout> {
match self {
PaneLayout::Leaf(pid) if *pid == id => Some(self),
PaneLayout::Leaf(_) => None,
PaneLayout::Split { children, .. } => {
for c in children {
if let Some(found) = c.find_leaf_mut(id) {
return Some(found);
}
}
None
}
}
}
pub fn leaves(&self) -> Vec<PaneId> {
let mut out = Vec::new();
self.collect_leaves(&mut out);
out
}
fn collect_leaves(&self, out: &mut Vec<PaneId>) {
match self {
PaneLayout::Leaf(id) => out.push(*id),
PaneLayout::Split { children, .. } => {
for c in children {
c.collect_leaves(out);
}
}
}
}
pub fn remove_leaf(&mut self, target: PaneId) -> Option<PaneId> {
enum RemoveResult {
NotFound,
RemoveSelf,
Removed(Option<PaneId>),
}
fn rightmost_leaf(node: &PaneLayout) -> PaneId {
match node {
PaneLayout::Leaf(id) => *id,
PaneLayout::Split { children, .. } => {
rightmost_leaf(children.last().expect("split has >= 1 child"))
}
}
}
fn walk(node: &mut PaneLayout, target: PaneId) -> RemoveResult {
match node {
PaneLayout::Leaf(id) if *id == target => RemoveResult::RemoveSelf,
PaneLayout::Leaf(_) => RemoveResult::NotFound,
PaneLayout::Split {
children, ratios, ..
} => {
for i in 0..children.len() {
match walk(&mut children[i], target) {
RemoveResult::NotFound => continue,
RemoveResult::RemoveSelf => {
children.remove(i);
ratios.remove(i);
let sum: f32 = ratios.iter().sum();
if sum > 0.0 {
for r in ratios.iter_mut() {
*r /= sum;
}
}
let neighbor = if children.is_empty() {
None
} else {
let pick = if i < children.len() { i } else { i - 1 };
Some(rightmost_leaf(&children[pick]))
};
return RemoveResult::Removed(neighbor);
}
RemoveResult::Removed(n) => return RemoveResult::Removed(n),
}
}
RemoveResult::NotFound
}
}
}
let neighbor = match walk(self, target) {
RemoveResult::Removed(n) => n,
_ => return None,
};
collapse_singletons(self);
neighbor
}
pub fn split_at(&mut self, dir: SplitDir, new_id: PaneId, place: SplitPlace) {
let existing = std::mem::replace(self, PaneLayout::Leaf(0));
let new = PaneLayout::Leaf(new_id);
let (children, ratios) = match place {
SplitPlace::After => (vec![existing, new], vec![0.5, 0.5]),
SplitPlace::Before => (vec![new, existing], vec![0.5, 0.5]),
};
*self = PaneLayout::Split {
dir,
children,
ratios,
};
}
}
#[derive(Debug, Clone, Copy)]
pub enum SplitPlace {
After,
#[allow(dead_code)]
Before,
}
fn collapse_singletons(node: &mut PaneLayout) {
loop {
let collapsed = match node {
PaneLayout::Leaf(_) => None,
PaneLayout::Split { children, .. } if children.len() == 1 => Some(children.remove(0)),
PaneLayout::Split { children, .. } => {
for c in children.iter_mut() {
collapse_singletons(c);
}
None
}
};
match collapsed {
None => break,
Some(replacement) => {
*node = replacement;
}
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PaneRect {
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
}
pub type PaneRectMap = HashMap<PaneId, PaneRect>;
pub const INITIAL_PANE_ID: PaneId = 0;
pub const NEXT_PANE_ID_SEED: PaneId = 1;
impl App {
pub fn split_window(&mut self, dir: SplitDir) {
if Some(self.active_pane) == self.agent_pane {
self.active_pane = self.editor_pane;
}
self.split_window_quiet(dir);
self.push_toast(Toast::info(format!(
"split ({})",
match dir {
SplitDir::Vertical => "vertical",
SplitDir::Horizontal => "horizontal",
},
)));
}
pub(super) fn split_window_quiet(&mut self, dir: SplitDir) -> PaneId {
let new_pane_id = self.mint_pane_id();
let active_pane_id = self.active_pane;
let shared_ref = self.editor.doc.clone();
let mut new_active = Editor::for_doc(shared_ref);
new_active.cursor = self.editor.cursor;
new_active.jumps = self.editor.jumps.clone();
new_active.cursor_memory = self.editor.cursor_memory.clone();
new_active.fold_memory = self.editor.fold_memory.clone();
let displaced = std::mem::replace(&mut self.editor, new_active);
self.pane_content
.insert(active_pane_id, PaneContent::Editor(displaced));
let leaf = self
.layout
.find_leaf_mut(active_pane_id)
.expect("active pane must be in the layout tree");
leaf.split_at(dir, new_pane_id, SplitPlace::After);
self.active_pane = new_pane_id;
self.editor_pane = new_pane_id;
new_pane_id
}
pub fn editor_for_pane(&self, id: PaneId) -> Option<&Editor> {
if id == self.editor_pane {
return Some(&self.editor);
}
match self.pane_content.get(&id) {
Some(PaneContent::Editor(ed)) => Some(ed),
_ => None,
}
}
pub fn buffer_for_pane(&self, id: PaneId) -> Option<&crate::editor::Buffer> {
let ed = self.editor_for_pane(id)?;
self.documents.get(&ed.doc)
}
pub fn ref_used_by_inactive_pane(&self, r: &BufferRef) -> bool {
self.pane_content.values().any(|c| match c {
PaneContent::Editor(ed) => &ed.doc == r,
PaneContent::Agent => false,
})
}
pub fn close_window(&mut self) {
if self.pane_count() <= 1 {
self.push_toast(Toast::error("only one pane (use :q to quit)"));
return;
}
let closing_id = self.active_pane;
if Some(closing_id) == self.agent_pane {
let neighbor = match self.layout.remove_leaf(closing_id) {
Some(n) => n,
None => {
self.push_toast(Toast::error("layout has no neighbour to close into"));
return;
}
};
self.pane_content.remove(&closing_id);
self.agent_pane = None;
self.active_pane = self.editor_pane;
self.focus_pane(neighbor);
self.push_toast(Toast::info("agent pane closed"));
return;
}
let has_other_editor_leaf = self
.layout
.leaves()
.into_iter()
.any(|id| id != closing_id && Some(id) != self.agent_pane);
if !has_other_editor_leaf {
self.push_toast(Toast::error("only editor pane (use :q to quit)"));
return;
}
let neighbor = match self.layout.remove_leaf(closing_id) {
Some(n) => n,
None => {
self.push_toast(Toast::error("layout has no neighbour to close into"));
return;
}
};
let editor_neighbor = if Some(neighbor) == self.agent_pane {
self.layout
.leaves()
.into_iter()
.find(|id| Some(*id) != self.agent_pane && *id != closing_id)
.unwrap_or(neighbor)
} else {
neighbor
};
let neighbour_ed = match self.pane_content.remove(&editor_neighbor) {
Some(PaneContent::Editor(ed)) => ed,
_ => {
self.push_toast(Toast::error("no editor neighbour to close into"));
return;
}
};
let closing_ref = self.editor.doc.clone();
self.editor = neighbour_ed;
self.editor_pane = editor_neighbor;
self.active_pane = editor_neighbor;
self.current_scratch_id = match &self.editor.doc {
BufferRef::Scratch(id) => Some(*id),
_ => None,
};
self.retire_doc_if_unreferenced(closing_ref);
self.lsp.detach_current();
self.lsp.set_last_synced_version(self.active_doc().version);
if let Some(path) = self.active_doc().path.clone() {
if self.active_doc().highlighter.is_none() {
self.spawn_engine_worker(&path);
}
self.spawn_lsp_worker(&path);
}
self.push_toast(Toast::info("pane closed"));
}
pub(crate) fn close_agent_pane(&mut self) {
let Some(agent_pid) = self.agent_pane else {
return;
};
let was_active = self.active_pane == agent_pid;
let neighbor = self.layout.remove_leaf(agent_pid);
self.pane_content.remove(&agent_pid);
self.agent_pane = None;
self.agent = None;
if was_active {
self.active_pane = self.editor_pane;
if let Some(n) = neighbor {
self.focus_pane(n);
}
}
}
pub fn focus_window(&mut self, dir: FocusDir) {
let Some(target) = self.pane_in_direction(dir) else {
return;
};
self.focus_pane(target);
}
pub fn cycle_window(&mut self) {
let leaves = self.layout.leaves();
if leaves.len() <= 1 {
return;
}
let idx = leaves
.iter()
.position(|id| *id == self.active_pane)
.unwrap_or(0);
let next = leaves[(idx + 1) % leaves.len()];
self.focus_pane(next);
}
pub fn pane_count(&self) -> usize {
self.layout.leaves().len()
}
pub(super) fn focus_pane(&mut self, target: PaneId) {
if target == self.active_pane {
return;
}
if Some(target) == self.agent_pane {
self.active_pane = target;
return;
}
if target == self.editor_pane {
self.active_pane = target;
return;
}
let Some(PaneContent::Editor(target_ed)) = self.pane_content.remove(&target) else {
return;
};
let prev_editor_pane = self.editor_pane;
let target_ref = target_ed.doc.clone();
let prev_ed = std::mem::replace(&mut self.editor, target_ed);
self.pane_content
.insert(prev_editor_pane, PaneContent::Editor(prev_ed));
self.editor_pane = target;
self.active_pane = target;
self.current_scratch_id = match &target_ref {
BufferRef::Scratch(id) => Some(*id),
_ => None,
};
self.lsp.detach_current();
self.lsp.set_last_synced_version(self.active_doc().version);
if let Some(path) = self.active_doc().path.clone() {
if self.active_doc().highlighter.is_none() {
self.spawn_engine_worker(&path);
}
self.spawn_lsp_worker(&path);
}
self.record_opened(target_ref);
}
fn pane_in_direction(&self, dir: FocusDir) -> Option<PaneId> {
let rects = self.last_pane_rects.borrow();
let active = rects.get(&self.active_pane).copied()?;
let active_cx = active.x + active.width / 2;
let active_cy = active.y + active.height / 2;
let mut best: Option<(PaneId, i32)> = None;
for (&id, &rect) in rects.iter() {
if id == self.active_pane {
continue;
}
let matches_dir = match dir {
FocusDir::Left => rect.x + rect.width <= active.x,
FocusDir::Right => rect.x >= active.x + active.width,
FocusDir::Up => rect.y + rect.height <= active.y,
FocusDir::Down => rect.y >= active.y + active.height,
};
if !matches_dir {
continue;
}
let cx = rect.x + rect.width / 2;
let cy = rect.y + rect.height / 2;
let dist: i32 = match dir {
FocusDir::Left | FocusDir::Right => {
(cx as i32 - active_cx as i32).abs() * 2 + (cy as i32 - active_cy as i32).abs()
}
FocusDir::Up | FocusDir::Down => {
(cy as i32 - active_cy as i32).abs() * 2 + (cx as i32 - active_cx as i32).abs()
}
};
match best {
Some((_, b)) if dist >= b => {}
_ => best = Some((id, dist)),
}
}
best.map(|(id, _)| id)
}
pub(super) fn mint_pane_id(&mut self) -> PaneId {
let id = self.next_pane_id;
self.next_pane_id = self.next_pane_id.saturating_add(1);
id
}
}
#[cfg(test)]
mod tests {
use crate::buffer_ref::BufferRef;
use crate::editor::{Buffer, Cursor, Editor, JumpEntry};
use std::collections::HashMap;
use super::{PaneContent, PaneLayout};
use crate::app::App;
fn test_app() -> App {
let config = crate::config::Config::load(None).expect("default config loads");
let loader =
crate::syntax::Loader::new(std::path::PathBuf::new(), std::path::PathBuf::new());
let (tx, _rx) = std::sync::mpsc::channel();
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
App::new(config, loader, tx, cwd)
}
fn attach_agent_pane(app: &mut App) -> super::PaneId {
let prev_editor_pane = app.editor_pane;
let new_leaf = app.split_window_quiet(super::SplitDir::Vertical);
if let Some(PaneContent::Editor(prev_ed)) = app.pane_content.remove(&prev_editor_pane) {
app.editor = prev_ed;
app.editor_pane = prev_editor_pane;
}
app.pane_content.insert(new_leaf, PaneContent::Agent);
app.agent_pane = Some(new_leaf);
app.active_pane = new_leaf;
new_leaf
}
#[test]
fn focusing_agent_pane_leaves_editor_intact() {
let mut app = test_app();
let editor_pane = app.editor_pane;
let editor_doc = app.editor.doc.clone();
let agent_leaf = attach_agent_pane(&mut app);
assert_eq!(app.active_pane, agent_leaf);
assert_eq!(app.editor_pane, editor_pane);
assert_eq!(app.editor.doc, editor_doc);
assert!(matches!(
app.pane_content.get(&agent_leaf),
Some(PaneContent::Agent)
));
assert!(!app.pane_content.contains_key(&editor_pane));
app.focus_pane(agent_leaf);
assert_eq!(app.editor_pane, editor_pane);
assert_eq!(app.editor.doc, editor_doc);
}
#[test]
fn switching_back_from_agent_restores_editor_focus() {
let mut app = test_app();
let editor_pane = app.editor_pane;
attach_agent_pane(&mut app);
app.focus_pane(editor_pane);
assert_eq!(app.active_pane, editor_pane);
assert_eq!(app.editor_pane, editor_pane);
}
#[test]
fn closing_agent_pane_keeps_agent_and_clears_pane() {
let mut app = test_app();
let editor_pane = app.editor_pane;
let editor_doc = app.editor.doc.clone();
attach_agent_pane(&mut app);
assert_eq!(app.pane_count(), 2);
app.close_window();
assert_eq!(app.agent_pane, None);
assert_eq!(app.pane_count(), 1);
assert_eq!(app.active_pane, editor_pane);
assert_eq!(app.editor_pane, editor_pane);
assert_eq!(app.editor.doc, editor_doc);
assert!(matches!(app.layout, PaneLayout::Leaf(id) if id == editor_pane));
assert!(app.pane_content.is_empty());
}
#[test]
fn split_is_redirected_away_from_the_agent_pane() {
let mut app = test_app();
let editor_pane = app.editor_pane;
let agent_leaf = attach_agent_pane(&mut app);
app.split_window(super::SplitDir::Horizontal);
assert_eq!(app.active_pane, app.editor_pane);
assert_ne!(app.editor_pane, agent_leaf);
assert!(matches!(
app.pane_content.get(&agent_leaf),
Some(PaneContent::Agent)
));
assert!(matches!(
app.pane_content.get(&editor_pane),
Some(PaneContent::Editor(_))
));
}
#[test]
fn split_shares_document_with_independent_cursors() {
let mut documents: HashMap<BufferRef, Buffer> = HashMap::new();
let doc_ref = BufferRef::Scratch(0);
let mut buf = Buffer::new();
buf.lines = vec!["alpha".into(), "beta".into(), "gamma".into()];
documents.insert(doc_ref.clone(), buf);
let mut a = Editor::for_doc(doc_ref.clone());
let mut b = Editor::for_doc(doc_ref.clone());
a.cursor = Cursor { row: 0, col: 0 };
b.cursor = Cursor { row: 2, col: 0 };
{
let doc = documents.get_mut(&a.doc).unwrap();
a.insert_char(doc, 'X');
}
let shared = documents.get(&b.doc).unwrap();
assert_eq!(shared.lines[0], "Xalpha");
assert_eq!(a.cursor, Cursor { row: 0, col: 1 });
assert_eq!(b.cursor, Cursor { row: 2, col: 0 });
}
#[test]
fn jumplist_survives_buffer_switch() {
let mut app = test_app();
let origin = app.editor.doc.clone();
app.editor.jumps.push(JumpEntry {
doc: origin.clone(),
cursor: Cursor { row: 0, col: 0 },
});
app.switch_to_buffer(BufferRef::Scratch(999)).unwrap();
assert_ne!(app.editor.doc, origin, "should have switched buffers");
assert_eq!(app.editor.jumps.entries().len(), 1);
assert_eq!(app.editor.jumps.entries()[0].doc, origin);
}
#[test]
fn cursor_position_restored_on_revisit() {
let mut app = test_app();
let first = app.editor.doc.clone();
app.documents.get_mut(&first).unwrap().lines =
vec!["one".into(), "two".into(), "three".into()];
app.editor.cursor = Cursor { row: 2, col: 1 };
app.switch_to_buffer(BufferRef::Scratch(999)).unwrap();
assert_eq!(app.editor.cursor, Cursor { row: 0, col: 0 });
app.switch_to_buffer(first.clone()).unwrap();
assert_eq!(app.editor.doc, first);
assert_eq!(app.editor.cursor, Cursor { row: 2, col: 1 });
}
#[test]
fn bd_normalizes_inactive_pane_showing_deleted_buffer() {
let mut app = test_app();
let deleted = app.editor.doc.clone();
let inactive_pane = app.editor_pane;
app.split_window_quiet(super::SplitDir::Vertical);
let successor = BufferRef::Scratch(42);
let mut succ = Buffer::new();
succ.lines = vec!["one".into(), "two".into(), "x".into()];
app.documents.insert(successor.clone(), succ);
if let Some(PaneContent::Editor(ed)) = app.pane_content.get_mut(&inactive_pane) {
ed.mode = crate::mode::Mode::Insert;
ed.tokens.push(crate::action::Token::Count(2));
ed.extra_cursors.push(Cursor { row: 0, col: 0 });
ed.cursor = Cursor { row: 0, col: 0 };
ed.cursor_memory
.insert(successor.clone(), Cursor { row: 2, col: 5 });
}
app.documents.remove(&deleted);
app.install_buffer(successor.clone());
let Some(PaneContent::Editor(ed)) = app.pane_content.get(&inactive_pane) else {
panic!("inactive pane should still be an editor");
};
assert_eq!(ed.doc, successor);
assert_eq!(ed.mode, crate::mode::Mode::Normal);
assert!(ed.tokens.is_empty());
assert!(ed.extra_cursors.is_empty());
assert_eq!(ed.cursor, Cursor { row: 2, col: 0 });
}
}