diff options
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 417 | ||||
| -rw-r--r-- | src/app/input.rs | 562 | ||||
| -rw-r--r-- | src/app/mod.rs | 159 | ||||
| -rw-r--r-- | src/app/state.rs | 130 |
4 files changed, 1268 insertions, 0 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs new file mode 100644 index 00000000..8e2e9297 --- /dev/null +++ b/src/app/actions.rs @@ -0,0 +1,417 @@ +//! Pure state mutations on AppState. +//! These don't need channels, async, or PTY runtime. + +use tracing::{info, warn}; + +use crate::detect::AgentState; +use crate::events::AppEvent; +use crate::layout::{find_in_direction, NavDirection, PaneId}; + +use super::state::{AppState, Mode}; + +// --------------------------------------------------------------------------- +// Workspace operations +// --------------------------------------------------------------------------- + +impl AppState { + pub fn switch_workspace(&mut self, idx: usize) { + if idx < self.workspaces.len() { + self.active = Some(idx); + self.selected = idx; + for pane in self.workspaces[idx].panes.values_mut() { + pane.seen = true; + } + } + } + + pub fn close_selected_workspace(&mut self) { + if self.workspaces.is_empty() { + return; + } + let name = self.workspaces[self.selected].name.clone(); + info!(workspace = %name, "workspace closed"); + self.workspaces.remove(self.selected); + if self.workspaces.is_empty() { + self.active = None; + self.selected = 0; + } else { + if self.selected >= self.workspaces.len() { + self.selected = self.workspaces.len() - 1; + } + self.active = Some(self.selected); + } + } +} + +// --------------------------------------------------------------------------- +// Pane operations +// --------------------------------------------------------------------------- + +impl AppState { + pub fn navigate_pane(&mut self, direction: NavDirection) { + let panes = &self.view.pane_infos; + if let Some(focused) = panes.iter().find(|p| p.is_focused) { + if let Some(target) = find_in_direction(focused, direction, panes) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + ws.layout.focus_pane(target); + } + } + } + } + + pub fn resize_pane(&mut self, direction: NavDirection) { + if let Some(first) = self.view.pane_infos.first() { + let area = self + .view.pane_infos + .iter() + .fold(first.rect, |acc, p| acc.union(p.rect)); + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + ws.layout.resize_focused(direction, 0.05, area); + } + } + } + + pub fn cycle_pane(&mut self, reverse: bool) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if reverse { + ws.layout.focus_prev(); + } else { + ws.layout.focus_next(); + } + } + } + + pub fn toggle_fullscreen(&mut self) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if ws.layout.pane_count() > 1 { + ws.zoomed = !ws.zoomed; + } + } + } + + pub fn close_pane(&mut self) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + ws.close_focused(); + } + } +} + +// --------------------------------------------------------------------------- +// Selection +// --------------------------------------------------------------------------- + +impl AppState { + pub fn clear_selection(&mut self) { + self.selection = None; + } + + pub fn copy_selection(&mut self) { + let sel = match self.selection.as_mut() { + Some(s) => { + if !s.finish() { + self.selection = None; + return; + } + s + } + None => return, + }; + + let ws = match self.active.and_then(|i| self.workspaces.get(i)) { + Some(ws) => ws, + None => return, + }; + + let rt = match ws.runtimes.get(&sel.pane_id) { + Some(r) => r, + None => return, + }; + + if let Ok(parser) = rt.parser.read() { + let text = crate::selection::extract_text(parser.screen(), sel); + if !text.is_empty() { + crate::selection::write_osc52(&text); + info!(len = text.len(), "copied selection to clipboard"); + } + } + } +} + +// --------------------------------------------------------------------------- +// Event handling +// --------------------------------------------------------------------------- + +impl AppState { + pub fn handle_app_event(&mut self, event: AppEvent) { + match event { + AppEvent::PaneDied { pane_id } => self.handle_pane_died(pane_id), + AppEvent::UpdateReady { version } => { + self.update_available = Some(version); + self.update_dismissed = false; + } + AppEvent::StateChanged { + pane_id, + agent, + state, + } => { + for (ws_idx, ws) in self.workspaces.iter_mut().enumerate() { + if let Some(pane) = ws.panes.get_mut(&pane_id) { + let is_active_ws = self.active == Some(ws_idx); + if state == AgentState::Idle + && pane.state != AgentState::Idle + && !is_active_ws + { + pane.seen = false; + } + pane.detected_agent = agent; + pane.state = state; + break; + } + } + } + } + } + + fn handle_pane_died(&mut self, pane_id: PaneId) { + let ws_idx = self + .workspaces + .iter() + .position(|ws| ws.panes.contains_key(&pane_id)); + + let Some(ws_idx) = ws_idx else { + warn!(pane = pane_id.raw(), "PaneDied for unknown pane"); + return; + }; + + let ws = &mut self.workspaces[ws_idx]; + + if ws.layout.pane_count() <= 1 { + self.workspaces.remove(ws_idx); + if self.workspaces.is_empty() { + self.active = None; + self.selected = 0; + if self.mode == Mode::Terminal { + self.mode = Mode::Navigate; + } + } else { + if let Some(active) = self.active { + if active >= self.workspaces.len() { + self.active = Some(self.workspaces.len() - 1); + } + } + if self.selected >= self.workspaces.len() { + self.selected = self.workspaces.len() - 1; + } + } + } else { + if ws.layout.focused() == pane_id { + ws.layout.close_focused(); + } else { + let prev_focus = ws.layout.focused(); + ws.layout.focus_pane(pane_id); + ws.layout.close_focused(); + ws.layout.focus_pane(prev_focus); + } + ws.panes.remove(&pane_id); + ws.runtimes.remove(&pane_id); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::detect::{Agent, AgentState}; + use crate::workspace::Workspace; + use ratatui::layout::Direction; + + fn app_with_workspaces(names: &[&str]) -> AppState { + let mut state = AppState::test_new(); + for name in names { + let ws = Workspace::test_new(name); + state.workspaces.push(ws); + } + if !state.workspaces.is_empty() { + state.active = Some(0); + state.mode = Mode::Terminal; + } + state + } + + #[test] + fn switch_workspace_updates_active_and_selected() { + let mut state = app_with_workspaces(&["a", "b", "c"]); + state.switch_workspace(2); + assert_eq!(state.active, Some(2)); + assert_eq!(state.selected, 2); + } + + #[test] + fn switch_workspace_marks_panes_seen() { + let mut state = app_with_workspaces(&["a", "b"]); + // Mark a pane in workspace 1 as unseen + let id = *state.workspaces[1].panes.keys().next().unwrap(); + state.workspaces[1].panes.get_mut(&id).unwrap().seen = false; + + state.switch_workspace(1); + assert!(state.workspaces[1].panes.get(&id).unwrap().seen); + } + + #[test] + fn switch_workspace_out_of_bounds_is_noop() { + let mut state = app_with_workspaces(&["a"]); + state.switch_workspace(5); + assert_eq!(state.active, Some(0)); + } + + #[test] + fn close_workspace_adjusts_indices() { + let mut state = app_with_workspaces(&["a", "b", "c"]); + state.selected = 1; + state.active = Some(1); + + state.close_selected_workspace(); + + assert_eq!(state.workspaces.len(), 2); + assert_eq!(state.selected, 1); + assert_eq!(state.active, Some(1)); + assert_eq!(state.workspaces[1].name, "c"); + } + + #[test] + fn close_last_workspace_clears_active() { + let mut state = app_with_workspaces(&["only"]); + state.selected = 0; + state.close_selected_workspace(); + + assert!(state.workspaces.is_empty()); + assert_eq!(state.active, None); + assert_eq!(state.selected, 0); + } + + #[test] + fn close_workspace_at_end_adjusts_selected() { + let mut state = app_with_workspaces(&["a", "b"]); + state.selected = 1; + state.active = Some(1); + + state.close_selected_workspace(); + + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.selected, 0); + assert_eq!(state.active, Some(0)); + } + + #[test] + fn pane_died_last_pane_removes_workspace() { + let mut state = app_with_workspaces(&["a", "b"]); + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + + state.handle_pane_died(pane_id); + + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].name, "b"); + } + + #[test] + fn pane_died_last_workspace_enters_navigate() { + let mut state = app_with_workspaces(&["only"]); + state.mode = Mode::Terminal; + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + + state.handle_pane_died(pane_id); + + assert!(state.workspaces.is_empty()); + assert_eq!(state.mode, Mode::Navigate); + } + + #[test] + fn pane_died_multi_pane_keeps_workspace() { + let mut state = app_with_workspaces(&["test"]); + let second_id = state.workspaces[0].test_split(Direction::Horizontal); + + state.handle_pane_died(second_id); + + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].panes.len(), 1); + } + + #[test] + fn pane_died_unknown_pane_is_noop() { + let mut state = app_with_workspaces(&["test"]); + let fake_id = PaneId::from_raw(9999); + + state.handle_pane_died(fake_id); + + assert_eq!(state.workspaces.len(), 1); + } + + #[test] + fn state_changed_updates_pane() { + let mut state = app_with_workspaces(&["test"]); + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Busy, + }); + + let pane = state.workspaces[0].panes.get(&pane_id).unwrap(); + assert_eq!(pane.state, AgentState::Busy); + assert_eq!(pane.detected_agent, Some(Agent::Pi)); + } + + #[test] + fn state_changed_idle_in_background_marks_unseen() { + let mut state = app_with_workspaces(&["active", "background"]); + state.active = Some(0); + let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); + + // First set it to Busy + state.workspaces[1].panes.get_mut(&bg_pane_id).unwrap().state = AgentState::Busy; + + // Now transition to Idle while in background + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + }); + + let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap(); + assert!(!pane.seen); + } + + #[test] + fn toggle_fullscreen_works() { + let mut state = app_with_workspaces(&["test"]); + state.workspaces[0].test_split(Direction::Horizontal); + + assert!(!state.workspaces[0].zoomed); + state.toggle_fullscreen(); + assert!(state.workspaces[0].zoomed); + state.toggle_fullscreen(); + assert!(!state.workspaces[0].zoomed); + } + + #[test] + fn toggle_fullscreen_single_pane_noop() { + let mut state = app_with_workspaces(&["test"]); + state.toggle_fullscreen(); + assert!(!state.workspaces[0].zoomed); + } + + #[test] + fn close_pane_removes_from_workspace() { + let mut state = app_with_workspaces(&["test"]); + state.workspaces[0].test_split(Direction::Horizontal); + assert_eq!(state.workspaces[0].panes.len(), 2); + + state.close_pane(); + assert_eq!(state.workspaces[0].panes.len(), 1); + } +} diff --git a/src/app/input.rs b/src/app/input.rs new file mode 100644 index 00000000..b9a52bda --- /dev/null +++ b/src/app/input.rs @@ -0,0 +1,562 @@ +//! Input handling — translates crossterm key/mouse events into state mutations. + +use bytes::Bytes; +use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::Direction; +use tracing::warn; + +use crate::layout::{NavDirection, PaneInfo, SplitBorder}; +use crate::selection::Selection; + +use super::state::{AppState, ContextMenuState, DragState, Mode, CONTEXT_MENU_ITEMS}; +use super::App; + +// --------------------------------------------------------------------------- +// Key handling +// --------------------------------------------------------------------------- + +impl App { + pub(super) async fn handle_key(&mut self, key: KeyEvent) { + match self.state.mode { + Mode::Navigate => handle_navigate_key(&mut self.state, key), + Mode::Terminal => self.handle_terminal_key(key).await, + Mode::CreateSession => handle_create_key(self, key), + Mode::RenameSession => handle_rename_key(&mut self.state, key), + Mode::Resize => handle_resize_key(&mut self.state, key), + Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key), + Mode::ContextMenu => handle_context_menu_key(&mut self.state, key), + } + } + + pub(super) async fn handle_paste(&mut self, text: String) { + if self.state.mode != Mode::Terminal { + return; + } + if let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) { + if let Some(rt) = ws.focused_runtime() { + let bracketed = rt + .parser + .read() + .map(|p| p.screen().bracketed_paste()) + .unwrap_or(false); + + let payload = if bracketed { + format!("\x1b[200~{text}\x1b[201~") + } else { + text + }; + let _ = rt.sender.send(Bytes::from(payload)).await; + } + } + } + + async fn handle_terminal_key(&mut self, key: KeyEvent) { + self.state.clear_selection(); + self.state.update_dismissed = true; + + if self.state.is_prefix(&key) { + self.state.mode = Mode::Navigate; + return; + } + + if let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) { + if let Some(rt) = ws.focused_runtime() { + rt.scroll_reset(); + let kitty = rt.kitty_keyboard.load(std::sync::atomic::Ordering::Relaxed); + let bytes = crate::input::encode_key(key, kitty); + if bytes.is_empty() { + warn!(code = ?key.code, mods = ?key.modifiers, state = ?key.state, "key produced empty encoding"); + } else { + let _ = rt.sender.send(Bytes::from(bytes)).await; + } + } + } + } +} + +fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { + state.update_dismissed = true; + + if state.is_prefix(&key) || key.code == KeyCode::Esc { + if state.active.is_some() { + state.mode = Mode::Terminal; + } + return; + } + + // Configurable keybinds (checked before fixed binds) + let kb = &state.keybinds; + if key.code == kb.split_vertical.0 && key.modifiers.contains(kb.split_vertical.1) { + state.split_pane(Direction::Horizontal); + return; + } + if key.code == kb.split_horizontal.0 && key.modifiers.contains(kb.split_horizontal.1) { + state.split_pane(Direction::Vertical); + return; + } + if key.code == kb.close_pane.0 && key.modifiers.contains(kb.close_pane.1) { + state.close_pane(); + return; + } + if key.code == kb.fullscreen.0 && key.modifiers.contains(kb.fullscreen.1) { + state.toggle_fullscreen(); + return; + } + + match key.code { + KeyCode::Char('q') => state.should_quit = true, + KeyCode::Char('n') => { + state.mode = Mode::CreateSession; + state.name_input.clear(); + } + KeyCode::Char('N') => { + if !state.workspaces.is_empty() { + state.name_input = state.workspaces[state.selected].name.clone(); + state.mode = Mode::RenameSession; + } + } + KeyCode::Char('d') => { + if !state.workspaces.is_empty() { + if state.confirm_close { + state.mode = Mode::ConfirmClose; + } else { + state.close_selected_workspace(); + if state.workspaces.is_empty() { + state.mode = Mode::Navigate; + } else { + state.mode = Mode::Terminal; + } + } + } + } + KeyCode::Up => { + if state.selected > 0 { + state.selected -= 1; + } + } + KeyCode::Down => { + if !state.workspaces.is_empty() && state.selected < state.workspaces.len() - 1 { + state.selected += 1; + } + } + KeyCode::Enter => { + if !state.workspaces.is_empty() { + state.switch_workspace(state.selected); + state.mode = Mode::Terminal; + } + } + KeyCode::Char('h') | KeyCode::Left => state.navigate_pane(NavDirection::Left), + KeyCode::Char('j') => state.navigate_pane(NavDirection::Down), + KeyCode::Char('k') => state.navigate_pane(NavDirection::Up), + KeyCode::Char('l') | KeyCode::Right => state.navigate_pane(NavDirection::Right), + KeyCode::Tab => state.cycle_pane(false), + KeyCode::BackTab => state.cycle_pane(true), + KeyCode::Char('r') => state.mode = Mode::Resize, + KeyCode::Char('b') => state.sidebar_collapsed = !state.sidebar_collapsed, + KeyCode::Char(c @ '1'..='9') => { + let idx = (c as usize) - ('1' as usize); + if idx < state.workspaces.len() { + state.switch_workspace(idx); + state.mode = Mode::Terminal; + } + } + _ => {} + } +} + +fn handle_create_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Enter => { + let name = if app.state.name_input.trim().is_empty() { + format!("workspace-{}", app.state.workspaces.len() + 1) + } else { + app.state.name_input.trim().to_string() + }; + app.state.name_input.clear(); + app.create_workspace(name); + } + KeyCode::Esc => { + app.state.name_input.clear(); + app.state.mode = Mode::Navigate; + } + KeyCode::Backspace => { + app.state.name_input.pop(); + } + KeyCode::Char(c) => { + app.state.name_input.push(c); + } + _ => {} + } +} + +fn handle_rename_key(state: &mut AppState, key: KeyEvent) { + match key.code { + KeyCode::Enter => { + let new_name = if state.name_input.trim().is_empty() { + state.name_input.clone() + } else { + state.name_input.trim().to_string() + }; + if !new_name.is_empty() && !state.workspaces.is_empty() { + state.workspaces[state.selected].name = new_name; + } + state.name_input.clear(); + state.mode = Mode::Navigate; + } + KeyCode::Esc => { + state.name_input.clear(); + state.mode = Mode::Navigate; + } + KeyCode::Backspace => { + state.name_input.pop(); + } + KeyCode::Char(c) => { + state.name_input.push(c); + } + _ => {} + } +} + +fn handle_resize_key(state: &mut AppState, key: KeyEvent) { + match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('r') => { + if state.active.is_some() { + state.mode = Mode::Terminal; + } else { + state.mode = Mode::Navigate; + } + } + KeyCode::Char('h') | KeyCode::Left => state.resize_pane(NavDirection::Left), + KeyCode::Char('l') | KeyCode::Right => state.resize_pane(NavDirection::Right), + KeyCode::Char('j') | KeyCode::Down => state.resize_pane(NavDirection::Down), + KeyCode::Char('k') | KeyCode::Up => state.resize_pane(NavDirection::Up), + _ => {} + } +} + +fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + state.close_selected_workspace(); + if state.workspaces.is_empty() { + state.mode = Mode::Navigate; + } else { + state.mode = Mode::Terminal; + } + } + _ => { + state.mode = Mode::Navigate; + } + } +} + +fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + state.context_menu = None; + state.mode = Mode::Navigate; + } + KeyCode::Up => { + if let Some(menu) = &mut state.context_menu { + if menu.selected > 0 { + menu.selected -= 1; + } + } + } + KeyCode::Down => { + if let Some(menu) = &mut state.context_menu { + if menu.selected < CONTEXT_MENU_ITEMS.len() - 1 { + menu.selected += 1; + } + } + } + KeyCode::Enter => { + if let Some(menu) = state.context_menu.take() { + match CONTEXT_MENU_ITEMS[menu.selected] { + "Rename" => { + state.selected = menu.ws_idx; + state.name_input = state.workspaces[menu.ws_idx].name.clone(); + state.mode = Mode::RenameSession; + } + "Close" => { + state.selected = menu.ws_idx; + if state.confirm_close { + state.mode = Mode::ConfirmClose; + } else { + state.close_selected_workspace(); + state.mode = Mode::Navigate; + } + } + _ => state.mode = Mode::Navigate, + } + } + } + _ => {} + } +} + +// --------------------------------------------------------------------------- +// Mouse handling +// --------------------------------------------------------------------------- + +impl AppState { + pub(crate) fn handle_mouse(&mut self, mouse: MouseEvent) { + let sidebar = self.view.sidebar_rect; + let in_sidebar = mouse.column >= sidebar.x + && mouse.column < sidebar.x + sidebar.width + && mouse.row >= sidebar.y + && mouse.row < sidebar.y + sidebar.height; + + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + self.selection = None; + + if self.mode == Mode::ContextMenu { + if let Some(menu) = &self.context_menu { + let item_idx = self.context_menu_item_at(mouse.column, mouse.row); + if let Some(idx) = item_idx { + let ws_idx = menu.ws_idx; + self.context_menu = None; + match CONTEXT_MENU_ITEMS[idx] { + "Rename" => { + self.selected = ws_idx; + self.name_input = self.workspaces[ws_idx].name.clone(); + self.mode = Mode::RenameSession; + } + "Close" => { + self.selected = ws_idx; + if self.confirm_close { + self.mode = Mode::ConfirmClose; + } else { + self.close_selected_workspace(); + self.mode = Mode::Navigate; + } + } + _ => self.mode = Mode::Navigate, + } + } else { + self.context_menu = None; + self.mode = Mode::Navigate; + } + } + return; + } + + if !in_sidebar { + if let Some(border) = self.find_border_at(mouse.column, mouse.row) { + self.drag = Some(DragState { + path: border.path.clone(), + direction: border.direction, + area: border.area, + }); + return; + } + } + + if in_sidebar { + let bottom_y = sidebar.y + sidebar.height.saturating_sub(1); + if mouse.row == bottom_y { + self.sidebar_collapsed = !self.sidebar_collapsed; + return; + } + + let inner_y = sidebar.y + 1; + if mouse.row >= inner_y { + let idx = (mouse.row - inner_y) as usize; + if idx < self.workspaces.len() { + self.switch_workspace(idx); + self.mode = Mode::Terminal; + } + } + } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { + let (row, col) = ( + mouse.row - info.inner_rect.y, + mouse.column - info.inner_rect.x, + ); + self.selection = Some(Selection::anchor( + info.id, + row, + col, + info.inner_rect, + )); + + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if ws.layout.focused() != info.id { + ws.layout.focus_pane(info.id); + } + } + if self.mode != Mode::Terminal { + self.mode = Mode::Terminal; + } + } else { + if let Some(info) = self.view.pane_infos.iter().find(|p| { + mouse.column >= p.rect.x + && mouse.column < p.rect.x + p.rect.width + && mouse.row >= p.rect.y + && mouse.row < p.rect.y + p.rect.height + }) { + let id = info.id; + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if ws.layout.focused() != id { + ws.layout.focus_pane(id); + } + } + if self.mode != Mode::Terminal { + self.mode = Mode::Terminal; + } + } + } + } + + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(drag) = &self.drag { + let ratio = match drag.direction { + Direction::Horizontal => { + (mouse.column.saturating_sub(drag.area.x)) as f32 + / drag.area.width.max(1) as f32 + } + Direction::Vertical => { + (mouse.row.saturating_sub(drag.area.y)) as f32 + / drag.area.height.max(1) as f32 + } + }; + let ratio = ratio.clamp(0.1, 0.9); + let path = drag.path.clone(); + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + ws.layout.set_ratio_at(&path, ratio); + } + } else if let Some(sel) = &mut self.selection { + sel.drag(mouse.column, mouse.row); + } + } + + MouseEventKind::Up(MouseButton::Left) => { + if self.drag.take().is_some() { + // Drag ended + } else { + let was_click = self + .selection + .as_ref() + .is_some_and(|s| s.was_just_click()); + if was_click { + self.selection = None; + } else { + self.copy_selection(); + } + } + } + + MouseEventKind::ScrollUp if !in_sidebar => { + self.selection = None; + if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { + if let Some(rt) = ws.focused_runtime() { + rt.scroll_up(3); + } + } + } + MouseEventKind::ScrollDown if !in_sidebar => { + self.selection = None; + if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { + if let Some(rt) = ws.focused_runtime() { + rt.scroll_down(3); + } + } + } + + MouseEventKind::ScrollUp if in_sidebar => { + if self.selected > 0 { + self.selected -= 1; + } + } + MouseEventKind::ScrollDown if in_sidebar => { + if !self.workspaces.is_empty() && self.selected < self.workspaces.len() - 1 { + self.selected += 1; + } + } + + MouseEventKind::Down(MouseButton::Right) if in_sidebar => { + let inner_y = sidebar.y + 1; + if mouse.row >= inner_y { + let idx = (mouse.row - inner_y) as usize; + if idx < self.workspaces.len() { + self.selected = idx; + self.context_menu = Some(ContextMenuState { + ws_idx: idx, + x: mouse.column, + y: mouse.row, + selected: 0, + }); + self.mode = Mode::ContextMenu; + } + } + } + + _ => {} + } + } + + fn context_menu_item_at(&self, col: u16, row: u16) -> Option<usize> { + let menu = self.context_menu.as_ref()?; + let menu_w = 14u16; + let inner_x = menu.x; + let inner_y = menu.y + 1; + if col >= inner_x + && col < inner_x + menu_w + && row >= inner_y + && row < inner_y + CONTEXT_MENU_ITEMS.len() as u16 + { + Some((row - inner_y) as usize) + } else { + None + } + } + + fn find_border_at(&self, col: u16, row: u16) -> Option<&SplitBorder> { + self.view.split_borders.iter().find(|b| match b.direction { + Direction::Horizontal => { + (col as i32 - b.pos as i32).unsigned_abs() <= 1 + && row >= b.area.y + && row < b.area.y + b.area.height + } + Direction::Vertical => { + (row as i32 - b.pos as i32).unsigned_abs() <= 1 + && col >= b.area.x + && col < b.area.x + b.area.width + } + }) + } + + fn pane_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { + self.view.pane_infos.iter().find(|p| { + col >= p.inner_rect.x + && col < p.inner_rect.x + p.inner_rect.width + && row >= p.inner_rect.y + && row < p.inner_rect.y + p.inner_rect.height + }) + } +} + +// Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App +impl AppState { + pub(crate) fn split_pane(&mut self, direction: Direction) { + // Actual PTY spawning happens in Workspace::split_focused + // which needs events channel — this is called from navigate_key + // where we don't have async context, so the workspace handles it + let (rows, cols) = self.estimate_pane_size(); + let new_rows = (rows / 2).max(4); + let new_cols = (cols / 2).max(10); + + let cwd = self + .active + .and_then(|i| self.workspaces.get(i)) + .and_then(|ws| ws.focused_runtime()) + .and_then(|rt| rt.cwd()); + + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if let Ok(new_id) = ws.split_focused(direction, new_rows, new_cols, cwd) { + ws.layout.focus_pane(new_id); + self.mode = Mode::Terminal; + } + } + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 00000000..62250fb8 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,159 @@ +//! Application orchestration. +//! +//! - `state.rs` — AppState, Mode, and pure data structs +//! - `actions.rs` — state mutations (testable without PTYs/async) +//! - `input.rs` — key/mouse → action translation + +mod actions; +mod input; +pub mod state; + +use std::io; +use std::time::Duration; + +use crossterm::event::{self, Event, KeyEventKind}; +use ratatui::layout::Rect; +use ratatui::DefaultTerminal; +use tokio::sync::mpsc; +use tracing::{error, info}; + +use crate::config::Config; +use crate::events::AppEvent; +use crate::workspace::Workspace; + +pub use state::{AppState, Mode, ViewState, CONTEXT_MENU_ITEMS}; + +/// Full application: AppState + runtime concerns (event channels, async I/O). +pub struct App { + pub state: AppState, + pub event_tx: mpsc::Sender<AppEvent>, + event_rx: mpsc::Receiver<AppEvent>, + no_session: bool, +} + +impl App { + pub fn new(config: &Config, no_session: bool) -> Self { + let (prefix_code, prefix_mods) = config.prefix_key(); + let (event_tx, event_rx) = mpsc::channel::<AppEvent>(64); + + // Try to restore previous session + let (workspaces, active, selected) = if no_session { + (Vec::new(), None, 0) + } else if let Some(snap) = crate::persist::load() { + let ws = crate::persist::restore(&snap, 24, 80, event_tx.clone()); + if ws.is_empty() { + info!("session file found but no workspaces restored"); + (Vec::new(), None, 0) + } else { + info!(count = ws.len(), "session restored"); + let active = snap.active.filter(|&i| i < ws.len()); + let selected = snap.selected.min(ws.len().saturating_sub(1)); + (ws, active, selected) + } + } else { + (Vec::new(), None, 0) + }; + + let mode = if active.is_some() { + state::Mode::Terminal + } else { + state::Mode::Navigate + }; + + let state = AppState { + workspaces, + active, + selected, + mode, + should_quit: false, + name_input: String::new(), + view: state::ViewState { + sidebar_rect: Rect::default(), + terminal_area: Rect::default(), + pane_infos: Vec::new(), + split_borders: Vec::new(), + }, + drag: None, + selection: None, + context_menu: None, + update_available: None, + update_dismissed: false, + prefix_code, + prefix_mods, + prefix_label: config.prefix_label(), + sidebar_width: config.ui.sidebar_width, + sidebar_collapsed: false, + confirm_close: config.ui.confirm_close, + accent: crate::config::parse_color(&config.ui.accent), + keybinds: config.keybinds(), + }; + + // Background auto-update (skipped in --no-session / test mode) + if !no_session { + let update_tx = event_tx.clone(); + std::thread::spawn(move || crate::update::auto_update(update_tx)); + } + + Self { + state, + event_tx, + event_rx, + no_session, + } + } + + pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { + while !self.state.should_quit { + terminal.draw(|frame| { + crate::ui::compute_view(&mut self.state, frame.area()); + crate::ui::render(&self.state, frame); + })?; + + // Drain internal events + while let Ok(ev) = self.event_rx.try_recv() { + self.state.handle_app_event(ev); + } + + if event::poll(Duration::from_millis(16))? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + self.handle_key(key).await; + } + Event::Paste(text) => self.handle_paste(text).await, + Event::Mouse(mouse) => self.state.handle_mouse(mouse), + Event::Resize(_, _) => {} + _ => {} + } + } + } + + // Save session on exit (skip in --no-session mode) + if !self.no_session && !self.state.workspaces.is_empty() { + let snap = crate::persist::capture( + &self.state.workspaces, + self.state.active, + self.state.selected, + ); + crate::persist::save(&snap); + } + + Ok(()) + } + + /// Create a workspace with a real PTY (needs event_tx). + fn create_workspace(&mut self, name: String) { + let (rows, cols) = self.state.estimate_pane_size(); + match Workspace::new(name, rows, cols, self.event_tx.clone()) { + Ok(ws) => { + self.state.workspaces.push(ws); + let idx = self.state.workspaces.len() - 1; + self.state.switch_workspace(idx); + self.state.mode = Mode::Terminal; + } + Err(e) => { + error!(err = %e, "failed to create workspace"); + self.state.mode = Mode::Navigate; + } + } + } +} diff --git a/src/app/state.rs b/src/app/state.rs new file mode 100644 index 00000000..b0a81165 --- /dev/null +++ b/src/app/state.rs @@ -0,0 +1,130 @@ +use crossterm::event::{KeyCode, KeyModifiers}; +use crate::config::Keybinds; +use ratatui::layout::{Direction, Rect}; +use ratatui::style::Color; + +use crate::layout::{PaneInfo, SplitBorder}; +use crate::selection::Selection; +use crate::workspace::Workspace; + +/// Computed view geometry — derived from AppState + terminal size. +/// Updated before each render, consumed by render and mouse handling. +pub struct ViewState { + pub sidebar_rect: Rect, + pub terminal_area: Rect, + pub pane_infos: Vec<PaneInfo>, + pub split_borders: Vec<SplitBorder>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Navigate, + Terminal, + CreateSession, + RenameSession, + Resize, + ConfirmClose, + ContextMenu, +} + +/// Active mouse drag on a split border. +pub(crate) struct DragState { + pub path: Vec<bool>, + pub direction: Direction, + pub area: Rect, +} + +/// Right-click context menu state. +pub struct ContextMenuState { + pub ws_idx: usize, + pub x: u16, + pub y: u16, + pub selected: usize, +} + +pub const CONTEXT_MENU_ITEMS: &[&str] = &["Rename", "Close"]; + +/// All application state — pure data, no channels or async runtime. +/// Testable without PTYs or a tokio runtime. +pub struct AppState { + pub workspaces: Vec<Workspace>, + pub active: Option<usize>, + pub selected: usize, + pub mode: Mode, + pub should_quit: bool, + pub name_input: String, + // View geometry (computed before render, consumed by render + mouse) + pub view: ViewState, + pub(crate) drag: Option<DragState>, + pub selection: Option<Selection>, + pub context_menu: Option<ContextMenuState>, + // Update notification + pub update_available: Option<String>, + pub update_dismissed: bool, + // Config + pub prefix_code: KeyCode, + pub prefix_mods: KeyModifiers, + pub prefix_label: String, + pub sidebar_width: u16, + pub sidebar_collapsed: bool, + pub confirm_close: bool, + pub accent: Color, + pub keybinds: Keybinds, +} + +impl AppState { + pub fn is_prefix(&self, key: &crossterm::event::KeyEvent) -> bool { + key.code == self.prefix_code && key.modifiers.contains(self.prefix_mods) + } + + pub fn estimate_pane_size(&self) -> (u16, u16) { + if let Some(info) = self.view.pane_infos.first() { + (info.rect.height, info.rect.width) + } else { + (24, 80) + } + } +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +#[cfg(test)] +impl AppState { + /// Create an AppState for testing — no channels, no PTYs. + pub fn test_new() -> Self { + Self { + workspaces: Vec::new(), + active: None, + selected: 0, + mode: Mode::Navigate, + should_quit: false, + name_input: String::new(), + view: ViewState { + sidebar_rect: Rect::default(), + terminal_area: Rect::default(), + pane_infos: Vec::new(), + split_borders: Vec::new(), + }, + drag: None, + selection: None, + context_menu: None, + update_available: None, + update_dismissed: false, + prefix_code: KeyCode::Char('s'), + prefix_mods: KeyModifiers::CONTROL, + prefix_label: "ctrl+s".into(), + sidebar_width: 26, + sidebar_collapsed: false, + confirm_close: true, + accent: Color::Cyan, + keybinds: Keybinds { + split_vertical: (KeyCode::Char('v'), KeyModifiers::empty()), + split_horizontal: (KeyCode::Char('-'), KeyModifiers::empty()), + close_pane: (KeyCode::Char('x'), KeyModifiers::empty()), + fullscreen: (KeyCode::Char('f'), KeyModifiers::empty()), + }, + } + } +} |
