//! `AppState` — the top-level mutable state of the running TUI. //! //! v1 keeps it small on purpose: a list of tabs (each bound to at most one //! session), an active tab index, focus split between sidebar and pane, the //! current input mode, and an optional overlay (spawn modal, kill confirm, //! help, transient toast). //! //! v1.5 will replace `Tab::pane: Option` with a split tree. use uuid::Uuid; use crate::keymap::Mode; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Focus { Sidebar, Pane, } #[derive(Debug, Clone)] pub struct Tab { pub name: String, pub pane: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum OverlayKind { Spawn, KillConfirm(Uuid), Help, Toast(String), } #[derive(Debug)] pub struct AppState { pub tabs: Vec, pub active_tab_idx: usize, pub focus: Focus, pub mode: Mode, pub sidebar_visible: bool, pub sidebar_highlight: Option, pub overlay: Option, } impl Default for AppState { fn default() -> Self { Self::new() } } impl AppState { pub fn new() -> Self { Self { tabs: vec![Tab { name: "1".into(), pane: None, }], active_tab_idx: 0, focus: Focus::Sidebar, mode: Mode::Normal, sidebar_visible: true, sidebar_highlight: None, overlay: None, } } pub fn active_tab(&self) -> &Tab { &self.tabs[self.active_tab_idx] } pub fn active_tab_mut(&mut self) -> &mut Tab { &mut self.tabs[self.active_tab_idx] } /// Append a new tab and switch to it. Sidebar regains focus so the user /// can pick something to attach. pub fn new_tab(&mut self) { let name = format!("{}", self.tabs.len() + 1); self.tabs.push(Tab { name, pane: None }); self.active_tab_idx = self.tabs.len() - 1; self.focus = Focus::Sidebar; } /// Drop the active tab. No-op if it's the only one — there must always /// be at least one tab. pub fn close_tab(&mut self) { if self.tabs.len() <= 1 { return; } self.tabs.remove(self.active_tab_idx); if self.active_tab_idx >= self.tabs.len() { self.active_tab_idx = self.tabs.len() - 1; } } pub fn next_tab(&mut self) { if self.tabs.is_empty() { return; } self.active_tab_idx = (self.active_tab_idx + 1) % self.tabs.len(); } pub fn prev_tab(&mut self) { if self.tabs.is_empty() { return; } self.active_tab_idx = (self.active_tab_idx + self.tabs.len() - 1) % self.tabs.len(); } pub fn goto_tab(&mut self, n: u8) { let idx = (n as usize) .saturating_sub(1) .min(self.tabs.len().saturating_sub(1)); self.active_tab_idx = idx; } /// Bind a session to the active tab, move focus into the pane, and /// drop straight into Insert mode so keystrokes go to the session /// without an extra `i` press. The leader returns to Normal. pub fn attach_to_focused(&mut self, session_id: Uuid) { self.active_tab_mut().pane = Some(session_id); self.focus = Focus::Pane; self.mode = Mode::Insert; } /// Move the sidebar highlight by `delta` (positive = down) within the /// given ordered list of selectable session ids. Wraps. No-op on empty. pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) { if order.is_empty() { return; } let len = order.len() as i32; let cur = self .sidebar_highlight .and_then(|id| order.iter().position(|x| *x == id)) .map(|i| i as i32) .unwrap_or(-1); let next = (cur + delta).rem_euclid(len) as usize; self.sidebar_highlight = Some(order[next]); } /// Toggle sidebar visibility. pub fn toggle_sidebar(&mut self) { self.sidebar_visible = !self.sidebar_visible; if !self.sidebar_visible { self.focus = Focus::Pane; } } } #[cfg(test)] mod tests { use super::*; #[test] fn new_app_starts_with_one_empty_tab_focused_on_sidebar() { let state = AppState::new(); assert_eq!(state.tabs.len(), 1); assert_eq!(state.tabs[0].pane, None); assert_eq!(state.focus, Focus::Sidebar); assert_eq!(state.mode, Mode::Normal); assert!(state.sidebar_visible); assert!(state.overlay.is_none()); } #[test] fn new_tab_extends_and_focuses() { let mut state = AppState::new(); state.new_tab(); assert_eq!(state.tabs.len(), 2); assert_eq!(state.active_tab_idx, 1); assert_eq!(state.focus, Focus::Sidebar); } #[test] fn close_tab_keeps_at_least_one() { let mut state = AppState::new(); state.close_tab(); assert_eq!(state.tabs.len(), 1); state.new_tab(); state.close_tab(); assert_eq!(state.tabs.len(), 1); } #[test] fn close_tab_advances_when_last_is_closed() { let mut state = AppState::new(); state.new_tab(); state.new_tab(); assert_eq!(state.active_tab_idx, 2); state.close_tab(); assert_eq!(state.tabs.len(), 2); assert_eq!(state.active_tab_idx, 1); } #[test] fn next_prev_tab_wraps() { let mut state = AppState::new(); state.new_tab(); state.new_tab(); state.active_tab_idx = 0; state.next_tab(); assert_eq!(state.active_tab_idx, 1); state.next_tab(); assert_eq!(state.active_tab_idx, 2); state.next_tab(); assert_eq!(state.active_tab_idx, 0); state.prev_tab(); assert_eq!(state.active_tab_idx, 2); } #[test] fn goto_tab_uses_one_based_clamp() { let mut state = AppState::new(); state.new_tab(); state.new_tab(); state.goto_tab(1); assert_eq!(state.active_tab_idx, 0); state.goto_tab(2); assert_eq!(state.active_tab_idx, 1); state.goto_tab(99); // Clamped to last available tab. assert_eq!(state.active_tab_idx, state.tabs.len() - 1); } #[test] fn move_sidebar_highlight_walks_order_and_wraps() { let mut state = AppState::new(); let a = Uuid::from_u128(1); let b = Uuid::from_u128(2); let c = Uuid::from_u128(3); let order = vec![a, b, c]; // No prior highlight + delta +1 lands on first item. state.move_sidebar_highlight(&order, 1); assert_eq!(state.sidebar_highlight, Some(a)); state.move_sidebar_highlight(&order, 1); assert_eq!(state.sidebar_highlight, Some(b)); state.move_sidebar_highlight(&order, 1); assert_eq!(state.sidebar_highlight, Some(c)); // Wraps forward. state.move_sidebar_highlight(&order, 1); assert_eq!(state.sidebar_highlight, Some(a)); // Wraps backward. state.move_sidebar_highlight(&order, -1); assert_eq!(state.sidebar_highlight, Some(c)); } #[test] fn move_sidebar_highlight_is_noop_on_empty_order() { let mut state = AppState::new(); state.move_sidebar_highlight(&[], 1); assert_eq!(state.sidebar_highlight, None); } #[test] fn attach_binds_focuses_pane_and_enters_insert_mode() { let mut state = AppState::new(); let sid = Uuid::from_u128(7); state.attach_to_focused(sid); assert_eq!(state.tabs[0].pane, Some(sid)); assert_eq!(state.focus, Focus::Pane); // The user expects to type into the session right away; the leader // (default ctrl-a) is the way back. assert_eq!(state.mode, Mode::Insert); } #[test] fn toggle_sidebar_moves_focus_to_pane_when_hiding() { let mut state = AppState::new(); state.toggle_sidebar(); assert!(!state.sidebar_visible); assert_eq!(state.focus, Focus::Pane); state.toggle_sidebar(); assert!(state.sidebar_visible); // Doesn't force focus back; sidebar shown but focus stays where it was. assert_eq!(state.focus, Focus::Pane); } }