diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-21 16:30:04 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-21 16:30:04 +0300 |
| commit | 3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch) | |
| tree | c457a7cf0af271dea1a412474fad5a33c1b60be8 /src/app | |
| parent | 2818ff492d45d17fd4f9b15478d703c6c664a8c0 (diff) | |
feat: add persistent server-client sessions
Make herdr persistent by default.
Launching herdr now starts or reattaches to a background session
server. Clients can detach and reattach while panes and agent
processes keep running. Session mode now supports multi-client attach,
auto-detect startup, and a thin-client/headless-server split.
This also refactors the large app, ui, input, pane, config,
workspace, and persistence modules into smaller focused submodules,
while preserving behavior and colocating tests with the code they
exercise.
Upgrade notes:
- persistence mode is now the default
- in-app quit detaches the current client instead of stopping the server
- use `herdr server stop` to stop the background session
- use `--no-session` for the old single-process behavior
- default socket paths now live under the config directory
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 28 | ||||
| -rw-r--r-- | src/app/api.rs | 709 | ||||
| -rw-r--r-- | src/app/api_helpers.rs | 92 | ||||
| -rw-r--r-- | src/app/config_io.rs | 45 | ||||
| -rw-r--r-- | src/app/creation.rs | 281 | ||||
| -rw-r--r-- | src/app/ids.rs | 82 | ||||
| -rw-r--r-- | src/app/input.rs | 5416 | ||||
| -rw-r--r-- | src/app/input/mod.rs | 321 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 739 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 1258 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 780 | ||||
| -rw-r--r-- | src/app/input/overlays.rs | 483 | ||||
| -rw-r--r-- | src/app/input/selection.rs | 68 | ||||
| -rw-r--r-- | src/app/input/settings.rs | 352 | ||||
| -rw-r--r-- | src/app/input/sidebar.rs | 1219 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 440 | ||||
| -rw-r--r-- | src/app/mod.rs | 1166 | ||||
| -rw-r--r-- | src/app/runtime.rs | 261 | ||||
| -rw-r--r-- | src/app/runtime_commands.rs | 177 | ||||
| -rw-r--r-- | src/app/session.rs | 41 | ||||
| -rw-r--r-- | src/app/state.rs | 22 | ||||
| -rw-r--r-- | src/app/theme_sync.rs | 51 |
22 files changed, 7433 insertions, 6598 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 3617bb07..f9136b9c 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -15,7 +15,7 @@ fn is_background_completion_transition(prev_state: AgentState, new_state: AgentS && matches!(prev_state, AgentState::Working | AgentState::Blocked) } -fn notification_sound_for_state_change( +pub fn notification_sound_for_state_change( is_active_tab: bool, prev_state: AgentState, new_state: AgentState, @@ -89,7 +89,7 @@ pub struct PaneStateUpdate { // --------------------------------------------------------------------------- impl AppState { - fn pane_is_in_active_tab(&self, ws_idx: usize, pane_id: PaneId) -> bool { + pub(crate) fn pane_is_in_active_tab(&self, ws_idx: usize, pane_id: PaneId) -> bool { let Some(active_ws_idx) = self.active else { return false; }; @@ -445,8 +445,8 @@ impl AppState { if let Some(text) = text { if !text.is_empty() { - crate::selection::write_osc52(&text); - info!(len = text.len(), "copied selection to clipboard"); + self.request_clipboard_write = Some(text.into_bytes()); + info!("copied selection to clipboard"); } } @@ -472,7 +472,7 @@ impl AppState { self.toast = Some(ToastNotification { kind: ToastKind::UpdateInstalled, title: format!("v{version} available"), - context: "run `herdr update`".to_string(), + context: "detach, then run `herdr update`".to_string(), }); Vec::new() } @@ -663,6 +663,22 @@ mod tests { } #[test] + fn update_ready_sets_explicit_upgrade_toast() { + let mut state = AppState::test_new(); + + let updates = state.handle_app_event(crate::events::AppEvent::UpdateReady { + version: "0.5.0".into(), + }); + + assert!(updates.is_empty()); + assert_eq!(state.update_available.as_deref(), Some("0.5.0")); + assert!(state.latest_release_notes_available); + let toast = state.toast.as_ref().expect("update toast"); + assert_eq!(toast.title, "v0.5.0 available"); + assert_eq!(toast.context, "detach, then run `herdr update`"); + } + + #[test] fn switch_workspace_updates_active_and_selected() { let mut state = app_with_workspaces(&["a", "b", "c"]); state.switch_workspace(2); @@ -1038,7 +1054,7 @@ mod tests { let toast = state.toast.as_ref().expect("update toast"); assert_eq!(toast.kind, ToastKind::UpdateInstalled); assert_eq!(toast.title, "v0.5.0 available"); - assert_eq!(toast.context, "run `herdr update`"); + assert_eq!(toast.context, "detach, then run `herdr update`"); } #[test] diff --git a/src/app/api.rs b/src/app/api.rs index 2ed224c7..e47eb452 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -1,131 +1,230 @@ -//! API request handling and response building for [`App`]. +use std::time::{Duration, Instant}; use super::{ - detect_state_from_api, encode_api_keys, encode_api_text, pane_agent_status, - tab_attention_priority, App, Mode, + api_helpers::{ + detect_state_from_api, encode_api_keys, encode_api_text, normalize_reported_agent_label, + pane_agent_status, + }, + App, Mode, OverlayPaneState, ToastKind, }; +use crate::events::AppEvent; -fn normalize_reported_agent_label(agent: &str) -> Option<String> { - let trimmed = agent.trim(); - if trimmed.is_empty() { - return None; - } - if let Some(agent) = crate::detect::parse_agent_label(trimmed) { - return Some(crate::detect::agent_label(agent).to_string()); - } - Some(trimmed.to_string()) -} +impl App { + pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) { + if let AppEvent::ClipboardWrite { content } = ev { + crate::selection::write_osc52_bytes(&content); + return; + } -pub(super) fn api_request_changes_ui(request: &crate::api::schema::Request) -> bool { - use crate::api::schema::Method; + let overlay_state = if let AppEvent::PaneDied { pane_id } = &ev { + self.overlay_panes.remove(pane_id) + } else { + None + }; - matches!( - &request.method, - Method::WorkspaceCreate(_) - | Method::WorkspaceFocus(_) - | Method::WorkspaceRename(_) - | Method::WorkspaceClose(_) - | Method::TabCreate(_) - | Method::TabFocus(_) - | Method::TabRename(_) - | Method::TabClose(_) - | Method::PaneSplit(_) - | Method::PaneReportAgent(_) - | Method::PaneClearAgentAuthority(_) - | Method::PaneReleaseAgent(_) - | Method::PaneClose(_) - ) -} + if let AppEvent::PaneDied { pane_id } = &ev { + if let Some((ws_idx, _)) = self.find_pane(*pane_id) { + if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneExited, + data: crate::api::schema::EventData::PaneExited { + pane_id: public_pane_id, + workspace_id: self.public_workspace_id(ws_idx), + }, + }); + } + } + } -impl App { - pub(super) fn handle_api_request_message( - &mut self, - msg: crate::api::ApiRequestMessage, - ) -> bool { - let changed = api_request_changes_ui(&msg.request); - let response = self.handle_api_request(msg.request); - let _ = msg.respond_to.send(response); - changed - } + let released_agent = if let AppEvent::HookAgentReleased { + pane_id, + known_agent, + .. + } = &ev + { + known_agent.map(|agent| (*pane_id, agent)) + } else { + None + }; - pub(super) fn public_workspace_id(&self, ws_idx: usize) -> String { - self.state.workspaces[ws_idx].id.clone() + let previous_toast = self.state.toast.clone(); + let pane_updates = self.state.handle_app_event(ev); + for update in &pane_updates { + self.emit_pane_state_update(update); + } + if let Some((pane_id, agent)) = released_agent { + if pane_updates.iter().any(|update| update.pane_id == pane_id) { + if let Some((ws_idx, _)) = self.find_pane(pane_id) { + if let Some(runtime) = self.state.workspaces[ws_idx].runtimes.get(&pane_id) { + runtime.begin_graceful_release(agent); + } + } + } + } + if let Some(overlay) = overlay_state { + self.restore_overlay_after_exit(overlay); + } + self.sync_toast_deadline(previous_toast); } - pub(super) fn public_tab_id(&self, ws_idx: usize, tab_idx: usize) -> Option<String> { - let ws = self.state.workspaces.get(ws_idx)?; - ws.tabs.get(tab_idx)?; - Some(format!("{}:{}", ws.id, tab_idx + 1)) - } + fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) { + let Some(ws) = self.state.workspaces.get_mut(overlay.ws_idx) else { + return; + }; + if overlay.tab_idx >= ws.tabs.len() { + return; + } - pub(super) fn public_pane_id( - &self, - ws_idx: usize, - pane_id: crate::layout::PaneId, - ) -> Option<String> { - let ws = self.state.workspaces.get(ws_idx)?; - let pane_number = ws.public_pane_number(pane_id)?; - Some(format!("{}-{pane_number}", ws.id)) + ws.active_tab = overlay.tab_idx; + let tab = &mut ws.tabs[overlay.tab_idx]; + if tab.panes.contains_key(&overlay.previous_focus) { + tab.layout.focus_pane(overlay.previous_focus); + } + tab.zoomed = overlay.previous_zoomed; + + if self.state.active == Some(overlay.ws_idx) { + self.state.mode = Mode::Terminal; + } } - pub(super) fn parse_workspace_id(&self, id: &str) -> Option<usize> { - self.state - .workspaces - .iter() - .position(|workspace| workspace.id == id) - .or_else(|| id.strip_prefix("w_")?.parse::<usize>().ok()?.checked_sub(1)) - .or_else(|| id.parse::<usize>().ok()?.checked_sub(1)) + fn emit_pane_state_update(&self, update: &crate::app::actions::PaneStateUpdate) { + let Some(pane_id) = self.public_pane_id(update.ws_idx, update.pane_id) else { + return; + }; + let workspace_id = self.public_workspace_id(update.ws_idx); + + if update.previous_agent_label != update.agent_label { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneAgentDetected, + data: crate::api::schema::EventData::PaneAgentDetected { + pane_id: pane_id.clone(), + workspace_id: workspace_id.clone(), + agent: update.agent_label.clone(), + }, + }); + } + + if update.previous_state != update.state { + let agent_status = self + .state + .workspaces + .get(update.ws_idx) + .and_then(|ws| ws.pane_state(update.pane_id)) + .map(|pane| pane_agent_status(pane.state, pane.seen)) + .unwrap_or_else(|| pane_agent_status(update.state, true)); + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneAgentStatusChanged, + data: crate::api::schema::EventData::PaneAgentStatusChanged { + pane_id, + workspace_id, + agent_status, + }, + }); + } } - pub(super) fn parse_tab_id(&self, id: &str) -> Option<(usize, usize)> { - if let Some(rest) = id.strip_prefix("t_") { - let (ws_raw, tab_raw) = rest.rsplit_once('_')?; - let ws_idx = self.parse_workspace_id(ws_raw)?; - let tab_idx = tab_raw.parse::<usize>().ok()?.checked_sub(1)?; - self.state.workspaces.get(ws_idx)?.tabs.get(tab_idx)?; - return Some((ws_idx, tab_idx)); + pub(super) fn sync_toast_deadline( + &mut self, + previous_toast: Option<crate::app::state::ToastNotification>, + ) { + if self.state.toast != previous_toast { + self.toast_deadline = self.state.toast.as_ref().map(|toast| { + let duration = match toast.kind { + ToastKind::NeedsAttention => Duration::from_secs(8), + ToastKind::Finished => Duration::from_secs(5), + ToastKind::UpdateInstalled => Duration::from_secs(3), + }; + Instant::now() + duration + }); } + } - let (ws_raw, tab_raw) = id.rsplit_once(':')?; - let ws_idx = self.parse_workspace_id(ws_raw)?; - let tab_idx = tab_raw.parse::<usize>().ok()?.checked_sub(1)?; - self.state.workspaces.get(ws_idx)?.tabs.get(tab_idx)?; - Some((ws_idx, tab_idx)) + pub(super) fn emit_event(&self, event: crate::api::schema::EventEnvelope) { + self.event_hub.push(event); } - pub(super) fn parse_pane_id(&self, id: &str) -> Option<(usize, crate::layout::PaneId)> { - if let Some(rest) = id.strip_prefix("p_") { - if let Some((ws_raw, pane_raw)) = rest.rsplit_once('_') { - let ws_idx = self.parse_workspace_id(ws_raw)?; - let pane_id = crate::layout::PaneId::from_raw(pane_raw.parse::<u32>().ok()?); - return Some((ws_idx, pane_id)); - } + pub(crate) fn sync_focus_events(&mut self) { + let current_focus = self.state.active.and_then(|idx| { + self.state + .workspaces + .get(idx) + .and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id))) + }); + if current_focus == self.last_focus { + return; + } - let pane_id = crate::layout::PaneId::from_raw(rest.parse::<u32>().ok()?); - return self.find_pane(pane_id).map(|(ws_idx, _)| (ws_idx, pane_id)); + if let Some((ws_idx, pane_id)) = self.last_focus { + self.send_pane_focus_event(ws_idx, pane_id, crate::ghostty::FocusEvent::Lost); + } + if let Some((ws_idx, pane_id)) = current_focus { + self.send_pane_focus_event(ws_idx, pane_id, crate::ghostty::FocusEvent::Gained); + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::WorkspaceFocused, + data: crate::api::schema::EventData::WorkspaceFocused { + workspace_id: self.public_workspace_id(ws_idx), + }, + }); + if let Some(tab_id) = + self.public_tab_id(ws_idx, self.state.workspaces[ws_idx].active_tab) + { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::TabFocused, + data: crate::api::schema::EventData::TabFocused { + tab_id, + workspace_id: self.public_workspace_id(ws_idx), + }, + }); + } + if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneFocused, + data: crate::api::schema::EventData::PaneFocused { + pane_id: public_pane_id, + workspace_id: self.public_workspace_id(ws_idx), + }, + }); + } } - let (ws_raw, pane_number_raw) = id.rsplit_once('-')?; - let ws_idx = self.parse_workspace_id(ws_raw)?; - let pane_number = pane_number_raw.parse::<usize>().ok()?; - let ws = self.state.workspaces.get(ws_idx)?; - let pane_id = ws - .public_pane_numbers - .iter() - .find_map(|(pane_id, number)| (*number == pane_number).then_some(*pane_id))?; - Some((ws_idx, pane_id)) + self.last_focus = current_focus; + } + + fn send_pane_focus_event( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + event: crate::ghostty::FocusEvent, + ) { + let Some(runtime) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.runtime(pane_id)) + else { + return; + }; + runtime.try_send_focus_event(event); } - pub(super) fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String { + pub(crate) fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String { self.drain_internal_events(); use bytes::Bytes; use crate::api::schema::{ - ErrorBody, ErrorResponse, Method, PaneListParams, PaneReadResult, ReadSource, + ErrorBody, ErrorResponse, IntegrationInstallResult, IntegrationTarget, + IntegrationUninstallResult, Method, PaneListParams, PaneReadResult, ReadSource, ResponseResult, SuccessResponse, TabListParams, }; let response = match request.method { + Method::ServerStop(_) => { + self.state.should_quit = true; + SuccessResponse { + id: request.id, + result: ResponseResult::Ok {}, + } + } Method::WorkspaceList(_) => SuccessResponse { id: request.id, result: ResponseResult::WorkspaceList { @@ -1065,6 +1164,265 @@ impl App { result: ResponseResult::Ok {}, } } + Method::IntegrationInstall(params) => { + let target = params.target; + let messages = match target { + IntegrationTarget::Pi => { + let path = crate::integration::install_pi().map_err(|err| { + serde_json::to_string(&ErrorResponse { + id: request.id.clone(), + error: ErrorBody { + code: "integration_install_failed".into(), + message: err.to_string(), + }, + }) + .unwrap() + }); + match path { + Ok(path) => { + vec![format!("installed pi integration to {}", path.display())] + } + Err(response) => return response, + } + } + IntegrationTarget::Claude => { + let installed = crate::integration::install_claude().map_err(|err| { + serde_json::to_string(&ErrorResponse { + id: request.id.clone(), + error: ErrorBody { + code: "integration_install_failed".into(), + message: err.to_string(), + }, + }) + .unwrap() + }); + match installed { + Ok(installed) => vec![ + format!( + "installed claude integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured claude settings at {}", + installed.settings_path.display() + ), + ], + Err(response) => return response, + } + } + IntegrationTarget::Codex => { + let installed = crate::integration::install_codex().map_err(|err| { + serde_json::to_string(&ErrorResponse { + id: request.id.clone(), + error: ErrorBody { + code: "integration_install_failed".into(), + message: err.to_string(), + }, + }) + .unwrap() + }); + match installed { + Ok(installed) => vec![ + format!( + "installed codex integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured codex hooks at {}", + installed.hooks_path.display() + ), + format!( + "ensured codex config at {}", + installed.config_path.display() + ), + ], + Err(response) => return response, + } + } + IntegrationTarget::Opencode => { + let installed = crate::integration::install_opencode().map_err(|err| { + serde_json::to_string(&ErrorResponse { + id: request.id.clone(), + error: ErrorBody { + code: "integration_install_failed".into(), + message: err.to_string(), + }, + }) + .unwrap() + }); + match installed { + Ok(installed) => vec![format!( + "installed opencode integration plugin to {}", + installed.plugin_path.display() + )], + Err(response) => return response, + } + } + }; + + SuccessResponse { + id: request.id, + result: ResponseResult::IntegrationInstall { + target, + details: IntegrationInstallResult { messages }, + }, + } + } + Method::IntegrationUninstall(params) => { + let target = params.target; + let messages = match target { + IntegrationTarget::Pi => { + let result = crate::integration::uninstall_pi().map_err(|err| { + serde_json::to_string(&ErrorResponse { + id: request.id.clone(), + error: ErrorBody { + code: "integration_uninstall_failed".into(), + message: err.to_string(), + }, + }) + .unwrap() + }); + match result { + Ok(result) => { + if result.removed_extension { + vec![format!( + "removed pi integration extension at {}", + result.extension_path.display() + )] + } else { + vec![format!( + "no pi integration extension found at {}", + result.extension_path.display() + )] + } + } + Err(response) => return response, + } + } + IntegrationTarget::Claude => { + let result = crate::integration::uninstall_claude().map_err(|err| { + serde_json::to_string(&ErrorResponse { + id: request.id.clone(), + error: ErrorBody { + code: "integration_uninstall_failed".into(), + message: err.to_string(), + }, + }) + .unwrap() + }); + match result { + Ok(result) => { + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed claude hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no claude hook found at {}", + result.hook_path.display() + )); + } + if result.updated_settings { + messages.push(format!( + "removed herdr claude hook entries from {}", + result.settings_path.display() + )); + } else { + messages.push(format!( + "no herdr claude hook entries found in {}", + result.settings_path.display() + )); + } + messages + } + Err(response) => return response, + } + } + IntegrationTarget::Codex => { + let result = crate::integration::uninstall_codex().map_err(|err| { + serde_json::to_string(&ErrorResponse { + id: request.id.clone(), + error: ErrorBody { + code: "integration_uninstall_failed".into(), + message: err.to_string(), + }, + }) + .unwrap() + }); + match result { + Ok(result) => { + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed codex hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no codex hook found at {}", + result.hook_path.display() + )); + } + if result.updated_hooks { + messages.push(format!( + "removed herdr codex hook entries from {}", + result.hooks_path.display() + )); + } else { + messages.push(format!( + "no herdr codex hook entries found in {}", + result.hooks_path.display() + )); + } + messages.push(format!( + "left codex config unchanged at {}", + result.config_path.display() + )); + messages + } + Err(response) => return response, + } + } + IntegrationTarget::Opencode => { + let result = crate::integration::uninstall_opencode().map_err(|err| { + serde_json::to_string(&ErrorResponse { + id: request.id.clone(), + error: ErrorBody { + code: "integration_uninstall_failed".into(), + message: err.to_string(), + }, + }) + .unwrap() + }); + match result { + Ok(result) => { + if result.removed_plugin { + vec![format!( + "removed opencode integration plugin at {}", + result.plugin_path.display() + )] + } else { + vec![format!( + "no opencode integration plugin found at {}", + result.plugin_path.display() + )] + } + } + Err(response) => return response, + } + } + }; + + SuccessResponse { + id: request.id, + result: ResponseResult::IntegrationUninstall { + target, + details: IntegrationUninstallResult { messages }, + }, + } + } _ => { return serde_json::to_string(&ErrorResponse { id: request.id, @@ -1079,163 +1437,4 @@ impl App { serde_json::to_string(&response).unwrap() } - - pub(super) fn collect_panes_for_workspace( - &self, - workspace_id: Option<&str>, - ) -> Result<Vec<crate::api::schema::PaneInfo>, (String, String)> { - if let Some(workspace_id) = workspace_id { - let Some(ws_idx) = self.parse_workspace_id(workspace_id) else { - return Err(( - "workspace_not_found".into(), - format!("workspace {workspace_id} not found"), - )); - }; - let Some(ws) = self.state.workspaces.get(ws_idx) else { - return Err(( - "workspace_not_found".into(), - format!("workspace {workspace_id} not found"), - )); - }; - Ok(ws - .tabs - .iter() - .flat_map(|tab| tab.layout.pane_ids().into_iter()) - .filter_map(|pane_id| self.pane_info(ws_idx, pane_id)) - .collect()) - } else { - Ok(self - .state - .workspaces - .iter() - .enumerate() - .flat_map(|(ws_idx, ws)| { - ws.tabs - .iter() - .flat_map(|tab| tab.layout.pane_ids().into_iter()) - .filter_map(move |pane_id| self.pane_info(ws_idx, pane_id)) - }) - .collect()) - } - } - - pub(super) fn tab_info( - &self, - ws_idx: usize, - tab_idx: usize, - ) -> Option<crate::api::schema::TabInfo> { - let ws = self.state.workspaces.get(ws_idx)?; - let tab = ws.tabs.get(tab_idx)?; - let (agg_state, seen) = tab - .panes - .values() - .map(|pane| (pane.state, pane.seen)) - .max_by_key(|(state, seen)| tab_attention_priority(*state, *seen)) - .unwrap_or((crate::detect::AgentState::Unknown, true)); - Some(crate::api::schema::TabInfo { - tab_id: self.public_tab_id(ws_idx, tab_idx)?, - workspace_id: self.public_workspace_id(ws_idx), - number: tab_idx + 1, - label: tab.display_name(), - focused: self.state.active == Some(ws_idx) && ws.active_tab == tab_idx, - pane_count: tab.panes.len(), - agent_status: pane_agent_status(agg_state, seen), - }) - } - - pub(super) fn workspace_created_result( - &self, - ws_idx: usize, - ) -> Option<crate::api::schema::ResponseResult> { - Some(crate::api::schema::ResponseResult::WorkspaceCreated { - workspace: self.workspace_info(ws_idx), - tab: self.tab_info(ws_idx, 0)?, - root_pane: self.root_pane_info(ws_idx, 0)?, - }) - } - - pub(super) fn tab_created_result( - &self, - ws_idx: usize, - tab_idx: usize, - ) -> Option<crate::api::schema::ResponseResult> { - Some(crate::api::schema::ResponseResult::TabCreated { - tab: self.tab_info(ws_idx, tab_idx)?, - root_pane: self.root_pane_info(ws_idx, tab_idx)?, - }) - } - - pub(super) fn root_pane_info( - &self, - ws_idx: usize, - tab_idx: usize, - ) -> Option<crate::api::schema::PaneInfo> { - let ws = self.state.workspaces.get(ws_idx)?; - let tab = ws.tabs.get(tab_idx)?; - self.pane_info(ws_idx, tab.root_pane) - } - - pub(super) fn pane_info( - &self, - ws_idx: usize, - pane_id: crate::layout::PaneId, - ) -> Option<crate::api::schema::PaneInfo> { - let ws = self.state.workspaces.get(ws_idx)?; - let pane = ws.pane_state(pane_id)?; - let runtime = ws.runtime(pane_id); - let tab_idx = ws.find_tab_index_for_pane(pane_id)?; - let focused = self.state.active == Some(ws_idx) - && ws.active_tab == tab_idx - && ws - .focused_pane_id() - .is_some_and(|focused| focused == pane_id); - Some(crate::api::schema::PaneInfo { - pane_id: self.public_pane_id(ws_idx, pane_id)?, - workspace_id: self.public_workspace_id(ws_idx), - tab_id: self.public_tab_id(ws_idx, tab_idx)?, - focused, - cwd: runtime - .and_then(|rt| rt.cwd()) - .map(|cwd| cwd.display().to_string()), - agent: pane.effective_agent_label().map(str::to_string), - agent_status: pane_agent_status(pane.state, pane.seen), - revision: 0, - }) - } - - pub(super) fn lookup_runtime( - &self, - ws_idx: usize, - pane_id: crate::layout::PaneId, - ) -> Option<(&crate::pane::PaneRuntime, String)> { - let ws = self.state.workspaces.get(ws_idx)?; - let runtime = ws.runtime(pane_id)?; - Some((runtime, self.public_workspace_id(ws_idx))) - } - - pub(super) fn lookup_runtime_sender( - &self, - ws_idx: usize, - pane_id: crate::layout::PaneId, - ) -> Option<&crate::pane::PaneRuntime> { - let ws = self.state.workspaces.get(ws_idx)?; - ws.runtime(pane_id) - } - - pub(super) fn workspace_info(&self, index: usize) -> crate::api::schema::WorkspaceInfo { - let ws = &self.state.workspaces[index]; - let (agg_state, seen) = ws.aggregate_state(); - crate::api::schema::WorkspaceInfo { - workspace_id: self.public_workspace_id(index), - number: index + 1, - label: ws.display_name(), - focused: self.state.active == Some(index), - pane_count: ws.public_pane_numbers.len(), - tab_count: ws.tabs.len(), - active_tab_id: self - .public_tab_id(index, ws.active_tab) - .unwrap_or_else(|| format!("{}:{}", ws.id, ws.active_tab + 1)), - agent_status: pane_agent_status(agg_state, seen), - } - } } diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs new file mode 100644 index 00000000..011e5ccb --- /dev/null +++ b/src/app/api_helpers.rs @@ -0,0 +1,92 @@ +pub(super) fn tab_attention_priority(state: crate::detect::AgentState, seen: bool) -> u8 { + match (state, seen) { + (crate::detect::AgentState::Blocked, _) => 4, + (crate::detect::AgentState::Idle, false) => 3, + (crate::detect::AgentState::Working, _) => 2, + (crate::detect::AgentState::Idle, true) => 1, + (crate::detect::AgentState::Unknown, _) => 0, + } +} + +fn parse_api_key(key: &str) -> Option<crossterm::event::KeyEvent> { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let normalized = key.trim(); + match normalized { + "Enter" | "enter" => Some(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())), + "Tab" | "tab" => Some(KeyEvent::new(KeyCode::Tab, KeyModifiers::empty())), + "Esc" | "esc" => Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())), + "Backspace" | "backspace" => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty())), + "Up" | "up" => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::empty())), + "Down" | "down" => Some(KeyEvent::new(KeyCode::Down, KeyModifiers::empty())), + "Left" | "left" => Some(KeyEvent::new(KeyCode::Left, KeyModifiers::empty())), + "Right" | "right" => Some(KeyEvent::new(KeyCode::Right, KeyModifiers::empty())), + "C-c" | "c-c" | "ctrl+c" => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + _ if normalized.len() == 1 => normalized + .chars() + .next() + .map(|ch| KeyEvent::new(KeyCode::Char(ch), KeyModifiers::empty())), + _ => None, + } +} + +pub(super) fn encode_api_text(runtime: &crate::pane::PaneRuntime, text: &str) -> Vec<u8> { + let bracketed = runtime + .input_state() + .map(|state| state.bracketed_paste) + .unwrap_or(false); + if bracketed { + format!("\x1b[200~{text}\x1b[201~").into_bytes() + } else { + text.as_bytes().to_vec() + } +} + +pub(super) fn encode_api_keys( + runtime: &crate::pane::PaneRuntime, + keys: &[String], +) -> Result<Vec<Vec<u8>>, String> { + let mut encoded_keys = Vec::with_capacity(keys.len()); + for key in keys { + let Some(key_event) = parse_api_key(key) else { + return Err(key.clone()); + }; + encoded_keys.push(runtime.encode_terminal_key(key_event.into())); + } + Ok(encoded_keys) +} + +pub(super) fn detect_state_from_api( + state: crate::api::schema::PaneAgentState, +) -> crate::detect::AgentState { + match state { + crate::api::schema::PaneAgentState::Idle => crate::detect::AgentState::Idle, + crate::api::schema::PaneAgentState::Working => crate::detect::AgentState::Working, + crate::api::schema::PaneAgentState::Blocked => crate::detect::AgentState::Blocked, + crate::api::schema::PaneAgentState::Unknown => crate::detect::AgentState::Unknown, + } +} + +pub(super) fn pane_agent_status( + state: crate::detect::AgentState, + seen: bool, +) -> crate::api::schema::AgentStatus { + match (state, seen) { + (crate::detect::AgentState::Idle, false) => crate::api::schema::AgentStatus::Done, + (crate::detect::AgentState::Idle, true) => crate::api::schema::AgentStatus::Idle, + (crate::detect::AgentState::Working, _) => crate::api::schema::AgentStatus::Working, + (crate::detect::AgentState::Blocked, _) => crate::api::schema::AgentStatus::Blocked, + (crate::detect::AgentState::Unknown, _) => crate::api::schema::AgentStatus::Unknown, + } +} + +pub(super) fn normalize_reported_agent_label(agent: &str) -> Option<String> { + let trimmed = agent.trim(); + if trimmed.is_empty() { + return None; + } + if let Some(agent) = crate::detect::parse_agent_label(trimmed) { + return Some(crate::detect::agent_label(agent).to_string()); + } + Some(trimmed.to_string()) +} diff --git a/src/app/config_io.rs b/src/app/config_io.rs new file mode 100644 index 00000000..21d4365b --- /dev/null +++ b/src/app/config_io.rs @@ -0,0 +1,45 @@ +use super::App; + +impl App { + pub(super) fn update_config_file<F>(&mut self, error_context: &str, update: F) + where + F: FnOnce(&str) -> String, + { + let path = crate::config::config_path(); + if let Some(parent) = path.parent() { + if let Err(err) = std::fs::create_dir_all(parent) { + self.state.config_diagnostic = + Some(format!("failed to save {error_context}: {err}")); + self.config_diagnostic_deadline = + Some(std::time::Instant::now() + std::time::Duration::from_secs(5)); + return; + } + } + + let content = std::fs::read_to_string(&path).unwrap_or_default(); + let new_content = update(&content); + if let Err(err) = std::fs::write(&path, new_content) { + self.state.config_diagnostic = Some(format!("failed to save {error_context}: {err}")); + self.config_diagnostic_deadline = + Some(std::time::Instant::now() + std::time::Duration::from_secs(5)); + } + } + + pub(super) fn save_theme(&mut self, name: &str) { + self.update_config_file("theme", |content| { + crate::config::upsert_section_value(content, "theme", "name", &format!("\"{name}\"")) + }); + } + + pub(super) fn save_sound(&mut self, enabled: bool) { + self.update_config_file("sound setting", |content| { + crate::config::upsert_section_bool(content, "ui.sound", "enabled", enabled) + }); + } + + pub(super) fn save_toast(&mut self, enabled: bool) { + self.update_config_file("toast setting", |content| { + crate::config::upsert_section_bool(content, "ui.toast", "enabled", enabled) + }); + } +} diff --git a/src/app/creation.rs b/src/app/creation.rs new file mode 100644 index 00000000..dd2833e0 --- /dev/null +++ b/src/app/creation.rs @@ -0,0 +1,281 @@ +use tracing::error; + +use super::{ + api_helpers::{pane_agent_status, tab_attention_priority}, + App, Mode, +}; +use crate::workspace::Workspace; + +impl App { + pub(super) fn seed_cwd_from_workspace(&self, ws_idx: usize) -> Option<std::path::PathBuf> { + self.state.workspaces.get(ws_idx)?.resolved_identity_cwd() + } + + pub(super) fn workspace_creation_source(&self) -> Option<usize> { + if self.state.mode == Mode::Navigate + && self.state.workspaces.get(self.state.selected).is_some() + { + return Some(self.state.selected); + } + + self.state.active.or_else(|| { + self.state + .workspaces + .get(self.state.selected) + .map(|_| self.state.selected) + }) + } + + /// Create a workspace with a real PTY (needs event_tx). + pub(crate) fn create_workspace(&mut self) { + let initial_cwd = self + .workspace_creation_source() + .and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx)) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| std::path::PathBuf::from("/")); + if let Err(e) = self.create_workspace_with_options(initial_cwd, true) { + error!(err = %e, "failed to create workspace"); + self.state.mode = Mode::Navigate; + } + } + + pub(crate) fn create_tab(&mut self) { + let custom_name = self.state.requested_new_tab_name.take(); + let initial_cwd = self + .state + .active + .and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx)) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| std::path::PathBuf::from("/")); + match self.create_tab_with_options(initial_cwd, true) { + Ok(tab_idx) => { + if let Some(name) = custom_name { + if let Some(ws) = self + .state + .active + .and_then(|ws_idx| self.state.workspaces.get_mut(ws_idx)) + { + if let Some(tab) = ws.tabs.get_mut(tab_idx) { + tab.set_custom_name(name); + } + self.schedule_session_save(); + } + } + } + Err(e) => { + error!(err = %e, "failed to create tab"); + } + } + } + + pub(super) fn create_tab_with_options( + &mut self, + initial_cwd: std::path::PathBuf, + focus: bool, + ) -> std::io::Result<usize> { + let Some(ws_idx) = self.state.active else { + return self.create_workspace_with_options(initial_cwd, focus); + }; + let (rows, cols) = self.state.estimate_pane_size(); + let ws = &mut self.state.workspaces[ws_idx]; + let idx = ws.create_tab( + rows, + cols, + initial_cwd, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + )?; + if focus { + ws.switch_tab(idx); + self.state.mode = Mode::Terminal; + } + self.schedule_session_save(); + Ok(idx) + } + + pub(super) fn create_workspace_with_options( + &mut self, + initial_cwd: std::path::PathBuf, + focus: bool, + ) -> std::io::Result<usize> { + let (rows, cols) = self.state.estimate_pane_size(); + let mut ws = Workspace::new( + initial_cwd, + rows, + cols, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + self.event_tx.clone(), + self.render_notify.clone(), + self.render_dirty.clone(), + )?; + ws.refresh_git_ahead_behind(); + self.state.workspaces.push(ws); + let idx = self.state.workspaces.len() - 1; + if focus || self.state.active.is_none() { + self.state.switch_workspace(idx); + self.state.mode = Mode::Terminal; + } + self.schedule_session_save(); + Ok(idx) + } + + pub(super) fn collect_panes_for_workspace( + &self, + workspace_id: Option<&str>, + ) -> Result<Vec<crate::api::schema::PaneInfo>, (String, String)> { + if let Some(workspace_id) = workspace_id { + let Some(ws_idx) = self.parse_workspace_id(workspace_id) else { + return Err(( + "workspace_not_found".into(), + format!("workspace {workspace_id} not found"), + )); + }; + let Some(ws) = self.state.workspaces.get(ws_idx) else { + return Err(( + "workspace_not_found".into(), + format!("workspace {workspace_id} not found"), + )); + }; + Ok(ws + .tabs + .iter() + .flat_map(|tab| tab.layout.pane_ids().into_iter()) + .filter_map(|pane_id| self.pane_info(ws_idx, pane_id)) + .collect()) + } else { + Ok(self + .state + .workspaces + .iter() + .enumerate() + .flat_map(|(ws_idx, ws)| { + ws.tabs + .iter() + .flat_map(|tab| tab.layout.pane_ids().into_iter()) + .filter_map(move |pane_id| self.pane_info(ws_idx, pane_id)) + }) + .collect()) + } + } + + pub(super) fn tab_info( + &self, + ws_idx: usize, + tab_idx: usize, + ) -> Option<crate::api::schema::TabInfo> { + let ws = self.state.workspaces.get(ws_idx)?; + let tab = ws.tabs.get(tab_idx)?; + let (agg_state, seen) = tab + .panes + .values() + .map(|pane| (pane.state, pane.seen)) + .max_by_key(|(state, seen)| tab_attention_priority(*state, *seen)) + .unwrap_or((crate::detect::AgentState::Unknown, true)); + Some(crate::api::schema::TabInfo { + tab_id: self.public_tab_id(ws_idx, tab_idx)?, + workspace_id: self.public_workspace_id(ws_idx), + number: tab_idx + 1, + label: tab.display_name(), + focused: self.state.active == Some(ws_idx) && ws.active_tab == tab_idx, + pane_count: tab.panes.len(), + agent_status: pane_agent_status(agg_state, seen), + }) + } + + pub(super) fn workspace_created_result( + &self, + ws_idx: usize, + ) -> Option<crate::api::schema::ResponseResult> { + Some(crate::api::schema::ResponseResult::WorkspaceCreated { + workspace: self.workspace_info(ws_idx), + tab: self.tab_info(ws_idx, 0)?, + root_pane: self.root_pane_info(ws_idx, 0)?, + }) + } + + pub(super) fn tab_created_result( + &self, + ws_idx: usize, + tab_idx: usize, + ) -> Option<crate::api::schema::ResponseResult> { + Some(crate::api::schema::ResponseResult::TabCreated { + tab: self.tab_info(ws_idx, tab_idx)?, + root_pane: self.root_pane_info(ws_idx, tab_idx)?, + }) + } + + pub(super) fn root_pane_info( + &self, + ws_idx: usize, + tab_idx: usize, + ) -> Option<crate::api::schema::PaneInfo> { + let ws = self.state.workspaces.get(ws_idx)?; + let tab = ws.tabs.get(tab_idx)?; + self.pane_info(ws_idx, tab.root_pane) + } + + pub(super) fn pane_info( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> Option<crate::api::schema::PaneInfo> { + let ws = self.state.workspaces.get(ws_idx)?; + let pane = ws.pane_state(pane_id)?; + let runtime = ws.runtime(pane_id); + let tab_idx = ws.find_tab_index_for_pane(pane_id)?; + let focused = self.state.active == Some(ws_idx) + && ws.active_tab == tab_idx + && ws + .focused_pane_id() + .is_some_and(|focused| focused == pane_id); + Some(crate::api::schema::PaneInfo { + pane_id: self.public_pane_id(ws_idx, pane_id)?, + workspace_id: self.public_workspace_id(ws_idx), + tab_id: self.public_tab_id(ws_idx, tab_idx)?, + focused, + cwd: runtime + .and_then(|rt| rt.cwd()) + .map(|cwd| cwd.display().to_string()), + agent: pane.effective_agent_label().map(str::to_string), + agent_status: pane_agent_status(pane.state, pane.seen), + revision: 0, + }) + } + + pub(super) fn lookup_runtime( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> Option<(&crate::pane::PaneRuntime, String)> { + let ws = self.state.workspaces.get(ws_idx)?; + let runtime = ws.runtime(pane_id)?; + Some((runtime, self.public_workspace_id(ws_idx))) + } + + pub(super) fn lookup_runtime_sender( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> Option<&crate::pane::PaneRuntime> { + let ws = self.state.workspaces.get(ws_idx)?; + ws.runtime(pane_id) + } + + pub(super) fn workspace_info(&self, index: usize) -> crate::api::schema::WorkspaceInfo { + let ws = &self.state.workspaces[index]; + let (agg_state, seen) = ws.aggregate_state(); + crate::api::schema::WorkspaceInfo { + workspace_id: self.public_workspace_id(index), + number: index + 1, + label: ws.display_name(), + focused: self.state.active == Some(index), + pane_count: ws.public_pane_numbers.len(), + tab_count: ws.tabs.len(), + active_tab_id: self + .public_tab_id(index, ws.active_tab) + .unwrap_or_else(|| format!("{}:{}", ws.id, ws.active_tab + 1)), + agent_status: pane_agent_status(agg_state, seen), + } + } +} diff --git a/src/app/ids.rs b/src/app/ids.rs new file mode 100644 index 00000000..490e6051 --- /dev/null +++ b/src/app/ids.rs @@ -0,0 +1,82 @@ +use super::App; + +impl App { + pub(super) fn find_pane( + &self, + pane_id: crate::layout::PaneId, + ) -> Option<(usize, &crate::pane::PaneState)> { + self.state + .workspaces + .iter() + .enumerate() + .find_map(|(ws_idx, ws)| ws.pane_state(pane_id).map(|pane| (ws_idx, pane))) + } + + pub(super) fn public_workspace_id(&self, ws_idx: usize) -> String { + self.state.workspaces[ws_idx].id.clone() + } + + pub(super) fn public_tab_id(&self, ws_idx: usize, tab_idx: usize) -> Option<String> { + let ws = self.state.workspaces.get(ws_idx)?; + ws.tabs.get(tab_idx)?; + Some(format!("{}:{}", ws.id, tab_idx + 1)) + } + + pub(super) fn public_pane_id( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> Option<String> { + let ws = self.state.workspaces.get(ws_idx)?; + let pane_number = ws.public_pane_number(pane_id)?; + Some(format!("{}-{pane_number}", ws.id)) + } + + pub(super) fn parse_workspace_id(&self, id: &str) -> Option<usize> { + self.state + .workspaces + .iter() + .position(|workspace| workspace.id == id) + .or_else(|| id.strip_prefix("w_")?.parse::<usize>().ok()?.checked_sub(1)) + .or_else(|| id.parse::<usize>().ok()?.checked_sub(1)) + } + + pub(super) fn parse_tab_id(&self, id: &str) -> Option<(usize, usize)> { + if let Some(rest) = id.strip_prefix("t_") { + let (ws_raw, tab_raw) = rest.rsplit_once('_')?; + let ws_idx = self.parse_workspace_id(ws_raw)?; + let tab_idx = tab_raw.parse::<usize>().ok()?.checked_sub(1)?; + self.state.workspaces.get(ws_idx)?.tabs.get(tab_idx)?; + return Some((ws_idx, tab_idx)); + } + + let (ws_raw, tab_raw) = id.rsplit_once(':')?; + let ws_idx = self.parse_workspace_id(ws_raw)?; + let tab_idx = tab_raw.parse::<usize>().ok()?.checked_sub(1)?; + self.state.workspaces.get(ws_idx)?.tabs.get(tab_idx)?; + Some((ws_idx, tab_idx)) + } + + pub(super) fn parse_pane_id(&self, id: &str) -> Option<(usize, crate::layout::PaneId)> { + if let Some(rest) = id.strip_prefix("p_") { + if let Some((ws_raw, pane_raw)) = rest.rsplit_once('_') { + let ws_idx = self.parse_workspace_id(ws_raw)?; + let pane_id = crate::layout::PaneId::from_raw(pane_raw.parse::<u32>().ok()?); + return Some((ws_idx, pane_id)); + } + + let pane_id = crate::layout::PaneId::from_raw(rest.parse::<u32>().ok()?); + return self.find_pane(pane_id).map(|(ws_idx, _)| (ws_idx, pane_id)); + } + + let (ws_raw, pane_number_raw) = id.rsplit_once('-')?; + let ws_idx = self.parse_workspace_id(ws_raw)?; + let pane_number = pane_number_raw.parse::<usize>().ok()?; + let ws = self.state.workspaces.get(ws_idx)?; + let pane_id = ws + .public_pane_numbers + .iter() + .find_map(|(pane_id, number)| (*number == pane_number).then_some(*pane_id))?; + Some((ws_idx, pane_id)) + } +} diff --git a/src/app/input.rs b/src/app/input.rs deleted file mode 100644 index dbc2547d..00000000 --- a/src/app/input.rs +++ /dev/null @@ -1,5416 +0,0 @@ -//! Input handling — translates crossterm key/mouse events into state mutations. - -use bytes::Bytes; -use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; - -use crate::input::TerminalKey; -use ratatui::layout::{Direction, Rect}; -use tracing::{debug, warn}; - -use crate::layout::{NavDirection, PaneInfo, SplitBorder}; -use crate::selection::Selection; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ScrollbarClickTarget { - Thumb { grab_row_offset: u16 }, - Track { offset_from_bottom: usize }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg(test)] -enum WheelRouting { - HostScroll, - MouseReport, - AlternateScroll, -} - -const WORKSPACE_DRAG_THRESHOLD: u16 = 1; -const TAB_DRAG_THRESHOLD: u16 = 1; - -use super::state::{ - AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, - MenuListState, Mode, TabPressState, WorkspacePressState, -}; -use super::App; - -// --------------------------------------------------------------------------- -// Key handling -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct AppKey { - raw: KeyEvent, - code: KeyCode, - modifiers: crossterm::event::KeyModifiers, -} - -impl AppKey { - fn code(self) -> KeyCode { - self.code - } - - fn modifiers(self) -> crossterm::event::KeyModifiers { - self.modifiers - } - - fn raw_event(self) -> KeyEvent { - self.raw - } - - fn matches_binding( - self, - expected_code: KeyCode, - expected_modifiers: crossterm::event::KeyModifiers, - ) -> bool { - let (expected_code, expected_modifiers) = - crate::input::normalize_app_key_binding(expected_code, expected_modifiers, None); - self.code == expected_code && self.modifiers == expected_modifiers - } - - fn text_char(self) -> Option<char> { - match self.code { - KeyCode::Char(ch) if self.modifiers.is_empty() => Some(ch), - _ => None, - } - } -} - -impl From<TerminalKey> for AppKey { - fn from(value: TerminalKey) -> Self { - let raw = value.as_key_event(); - let (code, modifiers) = crate::input::normalize_app_key_binding( - value.code, - value.modifiers, - value.shifted_codepoint, - ); - Self { - raw, - code, - modifiers, - } - } -} - -impl From<KeyEvent> for AppKey { - fn from(value: KeyEvent) -> Self { - let (code, modifiers) = - crate::input::normalize_app_key_binding(value.code, value.modifiers, None); - Self { - raw: value, - code, - modifiers, - } - } -} - -fn is_modifier_only_key(code: &KeyCode) -> bool { - matches!(code, KeyCode::Modifier(_)) -} - -fn terminal_direct_navigation_action(state: &AppState, key: &AppKey) -> Option<NavigateAction> { - let kb = &state.keybinds; - if kb - .previous_workspace - .is_some_and(|(code, mods)| key.matches_binding(code, mods)) - { - return Some(NavigateAction::PreviousWorkspace); - } - if kb - .next_workspace - .is_some_and(|(code, mods)| key.matches_binding(code, mods)) - { - return Some(NavigateAction::NextWorkspace); - } - if kb - .previous_tab - .is_some_and(|(code, mods)| key.matches_binding(code, mods)) - { - return Some(NavigateAction::PreviousTab); - } - if kb - .next_tab - .is_some_and(|(code, mods)| key.matches_binding(code, mods)) - { - return Some(NavigateAction::NextTab); - } - if kb - .focus_pane_left - .is_some_and(|(code, mods)| key.matches_binding(code, mods)) - { - return Some(NavigateAction::FocusPaneLeft); - } - if kb - .focus_pane_down - .is_some_and(|(code, mods)| key.matches_binding(code, mods)) - { - return Some(NavigateAction::FocusPaneDown); - } - if kb - .focus_pane_up - .is_some_and(|(code, mods)| key.matches_binding(code, mods)) - { - return Some(NavigateAction::FocusPaneUp); - } - if kb - .focus_pane_right - .is_some_and(|(code, mods)| key.matches_binding(code, mods)) - { - return Some(NavigateAction::FocusPaneRight); - } - None -} - -impl App { - pub(super) async fn handle_key(&mut self, key: TerminalKey) { - match self.state.mode { - Mode::Terminal => self.handle_terminal_key(key).await, - Mode::Navigate => self.handle_navigate_key(key), - _ => { - let key = AppKey::from(key); - match self.state.mode { - Mode::Onboarding => self.handle_onboarding_key(key), - Mode::ReleaseNotes => self.handle_release_notes_key(key), - Mode::RenameWorkspace | Mode::RenameTab => { - 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), - Mode::Settings => self.handle_settings_key(key), - Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key), - Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key), - Mode::Terminal | Mode::Navigate => unreachable!(), - } - } - } - } - - 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 _ = rt.send_paste(text).await; - } - } - } - - fn handle_onboarding_key(&mut self, key: AppKey) { - match self.state.onboarding_step { - 0 => match key.code() { - KeyCode::Right | KeyCode::Char('l') => { - self.state.onboarding_step = 1; - } - _ => match modal_action_from_key(&key, ONBOARDING_WELCOME_ACTIONS) { - Some(ModalAction::Continue) => self.state.onboarding_step = 1, - _ => {} - }, - }, - _ => match key.code() { - KeyCode::Up | KeyCode::Char('k') => self.state.onboarding_list.move_prev(), - KeyCode::Down | KeyCode::Char('j') => self.state.onboarding_list.move_next(4), - KeyCode::Left | KeyCode::Char('h') => { - self.state.onboarding_step = 0; - } - KeyCode::Char(c) if ('1'..='4').contains(&c) => { - self.state - .onboarding_list - .select((c as usize) - ('1' as usize)); - } - _ => match modal_action_from_key(&key, ONBOARDING_NOTIFICATION_ACTIONS) { - Some(ModalAction::Back) => self.state.onboarding_step = 0, - Some(ModalAction::Save) => self.complete_onboarding(), - _ => {} - }, - }, - } - } - - fn handle_release_notes_key(&mut self, key: AppKey) { - match key.code() { - KeyCode::Up | KeyCode::Char('k') => self.scroll_release_notes(-1), - KeyCode::Down | KeyCode::Char('j') => self.scroll_release_notes(1), - KeyCode::PageUp => self.scroll_release_notes(-8), - KeyCode::PageDown => self.scroll_release_notes(8), - KeyCode::Home => { - if let Some(notes) = &mut self.state.release_notes { - notes.scroll = 0; - } - } - KeyCode::End => { - let max_scroll = self.state.release_notes_max_scroll(); - if let Some(notes) = &mut self.state.release_notes { - notes.scroll = max_scroll; - } - } - _ => match modal_action_from_key(&key, RELEASE_NOTES_ACTIONS) { - Some(ModalAction::Close) => self.dismiss_release_notes(), - _ => {} - }, - } - } - - fn handle_settings_key(&mut self, key: AppKey) { - if let Some(action) = update_settings_state(&mut self.state, key) { - match action { - SettingsAction::SaveTheme(name) => self.save_theme(&name), - SettingsAction::SaveSound(enabled) => self.save_sound(enabled), - SettingsAction::SaveToast(enabled) => self.save_toast(enabled), - } - } - } - - fn handle_navigate_key(&mut self, raw_key: TerminalKey) { - let key = AppKey::from(raw_key); - self.state.update_dismissed = true; - - if key.matches_binding(self.state.prefix_code, self.state.prefix_mods) { - if !self.pass_through_key_to_focused_pane(raw_key) { - leave_navigate_mode(&mut self.state); - } - return; - } - - if key.code() == KeyCode::Esc { - leave_navigate_mode(&mut self.state); - return; - } - - if let Some(action) = navigate_action_for_key(&self.state, &key) { - execute_navigate_action(&mut self.state, action); - return; - } - - if handle_navigate_reserved_key(&mut self.state, key) { - return; - } - - if let Some(binding) = navigate_custom_command_for_key(&self.state, &key) { - self.launch_custom_command(binding); - } - } - - pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) { - if self.state.mode == Mode::ReleaseNotes { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) - if self - .state - .release_notes_close_button_at(mouse.column, mouse.row) => - { - self.dismiss_release_notes(); - } - MouseEventKind::Down(MouseButton::Left) => { - if let Some(target) = self - .state - .release_notes_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.state.drag = Some(DragState { - target: DragTarget::ReleaseNotesScrollbar { grab_row_offset }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.state - .set_release_notes_offset_from_bottom(offset_from_bottom); - } - } - } - } - MouseEventKind::Drag(MouseButton::Left) => { - if let Some(DragState { - target: DragTarget::ReleaseNotesScrollbar { grab_row_offset }, - }) = &self.state.drag - { - if let Some(offset_from_bottom) = self - .state - .release_notes_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.state - .set_release_notes_offset_from_bottom(offset_from_bottom); - } - } - } - MouseEventKind::Up(MouseButton::Left) => { - self.state.drag = None; - } - MouseEventKind::ScrollUp => self.scroll_release_notes(-3), - MouseEventKind::ScrollDown => self.scroll_release_notes(3), - _ => {} - } - return; - } - - if self.state.mode == Mode::KeybindHelp { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) - if self - .state - .keybind_help_close_button_at(mouse.column, mouse.row) => - { - leave_modal(&mut self.state); - } - MouseEventKind::Down(MouseButton::Left) => { - if let Some(target) = self - .state - .keybind_help_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.state.drag = Some(DragState { - target: DragTarget::KeybindHelpScrollbar { grab_row_offset }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.state - .set_keybind_help_offset_from_bottom(offset_from_bottom); - } - } - } else { - let rect = self.state.keybind_help_popup_rect(); - let inside = mouse.column >= rect.x - && mouse.column < rect.x + rect.width - && mouse.row >= rect.y - && mouse.row < rect.y + rect.height; - if !inside { - leave_modal(&mut self.state); - } - } - } - MouseEventKind::Drag(MouseButton::Left) => { - if let Some(DragState { - target: DragTarget::KeybindHelpScrollbar { grab_row_offset }, - }) = &self.state.drag - { - if let Some(offset_from_bottom) = self - .state - .keybind_help_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.state - .set_keybind_help_offset_from_bottom(offset_from_bottom); - } - } - } - MouseEventKind::Up(MouseButton::Left) => { - self.state.drag = None; - } - MouseEventKind::ScrollUp => self.state.scroll_keybind_help(-3), - MouseEventKind::ScrollDown => self.state.scroll_keybind_help(3), - _ => {} - } - return; - } - - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - && self.state.on_sidebar_divider(mouse.column, mouse.row) - { - let now = std::time::Instant::now(); - let is_double_click = self - .last_sidebar_divider_click - .is_some_and(|last| now.duration_since(last) <= super::SIDEBAR_DOUBLE_CLICK_WINDOW); - self.last_sidebar_divider_click = Some(now); - - if is_double_click { - self.state.sidebar_width = self.state.default_sidebar_width; - self.state.sidebar_width_auto = false; - self.state.mark_session_dirty(); - self.state.drag = None; - return; - } - } - - if let Some(action) = self.state.handle_mouse(mouse) { - match action { - SettingsAction::SaveTheme(name) => self.save_theme(&name), - SettingsAction::SaveSound(enabled) => self.save_sound(enabled), - SettingsAction::SaveToast(enabled) => self.save_toast(enabled), - } - } - } - - fn pass_through_key_to_focused_pane(&mut self, key: TerminalKey) -> bool { - let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) else { - return false; - }; - let Some(rt) = ws.focused_runtime() else { - return false; - }; - - let bytes = rt.encode_terminal_key(key); - if bytes.is_empty() { - return false; - } - if rt.try_send_bytes(Bytes::from(bytes)).is_err() { - return false; - } - - self.state.mode = Mode::Terminal; - true - } - - async fn handle_terminal_key(&mut self, key: TerminalKey) { - self.state.clear_selection(); - self.state.update_dismissed = true; - - let app_key = AppKey::from(key); - let key_event = app_key.raw_event(); - - if let Some(action) = terminal_direct_navigation_action(&self.state, &app_key) { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - action = ?action, - "intercepted terminal direct navigation key before forwarding to pane" - ); - execute_navigate_action(&mut self.state, action); - return; - } - - if app_key.matches_binding(self.state.prefix_code, self.state.prefix_mods) { - self.state.mode = Mode::Navigate; - return; - } - - if is_modifier_only_key(&key_event.code) { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - "dropping modifier-only terminal key event instead of forwarding it to pane" - ); - 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 protocol = rt.keyboard_protocol(); - let bytes = rt.encode_terminal_key(key); - if matches!(key_event.code, KeyCode::Esc) - || key_event - .modifiers - .contains(crossterm::event::KeyModifiers::ALT) - { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - protocol = ?protocol, - encoded = ?bytes, - "forwarding potentially-ambiguous terminal key to pane" - ); - } - if bytes.is_empty() { - if key.kind != crossterm::event::KeyEventKind::Release - && !matches!( - key.code, - KeyCode::CapsLock - | KeyCode::ScrollLock - | KeyCode::NumLock - | KeyCode::PrintScreen - | KeyCode::Pause - | KeyCode::Menu - | KeyCode::KeypadBegin - | KeyCode::Media(_) - | KeyCode::Modifier(_) - ) - { - warn!(code = ?key_event.code, mods = ?key_event.modifiers, state = ?key_event.state, "key produced empty encoding"); - } - } else { - let _ = rt.send_bytes(Bytes::from(bytes)).await; - } - } - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum SettingsAction { - SaveTheme(String), - SaveSound(bool), - SaveToast(bool), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ModalAction { - Continue, - Back, - Save, - Clear, - Cancel, - Confirm, - Apply, - Close, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ModalKeyBinding { - Enter, - Esc, - CtrlC, -} - -impl ModalKeyBinding { - fn matches(self, key: &AppKey) -> bool { - match self { - Self::Enter => key.code() == KeyCode::Enter, - Self::Esc => key.code() == KeyCode::Esc, - Self::CtrlC => { - key.code() == KeyCode::Char('c') - && key.modifiers() == crossterm::event::KeyModifiers::CONTROL - } - } - } -} - -#[derive(Debug, Clone, Copy)] -struct ModalActionSpec<A> { - action: A, - bindings: &'static [ModalKeyBinding], -} - -fn modal_action_from_key<A: Copy>(key: &AppKey, specs: &[ModalActionSpec<A>]) -> Option<A> { - specs - .iter() - .find(|spec| spec.bindings.iter().any(|binding| binding.matches(key))) - .map(|spec| spec.action) -} - -fn modal_action_from_buttons<A: Copy>(col: u16, row: u16, buttons: &[(Rect, A)]) -> Option<A> { - buttons.iter().find_map(|(rect, action)| { - (col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height) - .then_some(*action) - }) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum GlobalMenuAction { - Quit, - WhatsNew, - Keybinds, - ReloadKeybinds, - Settings, -} - -fn global_menu_actions(state: &AppState) -> Vec<GlobalMenuAction> { - let mut actions = vec![ - GlobalMenuAction::Settings, - GlobalMenuAction::Keybinds, - GlobalMenuAction::ReloadKeybinds, - ]; - if state.latest_release_notes_available || state.update_available.is_some() { - actions.push(GlobalMenuAction::WhatsNew); - } - actions.push(GlobalMenuAction::Quit); - actions -} - -fn normalize_theme_name(name: &str) -> String { - name.to_lowercase().replace([' ', '_'], "-") -} - -fn current_theme_index(theme_name: &str) -> usize { - use crate::app::state::THEME_NAMES; - - let normalized = normalize_theme_name(theme_name); - THEME_NAMES - .iter() - .position(|name| normalize_theme_name(name) == normalized) - .unwrap_or(0) -} - -fn preview_selected_theme(state: &mut AppState) { - use crate::app::state::{Palette, THEME_NAMES}; - - let name = THEME_NAMES[state.settings.list.selected]; - if let Some(palette) = Palette::from_name(name) { - state.palette = palette; - state.theme_name = name.to_string(); - } -} - -fn cancel_settings(state: &mut AppState) { - if let Some(palette) = state.settings.original_palette.take() { - state.palette = palette; - } - if let Some(theme_name) = state.settings.original_theme.take() { - state.theme_name = theme_name; - } - leave_modal(state); -} - -fn apply_settings(state: &mut AppState) -> Option<SettingsAction> { - match state.settings.section { - crate::app::state::SettingsSection::Theme => { - let theme_name = state.theme_name.clone(); - state.settings.original_palette = None; - state.settings.original_theme = None; - leave_modal(state); - Some(SettingsAction::SaveTheme(theme_name)) - } - _ => { - leave_modal(state); - None - } - } -} - -fn update_settings_state(state: &mut AppState, key: impl Into<AppKey>) -> Option<SettingsAction> { - use crate::app::state::SettingsSection; - - let key = key.into(); - - match state.settings.section { - SettingsSection::Theme => match key.code() { - KeyCode::Up | KeyCode::Char('k') => { - let previous = state.settings.list.selected; - state.settings.list.move_prev(); - if state.settings.list.selected != previous { - preview_selected_theme(state); - } - } - KeyCode::Down | KeyCode::Char('j') => { - let previous = state.settings.list.selected; - state - .settings - .list - .move_next(crate::app::state::THEME_NAMES.len()); - if state.settings.list.selected != previous { - preview_selected_theme(state); - } - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Sound; - state.settings.list.selected = usize::from(!state.sound_enabled()); - } - _ => match modal_action_from_key(&key, SETTINGS_ACTIONS) { - Some(ModalAction::Apply) => return apply_settings(state), - Some(ModalAction::Close) => cancel_settings(state), - _ => {} - }, - }, - SettingsSection::Sound => match key.code() { - KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { - state.settings.list.selected = 1 - state.settings.list.selected.min(1); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let enabled = state.settings.list.selected == 0; - state.sound.enabled = enabled; - return Some(SettingsAction::SaveSound(enabled)); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Toast; - state.settings.list.selected = usize::from(!state.toast_config.enabled); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Theme; - state.settings.list.selected = current_theme_index(&state.theme_name); - } - _ => match modal_action_from_key(&key, SETTINGS_ACTIONS) { - Some(ModalAction::Close) => cancel_settings(state), - _ => {} - }, - }, - SettingsSection::Toast => match key.code() { - KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { - state.settings.list.selected = 1 - state.settings.list.selected.min(1); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let enabled = state.settings.list.selected == 0; - state.toast_config.enabled = enabled; - return Some(SettingsAction::SaveToast(enabled)); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Sound; - state.settings.list.selected = usize::from(!state.sound_enabled()); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Theme; - state.settings.list.selected = current_theme_index(&state.theme_name); - } - _ => match modal_action_from_key(&key, SETTINGS_ACTIONS) { - Some(ModalAction::Close) => cancel_settings(state), - _ => {} - }, - }, - } - - None -} - -fn open_global_menu(state: &mut AppState) { - state.global_menu = MenuListState::new(0); - state.mode = Mode::GlobalMenu; -} - -fn open_keybind_help(state: &mut AppState) { - state.keybind_help.scroll = 0; - state.mode = Mode::KeybindHelp; -} - -fn open_update_release_notes(state: &mut AppState) { - let Some(notes) = crate::release_notes::load_latest() else { - return; - }; - - state.release_notes = Some(crate::app::state::ReleaseNotesState { - version: notes.version, - body: notes.body, - scroll: 0, - preview: notes.preview, - }); - state.mode = Mode::ReleaseNotes; -} - -fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) { - match action { - GlobalMenuAction::Quit => state.should_quit = true, - GlobalMenuAction::WhatsNew => open_update_release_notes(state), - GlobalMenuAction::Keybinds => open_keybind_help(state), - GlobalMenuAction::ReloadKeybinds => { - state.request_reload_keybinds = true; - leave_modal(state); - } - GlobalMenuAction::Settings => open_settings(state), - } -} - -fn handle_global_menu_key(state: &mut AppState, key: impl Into<AppKey>) { - let key = key.into(); - let actions = global_menu_actions(state); - match key.code() { - KeyCode::Esc => leave_modal(state), - KeyCode::Up | KeyCode::Char('k') => state.global_menu.move_prev(), - KeyCode::Down | KeyCode::Char('j') => state.global_menu.move_next(actions.len()), - KeyCode::Enter => { - if let Some(action) = actions.get(state.global_menu.highlighted).copied() { - apply_global_menu_action(state, action); - } - } - _ => {} - } -} - -fn handle_keybind_help_key(state: &mut AppState, key: impl Into<AppKey>) { - let key = key.into(); - match key.code() { - KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1), - KeyCode::Down | KeyCode::Char('j') => state.scroll_keybind_help(1), - KeyCode::PageUp => state.scroll_keybind_help(-8), - KeyCode::PageDown => state.scroll_keybind_help(8), - KeyCode::Home => state.keybind_help.scroll = 0, - KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(), - KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => leave_modal(state), - _ => {} - } -} - -fn navigate_custom_command_for_key( - state: &AppState, - key: &AppKey, -) -> Option<crate::config::CustomCommandKeybind> { - state - .keybinds - .custom_commands - .iter() - .find(|binding| key.matches_binding(binding.key.0, binding.key.1)) - .cloned() -} - -fn handle_navigate_reserved_key(state: &mut AppState, key: impl Into<AppKey>) -> bool { - let key = key.into(); - match key.code() { - KeyCode::Char('q') => { - state.should_quit = true; - true - } - KeyCode::Enter => { - if !state.workspaces.is_empty() { - state.switch_workspace(state.selected); - leave_navigate_mode(state); - } - true - } - KeyCode::Char(c @ '1'..='9') => { - let idx = (c as usize) - ('1' as usize); - if idx < state.workspaces.len() { - state.switch_workspace(idx); - leave_navigate_mode(state); - } - true - } - KeyCode::Char('s') => { - open_settings(state); - true - } - KeyCode::Char('?') => { - open_keybind_help(state); - true - } - KeyCode::Up => { - if state.selected > 0 { - state.selected -= 1; - state.ensure_workspace_visible(state.selected); - } - true - } - KeyCode::Down => { - if !state.workspaces.is_empty() && state.selected < state.workspaces.len() - 1 { - state.selected += 1; - state.ensure_workspace_visible(state.selected); - } - true - } - KeyCode::Char('h') | KeyCode::Left => { - state.navigate_pane(NavDirection::Left); - true - } - KeyCode::Char('j') => { - state.navigate_pane(NavDirection::Down); - true - } - KeyCode::Char('k') => { - state.navigate_pane(NavDirection::Up); - true - } - KeyCode::Char('l') | KeyCode::Right => { - state.navigate_pane(NavDirection::Right); - true - } - KeyCode::Tab => { - state.cycle_pane(false); - true - } - KeyCode::BackTab => { - state.cycle_pane(true); - true - } - _ => false, - } -} - -#[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key -fn handle_navigate_key(state: &mut AppState, key: impl Into<AppKey>) { - let key = key.into(); - state.update_dismissed = true; - - if key.matches_binding(state.prefix_code, state.prefix_mods) || key.code() == KeyCode::Esc { - leave_navigate_mode(state); - return; - } - - if let Some(action) = navigate_action_for_key(state, &key) { - execute_navigate_action(state, action); - return; - } - - let _ = handle_navigate_reserved_key(state, key); -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum NavigateAction { - NewWorkspace, - RenameWorkspace, - CloseWorkspace, - PreviousWorkspace, - NextWorkspace, - NewTab, - RenameTab, - PreviousTab, - NextTab, - CloseTab, - FocusPaneLeft, - FocusPaneDown, - FocusPaneUp, - FocusPaneRight, - SplitVertical, - SplitHorizontal, - ClosePane, - Fullscreen, - EnterResizeMode, - ToggleSidebar, -} - -fn navigate_action_for_key(state: &AppState, key: &AppKey) -> Option<NavigateAction> { - let kb = &state.keybinds; - if key.matches_binding(kb.new_workspace.0, kb.new_workspace.1) { - return Some(NavigateAction::NewWorkspace); - } - if key.matches_binding(kb.rename_workspace.0, kb.rename_workspace.1) { - return Some(NavigateAction::RenameWorkspace); - } - if key.matches_binding(kb.close_workspace.0, kb.close_workspace.1) { - return Some(NavigateAction::CloseWorkspace); - } - if kb - .previous_workspace - .is_some_and(|(code, mods)| key.matches_binding(code, mods)) - { - return Some(NavigateAction::PreviousWorkspace); - } - if kb - .next_workspace - .is_some_and(|(code, mods)| key.matches_binding(code, mods)) - { - return Some(NavigateAction::NextWorkspace); - } - if key.matches_binding(kb.new_tab.0, kb.new_tab.1) { - return Some(NavigateAction::NewTab); - } - if kb - .rename_tab - .is_some_and(|(code, mods)| key.matches_binding(code, mods)) - { - return Some(NavigateAction::RenameTab); - } - if kb - .previous_tab - .is_some_and(|(code, mods)| key.matches_binding(code, mods)) - { - return Some(NavigateAction::PreviousTab); - } - if kb - .next_tab - .is_some_and(|(code, mods)| key.matches_binding(code, mods)) - { - return Some(NavigateAction::NextTab); - } - if kb - .close_tab - .is_some_and(|(code, mods)| key.matches_binding(code, mods)) - { - return Some(NavigateAction::CloseTab); - } - if key.matches_binding(kb.split_vertical.0, kb.split_vertical.1) { - return Some(NavigateAction::SplitVertical); - } - if key.matches_binding(kb.split_horizontal.0, kb.split_horizontal.1) { - return Some(NavigateAction::SplitHorizontal); - } - if key.matches_binding(kb.close_pane.0, kb.close_pane.1) { - return Some(NavigateAction::ClosePane); - } - if key.matches_binding(kb.fullscreen.0, kb.fullscreen.1) { - return Some(NavigateAction::Fullscreen); - } - if key.matches_binding(kb.resize_mode.0, kb.resize_mode.1) { - return Some(NavigateAction::EnterResizeMode); - } - if key.matches_binding(kb.toggle_sidebar.0, kb.toggle_sidebar.1) { - return Some(NavigateAction::ToggleSidebar); - } - None -} - -fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { - match action { - NavigateAction::NewWorkspace => { - state.request_new_workspace = true; - leave_navigate_mode(state); - } - NavigateAction::RenameWorkspace => { - if !state.workspaces.is_empty() { - open_rename_workspace(state, state.selected); - } - } - NavigateAction::CloseWorkspace => { - if !state.workspaces.is_empty() { - if state.confirm_close { - open_confirm_close(state); - } else { - state.close_selected_workspace(); - leave_navigate_mode(state); - } - } - } - NavigateAction::PreviousWorkspace => { - state.previous_workspace(); - leave_navigate_mode(state); - } - NavigateAction::NextWorkspace => { - state.next_workspace(); - leave_navigate_mode(state); - } - NavigateAction::NewTab => open_new_tab_dialog(state), - NavigateAction::RenameTab => open_rename_active_tab(state, false), - NavigateAction::PreviousTab => { - state.previous_tab(); - leave_navigate_mode(state); - } - NavigateAction::NextTab => { - state.next_tab(); - leave_navigate_mode(state); - } - NavigateAction::CloseTab => { - state.close_tab(); - leave_navigate_mode(state); - } - NavigateAction::FocusPaneLeft => state.navigate_pane(NavDirection::Left), - NavigateAction::FocusPaneDown => state.navigate_pane(NavDirection::Down), - NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up), - NavigateAction::FocusPaneRight => state.navigate_pane(NavDirection::Right), - NavigateAction::SplitVertical => { - state.split_pane(Direction::Horizontal); - leave_navigate_mode(state); - } - NavigateAction::SplitHorizontal => { - state.split_pane(Direction::Vertical); - leave_navigate_mode(state); - } - NavigateAction::ClosePane => { - state.close_pane(); - leave_navigate_mode(state); - } - NavigateAction::Fullscreen => { - state.toggle_fullscreen(); - leave_navigate_mode(state); - } - NavigateAction::EnterResizeMode => state.mode = Mode::Resize, - NavigateAction::ToggleSidebar => { - state.sidebar_collapsed = !state.sidebar_collapsed; - leave_navigate_mode(state); - } - } -} - -fn leave_navigate_mode(state: &mut AppState) { - if state.active.is_some() { - state.mode = Mode::Terminal; - } -} - -fn open_rename_workspace(state: &mut AppState, ws_idx: usize) { - state.selected = ws_idx; - state.name_input = state.workspaces[ws_idx].display_name(); - state.name_input_replace_on_type = false; - state.mode = Mode::RenameWorkspace; -} - -fn open_rename_active_tab(state: &mut AppState, replace_on_type: bool) { - state.creating_new_tab = false; - state.requested_new_tab_name = None; - if let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) { - if let Some(name) = ws.active_tab_display_name() { - state.name_input = name; - state.name_input_replace_on_type = replace_on_type; - state.mode = Mode::RenameTab; - } - } -} - -fn next_new_tab_default_name(state: &AppState) -> String { - state - .active - .and_then(|i| state.workspaces.get(i)) - .map(|ws| (ws.tabs.len() + 1).to_string()) - .unwrap_or_else(|| "1".to_string()) -} - -fn open_new_tab_dialog(state: &mut AppState) { - state.creating_new_tab = true; - state.requested_new_tab_name = None; - state.name_input = next_new_tab_default_name(state); - state.name_input_replace_on_type = true; - state.mode = Mode::RenameTab; -} - -/// Return to the appropriate mode after completing a modal action. -/// Goes to Terminal if a workspace is active, otherwise Navigate. -fn leave_modal(state: &mut AppState) { - if state.active.is_some() { - state.mode = Mode::Terminal; - } else { - state.mode = Mode::Navigate; - } -} - -const ONBOARDING_WELCOME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec { - action: ModalAction::Continue, - bindings: &[ModalKeyBinding::Enter], -}]; - -const ONBOARDING_NOTIFICATION_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ - ModalActionSpec { - action: ModalAction::Back, - bindings: &[ModalKeyBinding::Esc], - }, - ModalActionSpec { - action: ModalAction::Save, - bindings: &[ModalKeyBinding::Enter], - }, -]; - -const RELEASE_NOTES_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec { - action: ModalAction::Close, - bindings: &[ModalKeyBinding::Enter, ModalKeyBinding::Esc], -}]; - -const RENAME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ - ModalActionSpec { - action: ModalAction::Save, - bindings: &[ModalKeyBinding::Enter], - }, - ModalActionSpec { - action: ModalAction::Clear, - bindings: &[ModalKeyBinding::CtrlC], - }, - ModalActionSpec { - action: ModalAction::Cancel, - bindings: &[ModalKeyBinding::Esc], - }, -]; - -const CONFIRM_CLOSE_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ - ModalActionSpec { - action: ModalAction::Confirm, - bindings: &[ModalKeyBinding::Enter], - }, - ModalActionSpec { - action: ModalAction::Cancel, - bindings: &[ModalKeyBinding::Esc], - }, -]; - -const SETTINGS_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ - ModalActionSpec { - action: ModalAction::Apply, - bindings: &[ModalKeyBinding::Enter], - }, - ModalActionSpec { - action: ModalAction::Close, - bindings: &[ModalKeyBinding::Esc], - }, -]; - -fn open_settings(state: &mut AppState) { - use crate::app::state::SettingsSection; - - // Save current state for cancel - state.settings.original_palette = Some(state.palette.clone()); - state.settings.original_theme = Some(state.theme_name.clone()); - state.settings.section = SettingsSection::Theme; - state.settings.list.selected = current_theme_index(&state.theme_name); - state.mode = Mode::Settings; -} - -fn apply_rename_action(state: &mut AppState, action: ModalAction) { - match action { - ModalAction::Save => { - let new_name = if state.name_input.trim().is_empty() { - state.name_input.clone() - } else { - state.name_input.trim().to_string() - }; - match state.mode { - Mode::RenameWorkspace if !state.workspaces.is_empty() => { - if !new_name.is_empty() { - state.workspaces[state.selected].set_custom_name(new_name); - state.mark_session_dirty(); - } - } - Mode::RenameTab if state.creating_new_tab => { - state.request_new_tab = true; - let default_name = next_new_tab_default_name(state); - state.requested_new_tab_name = - if new_name.is_empty() || new_name == default_name { - None - } else { - Some(new_name) - }; - } - Mode::RenameTab => { - if let Some(ws) = state.active.and_then(|i| state.workspaces.get_mut(i)) { - if let Some(tab) = ws.active_tab_mut() { - let keep_auto_name = - tab.is_auto_named() && new_name == tab.number.to_string(); - if !new_name.is_empty() && !keep_auto_name { - tab.set_custom_name(new_name); - state.mark_session_dirty(); - } - } - } - } - _ => {} - } - state.creating_new_tab = false; - state.name_input.clear(); - state.name_input_replace_on_type = false; - leave_modal(state); - } - ModalAction::Clear => { - state.name_input.clear(); - state.name_input_replace_on_type = false; - } - ModalAction::Cancel => { - state.creating_new_tab = false; - state.requested_new_tab_name = None; - state.name_input.clear(); - state.name_input_replace_on_type = false; - leave_modal(state); - } - _ => {} - } -} - -fn handle_rename_key(state: &mut AppState, key: impl Into<AppKey>) { - let key = key.into(); - if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) { - apply_rename_action(state, action); - return; - } - - match key.code() { - KeyCode::Backspace => { - if state.name_input_replace_on_type { - state.name_input.clear(); - state.name_input_replace_on_type = false; - } else { - state.name_input.pop(); - } - } - _ => { - if let Some(c) = key.text_char() { - if state.name_input_replace_on_type { - state.name_input.clear(); - state.name_input_replace_on_type = false; - } - state.name_input.push(c); - } - } - } -} - -fn handle_resize_key(state: &mut AppState, key: impl Into<AppKey>) { - let key = key.into(); - if key.code() == KeyCode::Esc - || key.code() == KeyCode::Enter - || key.matches_binding(state.keybinds.resize_mode.0, state.keybinds.resize_mode.1) - { - if state.active.is_some() { - state.mode = Mode::Terminal; - } else { - state.mode = Mode::Navigate; - } - return; - } - - match key.code() { - 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 open_confirm_close(state: &mut AppState) { - state.mode = Mode::ConfirmClose; -} - -fn confirm_close_accept(state: &mut AppState) { - state.close_selected_workspace(); - if state.workspaces.is_empty() { - state.mode = Mode::Navigate; - } else { - state.mode = Mode::Terminal; - } -} - -fn confirm_close_cancel(state: &mut AppState) { - state.mode = Mode::Navigate; -} - -fn handle_confirm_close_key(state: &mut AppState, key: impl Into<AppKey>) { - let key = key.into(); - match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) { - Some(ModalAction::Confirm) => confirm_close_accept(state), - Some(ModalAction::Cancel) => confirm_close_cancel(state), - _ => {} - } -} - -fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx: usize) { - let item = menu.items().get(idx).copied(); - match (menu.kind, item) { - (ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => { - open_rename_workspace(state, ws_idx); - } - (ContextMenuKind::Workspace { ws_idx }, Some("Close")) => { - state.selected = ws_idx; - if state.confirm_close { - open_confirm_close(state); - } else { - state.close_selected_workspace(); - state.mode = Mode::Navigate; - } - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - open_new_tab_dialog(state); - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - open_rename_active_tab(state, false); - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - state.close_tab(); - state.mode = if state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - (ContextMenuKind::Pane, Some("Split vertical")) => { - state.split_pane(Direction::Horizontal); - state.mode = Mode::Terminal; - } - (ContextMenuKind::Pane, Some("Split horizontal")) => { - state.split_pane(Direction::Vertical); - state.mode = Mode::Terminal; - } - (ContextMenuKind::Pane, Some("Fullscreen")) => { - state.toggle_fullscreen(); - state.mode = Mode::Terminal; - } - (ContextMenuKind::Pane, Some("Close pane")) => { - state.close_pane(); - state.mode = if state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - _ => leave_modal(state), - } -} - -fn handle_context_menu_key(state: &mut AppState, key: impl Into<AppKey>) { - let key = key.into(); - match key.code() { - KeyCode::Esc => { - state.context_menu = None; - leave_modal(state); - } - KeyCode::Up => { - if let Some(menu) = &mut state.context_menu { - menu.list.move_prev(); - } - } - KeyCode::Down => { - if let Some(menu) = &mut state.context_menu { - menu.list.move_next(menu.items().len()); - } - } - KeyCode::Enter => { - if let Some(menu) = state.context_menu.take() { - let idx = menu.list.highlighted; - apply_context_menu_action(state, menu, idx); - } - } - _ => {} - } -} - -// --------------------------------------------------------------------------- -// Mouse handling -// --------------------------------------------------------------------------- - -impl AppState { - fn onboarding_full_area(&self) -> ratatui::layout::Rect { - self.view.sidebar_rect.union(self.view.terminal_area) - } - - fn onboarding_modal_inner(&self, popup_w: u16, popup_h: u16) -> Option<ratatui::layout::Rect> { - let area = self.onboarding_full_area(); - let popup_w = popup_w.min(area.width.saturating_sub(4)); - let popup_h = popup_h.min(area.height.saturating_sub(2)); - if popup_w < 4 || popup_h < 4 { - return None; - } - let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; - let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; - let popup = ratatui::layout::Rect::new(popup_x, popup_y, popup_w, popup_h); - let block = ratatui::widgets::Block::default().borders(ratatui::widgets::Borders::ALL); - Some(block.inner(popup)) - } - - fn release_notes_modal_inner(&self) -> Option<ratatui::layout::Rect> { - self.onboarding_modal_inner( - crate::ui::RELEASE_NOTES_MODAL_SIZE.0, - crate::ui::RELEASE_NOTES_MODAL_SIZE.1, - ) - } - - fn release_notes_close_button_at(&self, col: u16, row: u16) -> bool { - let Some(inner) = self.release_notes_modal_inner() else { - return false; - }; - if inner.height < 4 || inner.width < 12 { - return false; - } - let button = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - col >= button.x - && col < button.x + button.width - && row >= button.y - && row < button.y + button.height - } - - fn rename_modal_inner(&self) -> Option<Rect> { - self.onboarding_modal_inner(56, 7) - } - - fn release_notes_body_rect(&self) -> Option<Rect> { - let inner = self.release_notes_modal_inner()?; - if inner.height < 8 || inner.width < 4 { - return None; - } - let body = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content; - let preview = self - .release_notes - .as_ref() - .is_some_and(|notes| notes.preview); - Some(crate::ui::release_notes_sections(body, preview).notes_body) - } - - fn release_notes_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> { - let notes = self.release_notes.as_ref()?; - let body = self.release_notes_body_rect()?; - let viewport_rows = body.height.max(1) as usize; - let lines = crate::ui::release_notes_lines(¬es.body, &self.palette); - - let rows_for_width = |wrap_width: usize| { - lines - .iter() - .map(|(width, _)| width.max(&1).div_ceil(wrap_width.max(1))) - .sum::<usize>() - }; - - let full_width = body.width.max(1) as usize; - let mut total_rows = rows_for_width(full_width); - let wrap_width = if total_rows > viewport_rows && full_width > 1 { - body.width.saturating_sub(1).max(1) as usize - } else { - full_width - }; - total_rows = rows_for_width(wrap_width); - - let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); - Some(crate::pane::ScrollMetrics { - offset_from_bottom: max_offset_from_bottom.saturating_sub(notes.scroll as usize), - max_offset_from_bottom, - viewport_rows, - }) - } - - pub(crate) fn release_notes_max_scroll(&self) -> u16 { - self.release_notes_scroll_metrics() - .map(|metrics| metrics.max_offset_from_bottom as u16) - .unwrap_or(0) - } - - fn release_notes_scrollbar_target_at( - &self, - col: u16, - row: u16, - ) -> Option<ScrollbarClickTarget> { - let body = self.release_notes_body_rect()?; - let metrics = self.release_notes_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - if !(col >= track.x - && col < track.x + track.width - && row >= track.y - && row < track.y + track.height) - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - fn release_notes_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> { - let body = self.release_notes_body_rect()?; - let metrics = self.release_notes_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - fn set_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let max_scroll = self.release_notes_max_scroll() as usize; - if let Some(notes) = &mut self.release_notes { - notes.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; - } - } - - fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) { - if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - return; - } - - match self.onboarding_step { - 0 => { - let Some(inner) = self.onboarding_modal_inner(64, 16) else { - return; - }; - let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1) - .actions - .unwrap_or_default(); - let button = crate::ui::onboarding_welcome_continue_rect(actions); - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - && modal_action_from_buttons( - mouse.column, - mouse.row, - &[(button, ModalAction::Continue)], - ) == Some(ModalAction::Continue) - { - self.onboarding_step = 1; - } - } - _ => { - let Some(inner) = self.onboarding_modal_inner(56, 14) else { - return; - }; - let stack = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1); - if mouse.row >= stack.content.y && mouse.row < stack.content.y + 4 { - self.onboarding_list - .select((mouse.row - stack.content.y) as usize); - return; - } - - let (back, save) = crate::ui::onboarding_notification_button_rects( - stack.actions.unwrap_or_default(), - ); - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - match modal_action_from_buttons( - mouse.column, - mouse.row, - &[(back, ModalAction::Back), (save, ModalAction::Save)], - ) { - Some(ModalAction::Back) => self.onboarding_step = 0, - Some(ModalAction::Save) => self.request_complete_onboarding = true, - _ => {} - } - } - } - } - } - - fn workspace_list_rect(&self) -> Rect { - let sidebar = self.view.sidebar_rect; - if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 { - return Rect::default(); - } - crate::ui::workspace_list_rect(sidebar, self.sidebar_section_split) - } - - fn agent_panel_rect(&self) -> Rect { - let sidebar = self.view.sidebar_rect; - if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 { - return Rect::default(); - } - let (_, detail_area) = - crate::ui::expanded_sidebar_sections(sidebar, self.sidebar_section_split); - detail_area - } - - fn workspace_list_scrollbar_target_at( - &self, - col: u16, - row: u16, - ) -> Option<ScrollbarClickTarget> { - let area = self.workspace_list_rect(); - let metrics = crate::ui::workspace_list_scroll_metrics(self, area); - let track = crate::ui::workspace_list_scrollbar_rect(self, area)?; - if col < track.x - || col >= track.x + track.width - || row < track.y - || row >= track.y + track.height - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - fn workspace_list_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> { - let area = self.workspace_list_rect(); - let metrics = crate::ui::workspace_list_scroll_metrics(self, area); - let track = crate::ui::workspace_list_scrollbar_rect(self, area)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - fn set_workspace_list_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let area = self.workspace_list_rect(); - let metrics = crate::ui::workspace_list_scroll_metrics(self, area); - self.workspace_scroll = metrics - .max_offset_from_bottom - .saturating_sub(offset_from_bottom); - } - - fn scroll_workspace_list(&mut self, delta: i16) { - if delta.is_negative() { - self.workspace_scroll = self - .workspace_scroll - .saturating_sub(delta.unsigned_abs() as usize); - return; - } - - for _ in 0..delta as usize { - let cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect); - let Some(last) = cards.last() else { - break; - }; - if last.ws_idx + 1 >= self.workspaces.len() { - break; - } - self.workspace_scroll = self.workspace_scroll.saturating_add(1); - } - } - - fn agent_panel_scrollbar_target_at(&self, col: u16, row: u16) -> Option<ScrollbarClickTarget> { - let area = self.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(self, area); - let track = crate::ui::agent_panel_scrollbar_rect(self, area)?; - if col < track.x - || col >= track.x + track.width - || row < track.y - || row >= track.y + track.height - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - fn agent_panel_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> { - let area = self.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(self, area); - let track = crate::ui::agent_panel_scrollbar_rect(self, area)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - fn set_agent_panel_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let area = self.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(self, area); - self.agent_panel_scroll = metrics - .max_offset_from_bottom - .saturating_sub(offset_from_bottom); - } - - fn scroll_agent_panel(&mut self, delta: i16) { - let area = self.agent_panel_rect(); - let max_scroll = crate::ui::agent_panel_scroll_metrics(self, area).max_offset_from_bottom; - if delta.is_negative() { - self.agent_panel_scroll = self - .agent_panel_scroll - .saturating_sub(delta.unsigned_abs() as usize); - } else { - self.agent_panel_scroll = self - .agent_panel_scroll - .saturating_add(delta as usize) - .min(max_scroll); - } - } - - pub(crate) fn sidebar_footer_rect(&self) -> Rect { - let ws_area = self.workspace_list_rect(); - if ws_area == Rect::default() { - return Rect::default(); - } - let y = ws_area.y + ws_area.height.saturating_sub(1); - Rect::new(ws_area.x, y, ws_area.width, 1) - } - - pub(crate) fn sidebar_new_button_rect(&self) -> Rect { - let footer = self.sidebar_footer_rect(); - let width = 5u16.min(footer.width.max(1)); - Rect::new(footer.x, footer.y, width, footer.height) - } - - pub(crate) fn global_launcher_rect(&self) -> Rect { - let footer = self.sidebar_footer_rect(); - let width = if self.update_available.is_some() { - 8 - } else { - 6 - } - .min(footer.width.max(1)); - let x = footer.x + footer.width.saturating_sub(width); - Rect::new(x, footer.y, width, footer.height) - } - - pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> { - let mut labels = vec!["settings", "keybinds", "reload keybinds"]; - if self.update_available.is_some() { - labels.push("update available"); - } else if self.latest_release_notes_available { - labels.push("what's new"); - } - labels.push("quit"); - labels - } - - pub(crate) fn global_menu_rect(&self) -> Rect { - let screen = self.screen_rect(); - let launcher = self.global_launcher_rect(); - let labels = self.global_menu_labels(); - let content_width = labels - .iter() - .map(|label| { - let extra = if *label == "update available" { 2 } else { 0 }; - label.chars().count() as u16 + extra - }) - .max() - .unwrap_or(8) - .saturating_add(2); - let menu_w = content_width.saturating_add(2).min(screen.width.max(1)); - let menu_h = (labels.len() as u16 + 2).min(screen.height.max(1)); - let max_x = screen.x + screen.width.saturating_sub(menu_w); - let desired_x = launcher.x + launcher.width.saturating_sub(menu_w); - let x = desired_x.min(max_x); - let y = launcher.y.saturating_sub(menu_h); - Rect::new(x, y, menu_w, menu_h) - } - - fn global_menu_item_at(&self, col: u16, row: u16) -> Option<GlobalMenuAction> { - let rect = self.global_menu_rect(); - if col <= rect.x - || col >= rect.x + rect.width.saturating_sub(1) - || row <= rect.y - || row >= rect.y + rect.height.saturating_sub(1) - { - return None; - } - let idx = (row - rect.y - 1) as usize; - global_menu_actions(self).get(idx).copied() - } - - pub(crate) fn keybind_help_popup_rect(&self) -> Rect { - crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default() - } - - fn keybind_help_modal_inner(&self) -> Option<Rect> { - self.onboarding_modal_inner(76, 22) - } - - fn keybind_help_close_button_at(&self, col: u16, row: u16) -> bool { - let Some(inner) = self.keybind_help_modal_inner() else { - return false; - }; - if inner.height < 4 || inner.width < 12 { - return false; - } - let button = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - col >= button.x - && col < button.x + button.width - && row >= button.y - && row < button.y + button.height - } - - fn keybind_help_body_rect(&self) -> Option<Rect> { - let inner = self.keybind_help_modal_inner()?; - if inner.height < 6 || inner.width < 4 { - return None; - } - Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) - } - - fn keybind_help_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> { - let body = self.keybind_help_body_rect()?; - let viewport_rows = body.height.max(1) as usize; - let wrap_width = body.width.max(1) as usize; - let total_rows = crate::ui::keybind_help_lines(self) - .into_iter() - .map(|(width, _)| width.max(1).div_ceil(wrap_width)) - .sum::<usize>(); - let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); - Some(crate::pane::ScrollMetrics { - offset_from_bottom: max_offset_from_bottom - .saturating_sub(self.keybind_help.scroll as usize), - max_offset_from_bottom, - viewport_rows, - }) - } - - fn keybind_help_scrollbar_target_at(&self, col: u16, row: u16) -> Option<ScrollbarClickTarget> { - let body = self.keybind_help_body_rect()?; - let metrics = self.keybind_help_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - if !(col >= track.x - && col < track.x + track.width - && row >= track.y - && row < track.y + track.height) - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - fn keybind_help_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> { - let body = self.keybind_help_body_rect()?; - let metrics = self.keybind_help_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - pub(crate) fn keybind_help_max_scroll(&self) -> u16 { - self.keybind_help_scroll_metrics() - .map(|metrics| metrics.max_offset_from_bottom as u16) - .unwrap_or(0) - } - - fn set_keybind_help_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let max_scroll = self.keybind_help_max_scroll() as usize; - self.keybind_help.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; - } - - fn scroll_keybind_help(&mut self, delta: i16) { - let max_scroll = self.keybind_help_max_scroll(); - let current = self.keybind_help.scroll as i16; - self.keybind_help.scroll = current.saturating_add(delta).clamp(0, max_scroll as i16) as u16; - } - - fn settings_popup_rect(&self) -> Rect { - crate::ui::centered_popup_rect(self.screen_rect(), 56, 20).unwrap_or_default() - } - - fn settings_inner_rect(&self) -> Rect { - let popup = self.settings_popup_rect(); - Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ) - } - - fn settings_tab_at(&self, col: u16, row: u16) -> Option<crate::app::state::SettingsSection> { - use crate::app::state::SettingsSection; - - let inner = self.settings_inner_rect(); - let tab_y = inner.y + 1; - if row != tab_y { - return None; - } - let mut x = inner.x; - for section in SettingsSection::ALL { - let width = section.label().len() as u16 + 2; - if col >= x && col < x + width { - return Some(*section); - } - x += width + 1; - } - None - } - - fn settings_content_rect(&self) -> Rect { - let inner = self.settings_inner_rect(); - crate::ui::modal_stack_areas(inner, 3, 2, 0, 1).content - } - - fn settings_list_index_at(&self, col: u16, row: u16) -> Option<usize> { - let area = self.settings_content_rect(); - if row < area.y || row >= area.y + area.height || col < area.x || col >= area.x + area.width - { - return None; - } - - match self.settings.section { - crate::app::state::SettingsSection::Theme => { - let max_visible = area.height as usize; - let scroll = if self.settings.list.selected >= max_visible { - self.settings.list.selected - max_visible + 1 - } else { - 0 - }; - let idx = scroll + (row - area.y) as usize; - (idx < crate::app::state::THEME_NAMES.len()).then_some(idx) - } - crate::app::state::SettingsSection::Sound - | crate::app::state::SettingsSection::Toast => { - let list_y = area.y + 2; - if row >= list_y && row < list_y + 2 { - Some((row - list_y) as usize) - } else { - None - } - } - } - } - - fn handle_settings_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> { - use crate::app::state::SettingsSection; - - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { - if let Some(section) = self.settings_tab_at(mouse.column, mouse.row) { - self.settings.section = section; - self.settings.list.select(match section { - SettingsSection::Theme => current_theme_index(&self.theme_name), - SettingsSection::Sound => usize::from(!self.sound_enabled()), - SettingsSection::Toast => usize::from(!self.toast_config.enabled), - }); - return None; - } - if let Some(idx) = self.settings_list_index_at(mouse.column, mouse.row) { - self.settings.list.select(idx); - return match self.settings.section { - SettingsSection::Theme => { - preview_selected_theme(self); - None - } - SettingsSection::Sound => { - let enabled = idx == 0; - self.sound.enabled = enabled; - Some(SettingsAction::SaveSound(enabled)) - } - SettingsSection::Toast => { - let enabled = idx == 0; - self.toast_config.enabled = enabled; - Some(SettingsAction::SaveToast(enabled)) - } - }; - } - - let inner = self.settings_inner_rect(); - let (apply, close) = crate::ui::settings_button_rects(inner); - match modal_action_from_buttons( - mouse.column, - mouse.row, - &[(apply, ModalAction::Apply), (close, ModalAction::Close)], - ) { - Some(ModalAction::Apply) => apply_settings(self), - Some(ModalAction::Close) => { - cancel_settings(self); - None - } - _ => { - cancel_settings(self); - None - } - } - } - _ => None, - } - } - - fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> { - if self.mode == Mode::Onboarding { - self.handle_onboarding_mouse(mouse); - return None; - } - - if self.mode == Mode::Settings { - return self.handle_settings_mouse(mouse); - } - - let launcher_enabled = !self.sidebar_collapsed - && matches!( - self.mode, - Mode::Terminal - | Mode::Navigate - | Mode::Resize - | Mode::GlobalMenu - | Mode::KeybindHelp - ); - let launcher = self.global_launcher_rect(); - let launcher_hit = launcher_enabled - && mouse.column >= launcher.x - && mouse.column < launcher.x + launcher.width - && mouse.row >= launcher.y - && mouse.row < launcher.y + launcher.height; - - if matches!(mouse.kind, MouseEventKind::Moved) && self.mode == Mode::GlobalMenu { - let actions = global_menu_actions(self); - let hovered = self - .global_menu_item_at(mouse.column, mouse.row) - .and_then(|action| actions.iter().position(|item| *item == action)); - self.global_menu.hover(hovered); - return None; - } - - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && launcher_hit { - if self.mode == Mode::GlobalMenu { - leave_modal(self); - } else { - open_global_menu(self); - } - return None; - } - - if self.mode == Mode::GlobalMenu { - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - if let Some(action) = self.global_menu_item_at(mouse.column, mouse.row) { - apply_global_menu_action(self, action); - } else { - leave_modal(self); - } - } - return None; - } - - if self.mode == Mode::KeybindHelp { - return None; - } - - 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; - self.workspace_press = None; - - if self.mode == Mode::ConfirmClose { - let popup = self.confirm_close_rect(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (confirm, cancel) = crate::ui::confirm_close_button_rects(inner); - match modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (confirm, ModalAction::Confirm), - (cancel, ModalAction::Cancel), - ], - ) { - Some(ModalAction::Confirm) => confirm_close_accept(self), - Some(ModalAction::Cancel) | None => confirm_close_cancel(self), - _ => {} - } - return None; - } - - if matches!(self.mode, Mode::RenameWorkspace | Mode::RenameTab) { - let action = self - .rename_modal_inner() - .map(crate::ui::rename_button_rects) - .and_then(|(save, clear, cancel)| { - modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (save, ModalAction::Save), - (clear, ModalAction::Clear), - (cancel, ModalAction::Cancel), - ], - ) - }) - .unwrap_or(ModalAction::Cancel); - apply_rename_action(self, action); - return None; - } - - if self.mode == Mode::ContextMenu { - let item_idx = self.context_menu_item_at(mouse.column, mouse.row); - if let Some(menu) = self.context_menu.take() { - if let Some(idx) = item_idx { - apply_context_menu_action(self, menu, idx); - } else { - leave_modal(self); - } - } - return None; - } - - if self.on_sidebar_divider(mouse.column, mouse.row) { - self.drag = Some(DragState { - target: DragTarget::SidebarDivider, - }); - self.set_manual_sidebar_width(mouse.column); - return None; - } - - if self.on_sidebar_section_divider(mouse.column, mouse.row) { - self.drag = Some(DragState { - target: DragTarget::SidebarSectionDivider, - }); - self.set_sidebar_section_split(mouse.row); - return None; - } - - if !in_sidebar { - if let Some(border) = self.find_border_at(mouse.column, mouse.row) { - self.drag = Some(DragState { - target: DragTarget::PaneSplit { - path: border.path.clone(), - direction: border.direction, - area: border.area, - }, - }); - return None; - } - - if let Some((pane_id, target)) = - self.scrollbar_target_at(mouse.column, mouse.row) - { - self.focus_pane(pane_id); - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.drag = Some(DragState { - target: DragTarget::PaneScrollbar { - pane_id, - grab_row_offset, - }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.set_pane_scroll_offset(pane_id, offset_from_bottom); - } - } - if self.mode != Mode::Terminal { - self.mode = Mode::Terminal; - } - return None; - } - } - - if self.on_tab_scroll_left_button(mouse.column, mouse.row) { - self.scroll_tabs_left(); - return None; - } - if self.on_tab_scroll_right_button(mouse.column, mouse.row) { - self.scroll_tabs_right(); - return None; - } - if let (Some(ws_idx), Some(tab_idx)) = - (self.active, self.tab_at(mouse.column, mouse.row)) - { - self.tab_press = Some(TabPressState { - ws_idx, - tab_idx, - start_col: mouse.column, - start_row: mouse.row, - }); - return None; - } - if self.on_new_tab_button(mouse.column, mouse.row) { - open_new_tab_dialog(self); - return None; - } - - if in_sidebar { - if self.sidebar_collapsed { - if self.on_collapsed_sidebar_toggle(mouse.column, mouse.row) { - self.sidebar_collapsed = false; - return None; - } - - if let Some(idx) = self.collapsed_workspace_at_row(mouse.row) { - self.switch_workspace(idx); - self.mode = Mode::Terminal; - return None; - } - - if let Some((ws_idx, tab_idx, pane_id)) = - self.collapsed_agent_detail_target_at(mouse.row) - { - self.switch_workspace(ws_idx); - self.switch_tab(tab_idx); - self.focus_pane(pane_id); - self.mode = Mode::Terminal; - } - return None; - } - - let new_button = self.sidebar_new_button_rect(); - let on_new_button = mouse.row >= new_button.y - && mouse.row < new_button.y + new_button.height - && mouse.column >= new_button.x - && mouse.column < new_button.x + new_button.width; - if on_new_button { - self.request_new_workspace = true; - return None; - } - - if let Some(target) = - self.workspace_list_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.drag = Some(DragState { - target: DragTarget::WorkspaceListScrollbar { grab_row_offset }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.set_workspace_list_offset_from_bottom(offset_from_bottom); - } - } - return None; - } - - if let Some(idx) = self.workspace_at_row(mouse.row) { - self.workspace_press = Some(WorkspacePressState { - ws_idx: idx, - start_col: mouse.column, - start_row: mouse.row, - }); - return None; - } - - if self.on_agent_panel_scope_toggle(mouse.column, mouse.row) { - self.agent_panel_scope = match self.agent_panel_scope { - AgentPanelScope::CurrentWorkspace => AgentPanelScope::AllWorkspaces, - AgentPanelScope::AllWorkspaces => AgentPanelScope::CurrentWorkspace, - }; - self.agent_panel_scroll = 0; - self.mark_session_dirty(); - return None; - } - - if let Some(target) = - self.agent_panel_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.drag = Some(DragState { - target: DragTarget::AgentPanelScrollbar { grab_row_offset }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.set_agent_panel_offset_from_bottom(offset_from_bottom); - } - } - return None; - } - - if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row) - { - self.switch_workspace(ws_idx); - self.switch_tab(tab_idx); - self.focus_pane(pane_id); - self.mode = Mode::Terminal; - return None; - } - } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - self.focus_pane(info.id); - if self.mode != Mode::Terminal { - self.mode = Mode::Terminal; - } - - if self.forward_pane_mouse_button(&info, mouse) { - self.selection = None; - return None; - } - - let (row, col) = ( - mouse.row - info.inner_rect.y, - mouse.column - info.inner_rect.x, - ); - self.selection = Some(Selection::anchor( - info.id, - row, - col, - self.pane_scroll_metrics(info.id), - )); - } 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; - self.focus_pane(id); - if self.mode != Mode::Terminal { - self.mode = Mode::Terminal; - } - } - } - - MouseEventKind::Drag(MouseButton::Left) => { - if self.selection.is_some() { - self.update_selection_drag(mouse.column, mouse.row); - return None; - } - - if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - if self.forward_pane_mouse_button(&info, mouse) { - self.selection = None; - return None; - } - } - - let workspace_drop_index = self.workspace_drop_index_at_row(mouse.row); - let tab_drop_index = self.tab_drop_index_at(mouse.column, mouse.row); - if self.drag.is_none() { - if let Some(press) = &self.workspace_press { - let delta_col = mouse.column.abs_diff(press.start_col); - let delta_row = mouse.row.abs_diff(press.start_row); - if delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD { - self.drag = Some(DragState { - target: DragTarget::WorkspaceReorder { - source_ws_idx: press.ws_idx, - insert_idx: workspace_drop_index, - }, - }); - } - } else if let Some(press) = &self.tab_press { - let delta_col = mouse.column.abs_diff(press.start_col); - let delta_row = mouse.row.abs_diff(press.start_row); - if delta_col.max(delta_row) >= TAB_DRAG_THRESHOLD { - self.drag = Some(DragState { - target: DragTarget::TabReorder { - ws_idx: press.ws_idx, - source_tab_idx: press.tab_idx, - insert_idx: tab_drop_index, - }, - }); - } - } - } - - if let Some(DragState { - target: DragTarget::WorkspaceReorder { insert_idx, .. }, - }) = &mut self.drag - { - *insert_idx = workspace_drop_index; - } else if let Some(DragState { - target: - DragTarget::TabReorder { - ws_idx, insert_idx, .. - }, - }) = &mut self.drag - { - if self.active == Some(*ws_idx) { - *insert_idx = tab_drop_index; - } - } else if let Some(drag) = &self.drag { - match &drag.target { - DragTarget::WorkspaceReorder { .. } | DragTarget::TabReorder { .. } => {} - DragTarget::WorkspaceListScrollbar { grab_row_offset } => { - if let Some(offset_from_bottom) = - self.workspace_list_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.set_workspace_list_offset_from_bottom(offset_from_bottom); - } - } - DragTarget::AgentPanelScrollbar { grab_row_offset } => { - if let Some(offset_from_bottom) = - self.agent_panel_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.set_agent_panel_offset_from_bottom(offset_from_bottom); - } - } - DragTarget::PaneSplit { - path, - direction, - area, - } => { - let ratio = match direction { - Direction::Horizontal => { - (mouse.column.saturating_sub(area.x)) as f32 - / area.width.max(1) as f32 - } - Direction::Vertical => { - (mouse.row.saturating_sub(area.y)) as f32 - / area.height.max(1) as f32 - } - }; - let ratio = ratio.clamp(0.1, 0.9); - let path = path.clone(); - if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { - ws.layout.set_ratio_at(&path, ratio); - self.mark_session_dirty(); - } - } - DragTarget::PaneScrollbar { - pane_id, - grab_row_offset, - } => { - if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row( - *pane_id, - mouse.row, - *grab_row_offset, - ) { - self.set_pane_scroll_offset(*pane_id, offset_from_bottom); - } - } - DragTarget::SidebarDivider => { - self.set_manual_sidebar_width(mouse.column); - } - DragTarget::SidebarSectionDivider => { - self.set_sidebar_section_split(mouse.row); - } - DragTarget::ReleaseNotesScrollbar { .. } - | DragTarget::KeybindHelpScrollbar { .. } => {} - } - } - } - - MouseEventKind::Up(MouseButton::Left) => { - if self.selection.is_some() { - self.workspace_press = None; - self.tab_press = None; - self.drag = None; - 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(); - } - return None; - } - - if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - if self.forward_pane_mouse_button(&info, mouse) { - self.selection = None; - self.workspace_press = None; - self.tab_press = None; - self.drag = None; - return None; - } - } - - let workspace_press = self.workspace_press.take(); - let tab_press = self.tab_press.take(); - match self.drag.take() { - Some(DragState { - target: - DragTarget::WorkspaceReorder { - source_ws_idx, - insert_idx: Some(insert_idx), - }, - }) => { - self.move_workspace(source_ws_idx, insert_idx); - } - Some(DragState { - target: - DragTarget::TabReorder { - ws_idx, - source_tab_idx, - insert_idx: Some(insert_idx), - }, - }) => { - if self.active == Some(ws_idx) { - self.move_tab(source_tab_idx, insert_idx); - self.mode = Mode::Terminal; - } - } - Some(_) => {} - None => { - if let Some(press) = workspace_press { - self.switch_workspace(press.ws_idx); - self.mode = Mode::Terminal; - return None; - } - if let Some(press) = tab_press { - if self.active == Some(press.ws_idx) { - self.switch_tab(press.tab_idx); - self.mode = Mode::Terminal; - return None; - } - } - 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::Up(MouseButton::Middle) | MouseEventKind::Drag(MouseButton::Middle) - if !in_sidebar => - { - if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - let _ = self.forward_pane_mouse_button(&info, mouse); - } - } - - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => { - if self.on_tab_bar(mouse.column, mouse.row) { - match mouse.kind { - MouseEventKind::ScrollUp => self.scroll_tabs_left(), - MouseEventKind::ScrollDown => self.scroll_tabs_right(), - _ => {} - } - } else if !self.scroll_selection_with_wheel(mouse) { - self.selection = None; - self.handle_terminal_wheel(mouse); - } - } - - MouseEventKind::ScrollUp if in_sidebar => { - let agent_area = self.agent_panel_rect(); - let over_agent_panel = agent_area != Rect::default() - && mouse.row >= agent_area.y - && mouse.row < agent_area.y + agent_area.height; - if over_agent_panel { - if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( - self, agent_area, - )) { - self.scroll_agent_panel(-1); - } - } else if crate::ui::should_show_scrollbar( - crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()), - ) { - self.scroll_workspace_list(-1); - } else if self.selected > 0 { - self.selected -= 1; - self.ensure_workspace_visible(self.selected); - } - } - MouseEventKind::ScrollDown if in_sidebar => { - let agent_area = self.agent_panel_rect(); - let over_agent_panel = agent_area != Rect::default() - && mouse.row >= agent_area.y - && mouse.row < agent_area.y + agent_area.height; - if over_agent_panel { - if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( - self, agent_area, - )) { - self.scroll_agent_panel(1); - } - } else if crate::ui::should_show_scrollbar( - crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()), - ) { - self.scroll_workspace_list(1); - } else if !self.workspaces.is_empty() && self.selected < self.workspaces.len() - 1 { - self.selected += 1; - self.ensure_workspace_visible(self.selected); - } - } - - MouseEventKind::Moved if self.mode == Mode::ContextMenu => { - let hovered = self.context_menu_item_at(mouse.column, mouse.row); - if let Some(menu) = &mut self.context_menu { - menu.list.hover(hovered); - } - } - - MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => { - if self - .workspace_list_scrollbar_target_at(mouse.column, mouse.row) - .is_some() - { - return None; - } - if let Some(idx) = self.workspace_at_row(mouse.row) { - self.selected = idx; - self.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Workspace { ws_idx: idx }, - x: mouse.column, - y: mouse.row, - list: MenuListState::new(0), - }); - self.mode = Mode::ContextMenu; - } - } - - MouseEventKind::Down(MouseButton::Right) - if self.tab_at(mouse.column, mouse.row).is_some() => - { - if let (Some(ws_idx), Some(tab_idx)) = - (self.active, self.tab_at(mouse.column, mouse.row)) - { - self.switch_tab(tab_idx); - self.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Tab { ws_idx, tab_idx }, - x: mouse.column, - y: mouse.row, - list: MenuListState::new(0), - }); - self.mode = Mode::ContextMenu; - } - } - - MouseEventKind::Down(MouseButton::Right) if !in_sidebar => { - if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - self.focus_pane(info.id); - self.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Pane, - x: mouse.column, - y: mouse.row, - list: MenuListState::new(0), - }); - self.mode = Mode::ContextMenu; - } - } - - _ => {} - } - - None - } - - fn on_sidebar_divider(&self, col: u16, row: u16) -> bool { - if self.sidebar_collapsed { - return false; - } - let sidebar = self.view.sidebar_rect; - sidebar.width > 0 - && col == sidebar.x + sidebar.width.saturating_sub(1) - && row >= sidebar.y - && row < sidebar.y + sidebar.height - } - - fn on_collapsed_sidebar_toggle(&self, col: u16, row: u16) -> bool { - if !self.sidebar_collapsed { - return false; - } - let rect = crate::ui::collapsed_sidebar_toggle_rect(self.view.sidebar_rect); - rect.width > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height - } - - fn set_manual_sidebar_width(&mut self, divider_col: u16) { - let sidebar = self.view.sidebar_rect; - let width = divider_col.saturating_sub(sidebar.x).saturating_add(1); - self.sidebar_width = - width.clamp(crate::ui::MIN_SIDEBAR_WIDTH, crate::ui::MAX_SIDEBAR_WIDTH); - self.mark_session_dirty(); - } - - fn on_sidebar_section_divider(&self, col: u16, row: u16) -> bool { - if self.sidebar_collapsed { - return false; - } - let rect = crate::ui::sidebar_section_divider_rect( - self.view.sidebar_rect, - self.sidebar_section_split, - ); - rect.width > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height - } - - fn set_sidebar_section_split(&mut self, row: u16) { - let sidebar = self.view.sidebar_rect; - let content_height = sidebar.height; - if content_height < 6 { - return; - } - let relative_y = row.saturating_sub(sidebar.y); - let ratio = (relative_y as f32) / (content_height as f32); - self.sidebar_section_split = ratio.clamp(0.1, 0.9); - self.mark_session_dirty(); - } - - /// Find which workspace index a sidebar row belongs to (two-section layout). - fn tab_at(&self, col: u16, row: u16) -> Option<usize> { - self.view - .tab_hit_areas - .iter() - .enumerate() - .find_map(|(idx, area)| { - (area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width) - .then_some(idx) - }) - } - - fn on_tab_bar(&self, col: u16, row: u16) -> bool { - let area = self.view.tab_bar_rect; - area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width - } - - fn on_tab_scroll_left_button(&self, col: u16, row: u16) -> bool { - let area = self.view.tab_scroll_left_hit_area; - area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width - } - - fn on_tab_scroll_right_button(&self, col: u16, row: u16) -> bool { - let area = self.view.tab_scroll_right_hit_area; - area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width - } - - fn tab_drop_index_at(&self, col: u16, row: u16) -> Option<usize> { - if !self.on_tab_bar(col, row) { - return None; - } - - let visible_tabs: Vec<_> = self - .view - .tab_hit_areas - .iter() - .enumerate() - .filter(|(_, rect)| rect.width > 0) - .collect(); - let (first_idx, first_rect) = *visible_tabs.first()?; - let (last_idx, last_rect) = *visible_tabs.last()?; - - if self.on_tab_scroll_left_button(col, row) { - return Some(0); - } - if self.on_tab_scroll_right_button(col, row) { - return self - .active - .and_then(|idx| self.workspaces.get(idx)) - .map(|ws| ws.tabs.len()); - } - - let left_edge = if first_idx == 0 { - first_rect.x - } else { - self.view.tab_scroll_left_hit_area.x + self.view.tab_scroll_left_hit_area.width - }; - let right_edge = if self - .active - .and_then(|idx| self.workspaces.get(idx)) - .is_some_and(|ws| last_idx + 1 >= ws.tabs.len()) - { - last_rect.x + last_rect.width - } else { - self.view.tab_scroll_right_hit_area.x.saturating_sub(1) - }; - - if col <= left_edge { - return Some(first_idx); - } - if col >= right_edge { - return Some(last_idx + 1); - } - - for (idx, rect) in visible_tabs { - let midpoint = rect.x + rect.width / 2; - if col < midpoint { - return Some(idx); - } - if col < rect.x + rect.width { - return Some(idx + 1); - } - } - - Some(last_idx + 1) - } - - fn on_new_tab_button(&self, col: u16, row: u16) -> bool { - let area = self.view.new_tab_hit_area; - area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width - } - - fn workspace_at_row(&self, row: u16) -> Option<usize> { - let footer = self.sidebar_footer_rect(); - if footer == Rect::default() { - return None; - } - - let cards = if self.view.workspace_card_areas.is_empty() { - crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) - } else { - self.view.workspace_card_areas.clone() - }; - - cards.iter().find_map(|card| { - (row >= card.rect.y && row < card.rect.y + card.rect.height).then_some(card.ws_idx) - }) - } - - fn collapsed_workspace_at_row(&self, row: u16) -> Option<usize> { - if !self.sidebar_collapsed { - return None; - } - - let (ws_area, _, _) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect); - if ws_area == Rect::default() || row < ws_area.y || row >= ws_area.y + ws_area.height { - return None; - } - - let idx = (row - ws_area.y) as usize; - (idx < self.workspaces.len()).then_some(idx) - } - - fn collapsed_detail_workspace_idx(&self) -> Option<usize> { - if matches!( - self.mode, - Mode::Navigate - | Mode::RenameWorkspace - | Mode::Resize - | Mode::ConfirmClose - | Mode::ContextMenu - | Mode::Settings - | Mode::GlobalMenu - | Mode::KeybindHelp - ) { - Some(self.selected) - } else { - self.active - } - } - - fn collapsed_agent_detail_target_at( - &self, - row: u16, - ) -> Option<(usize, usize, crate::layout::PaneId)> { - if !self.sidebar_collapsed { - return None; - } - - let (_, _, detail_area) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect); - let detail_content_area = Rect::new( - detail_area.x, - detail_area.y, - detail_area.width, - detail_area.height.saturating_sub(1), - ); - if detail_content_area == Rect::default() - || row < detail_content_area.y - || row >= detail_content_area.y + detail_content_area.height - { - return None; - } - - let ws_idx = self.collapsed_detail_workspace_idx()?; - let ws = self.workspaces.get(ws_idx)?; - let detail_idx = (row - detail_content_area.y) as usize; - let details = ws.pane_details(); - let detail = details.get(detail_idx)?; - Some((ws_idx, detail.tab_idx, detail.pane_id)) - } - - fn workspace_drop_index_at_row(&self, row: u16) -> Option<usize> { - let area = self.workspace_list_rect(); - let footer = self.sidebar_footer_rect(); - if area == Rect::default() || row < area.y || row >= footer.y { - return None; - } - - let cards = if self.view.workspace_card_areas.is_empty() { - crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) - } else { - self.view.workspace_card_areas.clone() - }; - if cards.is_empty() { - return Some(0); - } - - let mut insert_indices = Vec::with_capacity(cards.len() + 1); - insert_indices.push(cards[0].ws_idx); - insert_indices.extend(cards.iter().skip(1).map(|card| card.ws_idx)); - insert_indices.push(cards.last().map(|card| card.ws_idx + 1).unwrap_or(0)); - - let mut best: Option<(usize, u16)> = None; - for insert_idx in insert_indices { - let Some(slot_row) = crate::ui::workspace_drop_indicator_row(&cards, area, insert_idx) - else { - continue; - }; - let distance = row.abs_diff(slot_row); - match best { - Some((best_idx, best_distance)) - if distance > best_distance - || (distance == best_distance && insert_idx < best_idx) => {} - _ => best = Some((insert_idx, distance)), - } - } - - best.map(|(insert_idx, _)| insert_idx) - } - - fn on_agent_panel_scope_toggle(&self, col: u16, row: u16) -> bool { - if self.sidebar_collapsed { - return false; - } - - let (_, detail_area) = crate::ui::expanded_sidebar_sections( - self.view.sidebar_rect, - self.sidebar_section_split, - ); - let rect = crate::ui::agent_panel_toggle_rect(detail_area, self.agent_panel_scope); - rect.width > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height - } - - fn agent_detail_target_at(&self, row: u16) -> Option<(usize, usize, crate::layout::PaneId)> { - if self.sidebar_collapsed { - return None; - } - - let detail_area = self.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(self, detail_area); - let body = crate::ui::agent_panel_body_rect( - detail_area, - crate::ui::should_show_scrollbar(metrics), - ); - if body.height < 2 || row < body.y || row >= body.y + body.height { - return None; - } - - let mut row_y = body.y; - for detail in crate::ui::agent_panel_entries(self) - .into_iter() - .skip(self.agent_panel_scroll) - { - if row_y.saturating_add(1) >= body.y + body.height { - break; - } - if row == row_y || row == row_y + 1 { - return Some((detail.ws_idx, detail.tab_idx, detail.pane_id)); - } - row_y = row_y.saturating_add(2); - if row_y < body.y + body.height { - row_y = row_y.saturating_add(1); - } - } - None - } - - fn screen_rect(&self) -> Rect { - let sidebar = self.view.sidebar_rect; - let terminal = self.view.terminal_area; - let x = sidebar.x.min(terminal.x); - let y = sidebar.y.min(terminal.y); - let right = (sidebar.x + sidebar.width).max(terminal.x + terminal.width); - let bottom = (sidebar.y + sidebar.height).max(terminal.y + terminal.height); - Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y)) - } - - pub(crate) fn context_menu_rect(&self) -> Option<Rect> { - let menu = self.context_menu.as_ref()?; - let screen = self.screen_rect(); - let max_item_w = menu - .items() - .iter() - .map(|item| item.len() as u16) - .max() - .unwrap_or(0); - let menu_w = (max_item_w + 4).max(14).min(screen.width.max(1)); - let menu_h = (menu.items().len() as u16 + 2).min(screen.height.max(1)); - let x = menu.x.min(screen.x + screen.width.saturating_sub(menu_w)); - let y = menu.y.min(screen.y + screen.height.saturating_sub(menu_h)); - Some(Rect::new(x, y, menu_w, menu_h)) - } - - pub(crate) fn confirm_close_rect(&self) -> Rect { - crate::ui::confirm_close_popup_rect(self.view.terminal_area).unwrap_or_default() - } - - fn context_menu_item_at(&self, col: u16, row: u16) -> Option<usize> { - let menu_rect = self.context_menu_rect()?; - let inner_x = menu_rect.x + 1; - let inner_y = menu_rect.y + 1; - let inner_w = menu_rect.width.saturating_sub(2); - let inner_h = menu_rect.height.saturating_sub(2); - let item_count = self - .context_menu - .as_ref() - .map(|menu| menu.items().len() as u16) - .unwrap_or(0); - if col >= inner_x - && col < inner_x + inner_w - && row >= inner_y - && row < inner_y + inner_h.min(item_count) - { - 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 - }) - } - - fn pane_mouse_target(&self, col: u16, row: u16) -> Option<&PaneInfo> { - self.pane_at(col, row) - .or_else(|| self.pane_frame_at(col, row)) - } - - fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> { - self.view.pane_infos.iter().find(|info| info.id == pane_id) - } - - fn pane_frame_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { - self.view.pane_infos.iter().find(|p| { - col >= p.rect.x - && col < p.rect.x + p.rect.width - && row >= p.rect.y - && row < p.rect.y + p.rect.height - }) - } - - fn focus_pane(&mut self, pane_id: crate::layout::PaneId) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { - if ws.layout.focused() != pane_id { - ws.layout.focus_pane(pane_id); - self.mark_session_dirty(); - } - } - } - - fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if let Some(rt) = ws.runtimes.get(&pane_id) { - rt.scroll_up(lines); - } - } - } - - fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if let Some(rt) = ws.runtimes.get(&pane_id) { - rt.scroll_down(lines); - } - } - } - - fn pane_scroll_metrics( - &self, - pane_id: crate::layout::PaneId, - ) -> Option<crate::pane::ScrollMetrics> { - self.active - .and_then(|i| self.workspaces.get(i)) - .and_then(|ws| ws.runtime(pane_id)) - .and_then(crate::pane::PaneRuntime::scroll_metrics) - } - - fn update_selection_cursor( - &mut self, - pane_id: crate::layout::PaneId, - screen_col: u16, - screen_row: u16, - ) { - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - return; - }; - let metrics = self.pane_scroll_metrics(pane_id); - if let Some(selection) = self.selection.as_mut() { - selection.drag(screen_col, screen_row, info.inner_rect, metrics); - } - } - - fn selection_edge_scroll_lines(distance: u16) -> usize { - usize::from(distance).saturating_mul(3).clamp(3, 15) - } - - fn update_selection_drag(&mut self, screen_col: u16, screen_row: u16) { - let Some(pane_id) = self.selection.as_ref().map(|selection| selection.pane_id) else { - return; - }; - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - return; - }; - - let bottom = info.inner_rect.y + info.inner_rect.height.saturating_sub(1); - if screen_row < info.inner_rect.y { - self.scroll_pane_up( - pane_id, - Self::selection_edge_scroll_lines(info.inner_rect.y - screen_row), - ); - } else if screen_row > bottom { - self.scroll_pane_down( - pane_id, - Self::selection_edge_scroll_lines(screen_row - bottom), - ); - } - - self.update_selection_cursor(pane_id, screen_col, screen_row); - } - - fn scroll_selection_with_wheel(&mut self, mouse: MouseEvent) -> bool { - const LINES_PER_NOTCH: usize = 3; - - let Some(selection) = self.selection.as_ref() else { - return false; - }; - if !selection.is_in_progress() { - return false; - } - let pane_id = selection.pane_id; - self.focus_pane(pane_id); - match mouse.kind { - MouseEventKind::ScrollUp => self.scroll_pane_up(pane_id, LINES_PER_NOTCH), - MouseEventKind::ScrollDown => self.scroll_pane_down(pane_id, LINES_PER_NOTCH), - _ => return false, - } - self.update_selection_cursor(pane_id, mouse.column, mouse.row); - true - } - - fn handle_terminal_wheel(&mut self, mouse: MouseEvent) { - const LINES_PER_NOTCH: usize = 3; - - if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - self.focus_pane(info.id); - if self.forward_pane_wheel(&info, mouse) { - return; - } - match mouse.kind { - MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH), - MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH), - _ => {} - } - return; - } - - if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() { - self.focus_pane(info.id); - match mouse.kind { - MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH), - MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH), - _ => {} - } - return; - } - - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if let Some(rt) = ws.focused_runtime() { - match mouse.kind { - MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH), - MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH), - _ => {} - } - } - } - } - - fn forward_pane_mouse_button(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { - let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else { - return false; - }; - let Some(rt) = ws.runtimes.get(&info.id) else { - return false; - }; - let column = mouse.column.saturating_sub(info.inner_rect.x); - let row = mouse.row.saturating_sub(info.inner_rect.y); - let Some(bytes) = rt.encode_mouse_button(mouse.kind, column, row, mouse.modifiers) else { - return false; - }; - rt.scroll_reset(); - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse button event"); - } - true - } - - fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { - let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else { - return false; - }; - let Some(rt) = ws.runtimes.get(&info.id) else { - return false; - }; - match rt.wheel_routing() { - Some(crate::pane::WheelRouting::HostScroll) | None => false, - Some(crate::pane::WheelRouting::MouseReport) => { - rt.scroll_reset(); - let column = mouse.column.saturating_sub(info.inner_rect.x); - let row = mouse.row.saturating_sub(info.inner_rect.y); - let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers) - else { - warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event"); - return true; - }; - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event"); - } - true - } - Some(crate::pane::WheelRouting::AlternateScroll) => { - rt.scroll_reset(); - let Some(bytes) = rt.encode_alternate_scroll(mouse.kind) else { - return true; - }; - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(pane = info.id.raw(), err = %err, "failed to forward alternate-scroll key"); - } - true - } - } - } - - fn set_pane_scroll_offset(&self, pane_id: crate::layout::PaneId, offset_from_bottom: usize) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if let Some(rt) = ws.runtimes.get(&pane_id) { - rt.set_scroll_offset_from_bottom(offset_from_bottom); - } - } - } - - fn scrollbar_target_at( - &self, - col: u16, - row: u16, - ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> { - let ws = self.active.and_then(|i| self.workspaces.get(i))?; - let info = self.view.pane_infos.iter().find(|info| { - crate::ui::pane_scrollbar_rect(info).is_some_and(|track| { - col >= track.x - && col < track.x + track.width - && row >= track.y - && row < track.y + track.height - }) - })?; - let rt = ws.runtimes.get(&info.id)?; - let metrics = rt.scroll_metrics()?; - if metrics.max_offset_from_bottom == 0 { - return None; - } - let track = crate::ui::pane_scrollbar_rect(info)?; - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some((info.id, ScrollbarClickTarget::Thumb { grab_row_offset })) - } else { - Some(( - info.id, - ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }, - )) - } - } - - fn scrollbar_offset_for_pane_row( - &self, - pane_id: crate::layout::PaneId, - row: u16, - grab_row_offset: u16, - ) -> Option<usize> { - let ws = self.active.and_then(|i| self.workspaces.get(i))?; - let info = self - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id)?; - let track = crate::ui::pane_scrollbar_rect(info)?; - let rt = ws.runtimes.get(&pane_id)?; - let metrics = rt.scroll_metrics()?; - if metrics.max_offset_from_bottom == 0 { - return None; - } - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } -} - -#[cfg(test)] -fn wheel_routing(input_state: crate::pane::InputState) -> WheelRouting { - if input_state.mouse_protocol_mode.reporting_enabled() { - WheelRouting::MouseReport - } else if input_state.alternate_screen && input_state.mouse_alternate_scroll { - WheelRouting::AlternateScroll - } else { - WheelRouting::HostScroll - } -} - -// 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, - self.pane_scrollback_limit_bytes, - self.host_terminal_theme, - ) { - ws.layout.focus_pane(new_id); - self.mark_session_dirty(); - self.mode = Mode::Terminal; - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::{ - fs, - path::Path, - time::{Duration, SystemTime, UNIX_EPOCH}, - }; - - use crate::{config::Config, detect::Agent, workspace::Workspace}; - use crossterm::event::{KeyModifiers, MouseEvent}; - use ratatui::layout::Rect; - - fn state_with_workspaces(names: &[&str]) -> AppState { - let mut state = AppState::test_new(); - state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect(); - if !state.workspaces.is_empty() { - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Navigate; - } - state - } - - fn app_for_mouse_test() -> App { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.mode = Mode::Terminal; - app.state.update_available = None; - app.state.latest_release_notes_available = false; - app.state.view.sidebar_rect = Rect::new(0, 0, 26, 20); - app.state.view.terminal_area = Rect::new(26, 0, 80, 20); - app - } - - fn mouse(kind: MouseEventKind, col: u16, row: u16) -> MouseEvent { - MouseEvent { - kind, - column: col, - row, - modifiers: KeyModifiers::empty(), - } - } - - fn numbered_lines_bytes(count: usize) -> Vec<u8> { - (0..count) - .map(|i| format!("{i:06}\r\n")) - .collect::<String>() - .into_bytes() - } - - fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot { - crate::persist::capture( - &state.workspaces, - state.active, - state.selected, - state.agent_panel_scope, - state.sidebar_width, - state.sidebar_section_split, - ) - } - - fn unique_temp_path(name: &str) -> std::path::PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) - } - - fn wait_for_file(path: &Path) -> String { - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while std::time::Instant::now() < deadline { - if let Ok(content) = fs::read_to_string(path) { - return content; - } - std::thread::sleep(Duration::from_millis(20)); - } - panic!("timed out waiting for {}", path.display()); - } - - fn root_layout_ratio(snapshot: &crate::persist::SessionSnapshot) -> Option<f32> { - match &snapshot.workspaces.first()?.tabs.first()?.layout { - crate::persist::LayoutSnapshot::Split { ratio, .. } => Some(*ratio), - crate::persist::LayoutSnapshot::Pane(_) => None, - } - } - - #[test] - fn custom_rename_key_enters_rename_mode() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.rename_workspace = (KeyCode::Char('g'), KeyModifiers::empty()); - state.keybinds.rename_workspace_label = "g".into(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::RenameWorkspace); - assert_eq!(state.name_input, "test"); - } - - #[test] - fn custom_new_workspace_key_requests_and_exits_navigate() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.new_workspace = (KeyCode::Char('g'), KeyModifiers::empty()); - state.keybinds.new_workspace_label = "g".into(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.request_new_workspace); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn custom_sidebar_toggle_key_toggles_and_exits_navigate() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.toggle_sidebar = (KeyCode::Char('g'), KeyModifiers::empty()); - state.keybinds.toggle_sidebar_label = "g".into(); - assert!(!state.sidebar_collapsed); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.sidebar_collapsed); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn custom_resize_key_enters_resize_mode() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty()); - state.keybinds.resize_mode_label = "g".into(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Resize); - } - - #[test] - fn movement_action_stays_in_navigate_mode() { - let mut state = state_with_workspaces(&["a", "b"]); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 1); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.focus_pane_left = Some((KeyCode::Left, KeyModifiers::ALT)); - state.keybinds.focus_pane_left_label = Some("alt+left".into()); - - let action = terminal_direct_navigation_action( - &state, - &AppKey::from(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT)), - ); - - assert_eq!(action, Some(NavigateAction::FocusPaneLeft)); - } - - #[tokio::test] - async fn terminal_direct_focus_pane_shortcut_switches_focus_without_leaving_terminal_mode() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.workspaces[0].test_split(Direction::Horizontal); - app.state.view.pane_infos = app.state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(Rect::new(0, 0, 80, 24)); - let focused_before = app.state.workspaces[0].layout.focused(); - app.state.keybinds.focus_pane_left = Some((KeyCode::Char('h'), KeyModifiers::ALT)); - app.state.keybinds.focus_pane_left_label = Some("alt+h".into()); - - app.handle_terminal_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::ALT)) - .await; - - assert_ne!(app.state.workspaces[0].layout.focused(), focused_before); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn double_prefix_passes_prefix_through_to_focused_pane() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - None, - api_rx, - crate::api::EventHub::default(), - ); - let mut workspace = Workspace::test_new("test"); - let pane_id = workspace.tabs[0].root_pane; - let (runtime, mut rx) = - crate::pane::PaneRuntime::test_with_screen_bytes_and_receiver(20, 5, b""); - let expected = runtime - .encode_terminal_key(TerminalKey::new(KeyCode::Char('a'), KeyModifiers::CONTROL)); - workspace.tabs[0].runtimes.insert(pane_id, runtime); - - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prefix_code = KeyCode::Char('a'); - app.state.prefix_mods = KeyModifiers::CONTROL; - - app.handle_key(TerminalKey::new(KeyCode::Char('a'), KeyModifiers::CONTROL)) - .await; - assert_eq!(app.state.mode, Mode::Navigate); - - app.handle_key(TerminalKey::new(KeyCode::Char('a'), KeyModifiers::CONTROL)) - .await; - assert_eq!(app.state.mode, Mode::Terminal); - - let forwarded = rx.try_recv().expect("prefix bytes should be forwarded"); - assert_eq!(forwarded.as_ref(), expected.as_slice()); - } - - #[tokio::test] - async fn double_shifted_prefix_preserves_shifted_codepoint_when_forwarded() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - None, - api_rx, - crate::api::EventHub::default(), - ); - let mut workspace = Workspace::test_new("test"); - let pane_id = workspace.tabs[0].root_pane; - let (runtime, mut rx) = - crate::pane::PaneRuntime::test_with_screen_bytes_and_receiver(20, 5, b""); - let prefix = TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32); - let expected = runtime.encode_terminal_key(prefix); - workspace.tabs[0].runtimes.insert(pane_id, runtime); - - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prefix_code = KeyCode::Char('?'); - app.state.prefix_mods = KeyModifiers::empty(); - - app.handle_key(prefix).await; - assert_eq!(app.state.mode, Mode::Navigate); - - app.handle_key(prefix).await; - assert_eq!(app.state.mode, Mode::Terminal); - - let forwarded = rx - .try_recv() - .expect("shifted prefix bytes should be forwarded"); - assert_eq!(forwarded.as_ref(), expected.as_slice()); - } - - #[tokio::test] - async fn custom_command_runs_from_prefix_key_in_navigate_mode() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("custom-command-keybind"); - let command = format!( - "printf '%s\\n%s\\n%s\\n' \"$HERDR_ACTIVE_WORKSPACE_ID\" \"$HERDR_ACTIVE_TAB_ID\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'", - output_path.display() - ); - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - key: (KeyCode::Char('g'), KeyModifiers::empty()), - label: "g".into(), - command, - action: crate::config::CustomCommandAction::Shell, - }]; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - assert_eq!(app.state.mode, Mode::Navigate); - - app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) - .await; - - let content = wait_for_file(&output_path); - let lines: Vec<&str> = content.lines().collect(); - assert_eq!(lines.len(), 3); - assert_eq!(lines[0], app.state.workspaces[0].id); - assert_eq!(lines[1], format!("{}:1", app.state.workspaces[0].id)); - assert_eq!(lines[2], format!("{}-1", app.state.workspaces[0].id)); - assert_eq!(app.state.mode, Mode::Terminal); - - let _ = fs::remove_file(output_path); - } - - #[tokio::test] - async fn pane_overlay_command_opens_and_closes_after_exit() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - None, - api_rx, - crate::api::EventHub::default(), - ); - let workspace = Workspace::new( - std::env::current_dir().unwrap_or_else(|_| "/".into()), - 24, - 80, - app.state.pane_scrollback_limit_bytes, - app.state.host_terminal_theme, - app.event_tx.clone(), - app.render_notify.clone(), - app.render_dirty.clone(), - ) - .expect("workspace should spawn"); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("custom-pane-command"); - let command = format!("printf done > '{}'", output_path.display()); - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - key: (KeyCode::Char('g'), KeyModifiers::empty()), - label: "g".into(), - command, - action: crate::config::CustomCommandAction::Pane, - }]; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); - assert!(app.state.workspaces[0].tabs[0].zoomed); - - let _ = wait_for_file(&output_path); - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while std::time::Instant::now() < deadline { - if app.drain_internal_events() - && app.state.workspaces[0].tabs[0].layout.pane_count() == 1 - { - break; - } - std::thread::sleep(Duration::from_millis(20)); - } - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - assert!(!app.state.workspaces[0].tabs[0].zoomed); - assert_eq!(app.state.mode, Mode::Terminal); - let _ = fs::remove_file(output_path); - } - - #[test] - fn fullscreen_action_exits_navigate_mode() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].test_split(Direction::Horizontal); - state.keybinds.fullscreen = (KeyCode::Char('g'), KeyModifiers::empty()); - state.keybinds.fullscreen_label = "g".into(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.workspaces[0].zoomed); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn custom_resize_key_exits_resize_mode() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::Resize; - state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty()); - state.keybinds.resize_mode_label = "g".into(); - - handle_resize_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn settings_cancel_restores_previewed_theme_from_other_sections() { - let mut state = state_with_workspaces(&["test"]); - let original_palette = state.palette.clone(); - let original_theme = state.theme_name.clone(); - - open_settings(&mut state); - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - assert_ne!(state.theme_name, original_theme); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), - ); - assert_eq!( - state.settings.section, - crate::app::state::SettingsSection::Sound - ); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert_eq!(state.theme_name, original_theme); - assert_eq!(state.palette.accent, original_palette.accent); - assert_eq!(state.palette.panel_bg, original_palette.panel_bg); - } - - #[test] - fn settings_sound_toggle_returns_save_action() { - let mut state = state_with_workspaces(&["test"]); - open_settings(&mut state); - state.settings.section = crate::app::state::SettingsSection::Sound; - state.settings.list.selected = 0; - - let action = update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(action, Some(SettingsAction::SaveSound(true))); - assert!(state.sound.enabled); - assert_eq!(state.mode, Mode::Settings); - } - - #[test] - fn question_mark_opens_keybind_help_from_navigate() { - let mut state = state_with_workspaces(&["test"]); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT), - ); - - assert_eq!(state.mode, Mode::KeybindHelp); - } - - #[test] - fn shifted_slash_terminal_key_opens_keybind_help_from_navigate() { - let mut state = state_with_workspaces(&["test"]); - - handle_navigate_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ); - - assert_eq!(state.mode, Mode::KeybindHelp); - } - - #[test] - fn rename_modal_keyboard_and_mouse_share_actions() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameWorkspace; - state.name_input = "hello".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), - ); - assert!(state.name_input.is_empty()); - - state.name_input = "renamed".into(); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::Terminal); - assert_eq!(state.workspaces[0].display_name(), "renamed"); - let snapshot = capture_snapshot(&state); - assert_eq!( - snapshot.workspaces[0].custom_name.as_deref(), - Some("renamed") - ); - - state.view.sidebar_rect = Rect::new(0, 0, 26, 20); - state.view.terminal_area = Rect::new(26, 0, 80, 20); - state.mode = Mode::RenameWorkspace; - state.name_input = "mouse".into(); - let inner = state.rename_modal_inner().unwrap(); - let (save, _, _) = crate::ui::rename_button_rects(inner); - let action = modal_action_from_buttons(save.x, save.y, &[(save, ModalAction::Save)]); - assert_eq!(action, Some(ModalAction::Save)); - } - - #[test] - fn tab_rename_updates_captured_snapshot() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameTab; - state.name_input = "logs".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - let snapshot = capture_snapshot(&state); - assert_eq!( - snapshot.workspaces[0].tabs[0].custom_name.as_deref(), - Some("logs") - ); - } - - #[test] - fn rename_cancel_returns_to_terminal_when_workspace_is_active() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameTab; - state.name_input = "test".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(state.name_input.is_empty()); - } - - #[test] - fn rename_modal_replaces_prefilled_text_on_first_type() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameTab; - state.name_input = "2".into(); - state.name_input_replace_on_type = true; - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty()), - ); - assert_eq!(state.name_input, "n"); - assert!(!state.name_input_replace_on_type); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()), - ); - assert_eq!(state.name_input, "ne"); - } - - #[test] - fn rename_modal_uses_shifted_terminal_text_for_app_input() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameTab; - - handle_rename_key( - &mut state, - TerminalKey::new(KeyCode::Char('l'), KeyModifiers::SHIFT) - .with_shifted_codepoint('L' as u32), - ); - handle_rename_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ); - - assert_eq!(state.name_input, "L?"); - } - - #[test] - fn open_rename_active_tab_can_prefill_default_new_tab_name() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].test_add_tab(None); - state.workspaces[0].switch_tab(1); - - open_rename_active_tab(&mut state, true); - - assert_eq!(state.mode, Mode::RenameTab); - assert_eq!(state.name_input, "2"); - assert!(state.name_input_replace_on_type); - } - - #[test] - fn new_tab_action_opens_dialog_without_creating_tab() { - let mut state = state_with_workspaces(&["test"]); - - execute_navigate_action(&mut state, NavigateAction::NewTab); - - assert_eq!(state.mode, Mode::RenameTab); - assert!(state.creating_new_tab); - assert_eq!(state.name_input, "2"); - assert!(state.name_input_replace_on_type); - assert!(!state.request_new_tab); - assert_eq!(state.workspaces[0].tabs.len(), 1); - } - - #[test] - fn cancel_new_tab_dialog_leaves_workspace_unchanged() { - let mut state = state_with_workspaces(&["test"]); - open_new_tab_dialog(&mut state); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(!state.creating_new_tab); - assert!(!state.request_new_tab); - assert!(state.requested_new_tab_name.is_none()); - assert_eq!(state.workspaces[0].tabs.len(), 1); - } - - #[test] - fn saving_new_tab_dialog_requests_creation_with_name() { - let mut state = state_with_workspaces(&["test"]); - open_new_tab_dialog(&mut state); - state.name_input = "logs".into(); - state.name_input_replace_on_type = false; - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(!state.creating_new_tab); - assert!(state.request_new_tab); - assert_eq!(state.requested_new_tab_name.as_deref(), Some("logs")); - } - - #[test] - fn saving_new_tab_dialog_with_default_name_keeps_tab_auto_named() { - let mut state = state_with_workspaces(&["test"]); - open_new_tab_dialog(&mut state); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(!state.creating_new_tab); - assert!(state.request_new_tab); - assert!(state.requested_new_tab_name.is_none()); - } - - #[test] - fn closing_first_auto_tab_resets_remaining_auto_tab_and_next_prompt() { - let mut state = state_with_workspaces(&["test"]); - open_new_tab_dialog(&mut state); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - state.workspaces[0].test_add_tab(state.requested_new_tab_name.as_deref()); - state.request_new_tab = false; - state.requested_new_tab_name = None; - - state.workspaces[0].close_tab(0); - state.workspaces[0].switch_tab(0); - - assert_eq!(state.workspaces[0].tabs[0].display_name(), "1"); - assert!(state.workspaces[0].tabs[0].custom_name.is_none()); - - open_new_tab_dialog(&mut state); - assert_eq!(state.name_input, "2"); - } - - #[test] - fn renaming_auto_tab_to_its_default_number_keeps_it_auto_named() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].test_add_tab(None); - state.workspaces[0].switch_tab(1); - - open_rename_active_tab(&mut state, false); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(state.workspaces[0].tabs[1].custom_name.is_none()); - assert_eq!(state.workspaces[0].tabs[1].display_name(), "2"); - } - - #[test] - fn confirm_close_keyboard_actions_are_direct_not_focused() { - let mut state = state_with_workspaces(&["a", "b"]); - state.mode = Mode::ConfirmClose; - state.selected = 1; - - handle_confirm_close_key( - &mut state, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::Navigate); - assert_eq!(state.workspaces.len(), 2); - - state.mode = Mode::ConfirmClose; - handle_confirm_close_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - assert_eq!(state.workspaces.len(), 1); - } - - #[test] - fn clicking_launcher_opens_global_menu() { - let mut app = app_for_mouse_test(); - let rect = app.state.global_launcher_rect(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - rect.x + rect.width.saturating_sub(1), - rect.y, - )); - - assert_eq!(app.state.mode, Mode::GlobalMenu); - } - - #[test] - fn hovering_global_menu_updates_highlight() { - let mut app = app_for_mouse_test(); - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2)); - - assert_eq!(app.state.global_menu.highlighted, 1); - } - - #[test] - fn clicking_keybinds_menu_item_opens_help() { - let mut app = app_for_mouse_test(); - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 2, - )); - - assert_eq!(app.state.mode, Mode::KeybindHelp); - } - - #[test] - fn clicking_settings_menu_item_opens_settings() { - let mut app = app_for_mouse_test(); - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 1, - )); - - assert_eq!(app.state.mode, Mode::Settings); - } - - #[test] - fn clicking_reload_keybinds_menu_item_requests_reload() { - let mut app = app_for_mouse_test(); - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 3, - )); - - assert!(app.state.request_reload_keybinds); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn update_available_menu_surfaces_update_status_without_apply_action() { - let mut app = app_for_mouse_test(); - app.state.update_available = Some("0.3.2".into()); - app.state.latest_release_notes_available = true; - - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - assert_eq!( - app.state.global_menu_labels(), - vec![ - "settings", - "keybinds", - "reload keybinds", - "update available", - "quit" - ] - ); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 5, - )); - - assert!(app.state.should_quit); - } - - #[test] - fn whats_new_remains_in_menu_for_latest_installed_release_notes() { - let mut app = app_for_mouse_test(); - app.state.latest_release_notes_available = true; - - assert_eq!( - app.state.global_menu_labels(), - vec![ - "settings", - "keybinds", - "reload keybinds", - "what's new", - "quit" - ] - ); - } - - #[test] - fn clicking_keybind_help_close_button_closes_overlay() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::KeybindHelp; - - let rect = app.state.keybind_help_popup_rect(); - let inner = Rect::new( - rect.x + 1, - rect.y + 1, - rect.width.saturating_sub(2), - rect.height.saturating_sub(2), - ); - let close = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - close.x, - close.y, - )); - - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn hovering_context_menu_updates_highlight() { - let mut app = app_for_mouse_test(); - app.state.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Workspace { ws_idx: 0 }, - x: 2, - y: 2, - list: MenuListState::new(0), - }); - app.state.mode = Mode::ContextMenu; - - let menu = app.state.context_menu_rect().unwrap(); - app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2)); - - assert_eq!(app.state.context_menu.unwrap().list.highlighted, 1); - } - - #[test] - fn onboarding_hover_does_not_change_selection() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::Onboarding; - app.state.onboarding_step = 1; - app.state.onboarding_list.select(1); - - let inner = app.state.onboarding_modal_inner(56, 14).unwrap(); - let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content; - app.handle_mouse(mouse(MouseEventKind::Moved, content.x + 2, content.y)); - - assert_eq!(app.state.onboarding_list.selected, 1); - } - - #[test] - fn onboarding_click_selects_notification_option() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::Onboarding; - app.state.onboarding_step = 1; - app.state.onboarding_list.select(0); - - let inner = app.state.onboarding_modal_inner(56, 14).unwrap(); - let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - content.x + 2, - content.y + 2, - )); - - assert_eq!(app.state.onboarding_list.selected, 2); - } - - #[test] - fn settings_hover_does_not_change_selection() { - let mut app = app_for_mouse_test(); - open_settings(&mut app.state); - app.state.settings.list.select(0); - - let area = app.state.settings_content_rect(); - app.handle_mouse(mouse(MouseEventKind::Moved, area.x + 2, area.y + 2)); - - assert_eq!(app.state.settings.list.selected, 0); - } - - #[test] - fn clicking_confirm_close_accepts_workspace_close() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::ConfirmClose; - - let popup = app.state.confirm_close_rect(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (confirm, _) = crate::ui::confirm_close_button_rects(inner); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - confirm.x, - confirm.y, - )); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn clicking_confirm_close_accepts_after_workspace_context_menu_close() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.state.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Workspace { ws_idx: 1 }, - x: 2, - y: 2, - list: MenuListState::new(1), - }); - app.state.mode = Mode::ContextMenu; - handle_context_menu_key( - &mut app.state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.selected, 1); - - let popup = app.state.confirm_close_rect(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (confirm, _) = crate::ui::confirm_close_button_rects(inner); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - confirm.x + 1, - confirm.y, - )); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "a"); - } - - #[test] - fn clicking_agent_detail_row_switches_to_correct_tab_and_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("main".into()); - let first_pane = ws.tabs[0].root_pane; - ws.tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .detected_agent = Some(Agent::Pi); - let second_tab = ws.test_add_tab(Some("logs")); - let second_pane = ws.tabs[second_tab].root_pane; - ws.tabs[second_tab] - .panes - .get_mut(&second_pane) - .unwrap() - .detected_agent = Some(Agent::Claude); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 16)); - - assert_eq!(app.state.workspaces[0].active_tab, 1); - assert_eq!( - app.state.workspaces[0].tabs[1].layout.focused(), - second_pane - ); - assert_eq!(app.state.mode, Mode::Terminal); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.workspaces[0].active_tab, second_tab); - assert_eq!( - snapshot.workspaces[0].tabs[second_tab].focused, - Some(second_pane.raw()) - ); - } - - #[test] - fn clicking_agent_panel_toggle_switches_scope() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.agent_panel_scroll = 3; - - let (_, detail_area) = crate::ui::expanded_sidebar_sections( - app.state.view.sidebar_rect, - app.state.sidebar_section_split, - ); - let toggle = crate::ui::agent_panel_toggle_rect(detail_area, app.state.agent_panel_scope); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - toggle.x, - toggle.y, - )); - - assert_eq!(app.state.agent_panel_scope, AgentPanelScope::AllWorkspaces); - assert_eq!(app.state.agent_panel_scroll, 0); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.agent_panel_scope, AgentPanelScope::AllWorkspaces); - } - - #[test] - fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() { - let mut app = app_for_mouse_test(); - let mut first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - first.tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .detected_agent = Some(Agent::Pi); - - let mut second = Workspace::test_new("two"); - let second_pane = second.tabs[0].root_pane; - second.tabs[0] - .panes - .get_mut(&second_pane) - .unwrap() - .detected_agent = Some(Agent::Claude); - - app.state.workspaces = vec![first, second]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.agent_panel_scope = AgentPanelScope::AllWorkspaces; - - let (_, detail_area) = crate::ui::expanded_sidebar_sections( - app.state.view.sidebar_rect, - app.state.sidebar_section_split, - ); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - detail_area.x + 2, - detail_area.y + 6, - )); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.selected, 1); - assert_eq!(app.state.workspaces[1].active_tab, 0); - assert_eq!( - app.state.workspaces[1].tabs[0].layout.focused(), - second_pane - ); - } - - #[test] - fn scrolling_agent_panel_with_wheel_updates_agent_panel_scroll() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - ws.tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .detected_agent = Some(Agent::Pi); - - for (tab_name, agent) in [ - ("logs", Agent::Claude), - ("review", Agent::Codex), - ("ops", Agent::Gemini), - ] { - let tab_idx = ws.test_add_tab(Some(tab_name)); - let pane_id = ws.tabs[tab_idx].root_pane; - ws.tabs[tab_idx] - .panes - .get_mut(&pane_id) - .unwrap() - .detected_agent = Some(agent); - } - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let detail_area = app.state.agent_panel_rect(); - assert!(crate::ui::should_show_scrollbar( - crate::ui::agent_panel_scroll_metrics(&app.state, detail_area) - )); - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - detail_area.x + 1, - detail_area.y + 4, - )); - - assert_eq!(app.state.agent_panel_scroll, 1); - assert_eq!(app.state.selected, 0); - } - - #[test] - fn clicking_scrolled_agent_detail_row_switches_to_correct_tab_and_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - ws.tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .detected_agent = Some(Agent::Pi); - - let second_tab = ws.test_add_tab(Some("logs")); - let second_pane = ws.tabs[second_tab].root_pane; - ws.tabs[second_tab] - .panes - .get_mut(&second_pane) - .unwrap() - .detected_agent = Some(Agent::Claude); - - for (tab_name, agent) in [("review", Agent::Codex), ("ops", Agent::Gemini)] { - let tab_idx = ws.test_add_tab(Some(tab_name)); - let pane_id = ws.tabs[tab_idx].root_pane; - ws.tabs[tab_idx] - .panes - .get_mut(&pane_id) - .unwrap() - .detected_agent = Some(agent); - } - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.agent_panel_scroll = 1; - - let detail_area = app.state.agent_panel_rect(); - let body = crate::ui::agent_panel_body_rect(detail_area, true); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - body.x + 1, - body.y, - )); - - assert_eq!(app.state.workspaces[0].active_tab, second_tab); - assert_eq!( - app.state.workspaces[0].tabs[second_tab].layout.focused(), - second_pane - ); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn clicking_collapsed_agent_row_switches_to_correct_tab_and_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - ws.tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .detected_agent = Some(Agent::Pi); - let second_tab = ws.test_add_tab(Some("logs")); - let second_pane = ws.tabs[second_tab].root_pane; - ws.tabs[second_tab] - .panes - .get_mut(&second_pane) - .unwrap() - .detected_agent = Some(Agent::Claude); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.sidebar_collapsed = true; - app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20); - app.state.view.terminal_area = Rect::new(4, 0, 80, 20); - - let (_, _, detail_area) = - crate::ui::collapsed_sidebar_sections(app.state.view.sidebar_rect); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - detail_area.x, - detail_area.y + 1, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 1); - assert_eq!( - app.state.workspaces[0].tabs[1].layout.focused(), - second_pane - ); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn clicking_collapsed_sidebar_toggle_expands_sidebar() { - let mut app = app_for_mouse_test(); - app.state.sidebar_collapsed = true; - app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20); - app.state.view.terminal_area = Rect::new(4, 0, 80, 20); - - let toggle = crate::ui::collapsed_sidebar_toggle_rect(app.state.view.sidebar_rect); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - toggle.x, - toggle.y, - )); - - assert!(!app.state.sidebar_collapsed); - } - - #[tokio::test] - async fn dragging_selection_above_pane_autoscrolls_and_extends_into_scrollback() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( - pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let start_metrics = app.state.workspaces[0] - .runtime(pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) - .expect("initial scroll metrics"); - let start_row = info.inner_rect.y; - let start_col = info.inner_rect.x + 2; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - start_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - start_col, - info.inner_rect.y.saturating_sub(1), - )); - - let end_metrics = app.state.workspaces[0] - .runtime(pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) - .expect("scroll metrics after drag"); - assert_eq!( - end_metrics.offset_from_bottom, - start_metrics.offset_from_bottom + 3 - ); - - let selection = app.state.selection.as_ref().expect("selection after drag"); - assert!(selection.is_visible()); - assert_eq!( - selection.ordered_cells(), - ( - ( - (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32, - 2, - ), - (start_metrics.max_offset_from_bottom as u32, 2), - ) - ); - } - - #[tokio::test] - async fn releasing_dragged_selection_clears_highlight_after_copy() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( - pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let row = info.inner_rect.y; - let start_col = info.inner_rect.x + 1; - let end_col = info.inner_rect.x + 4; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); - assert!(app.state.selection.is_some()); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); - - assert!(app.state.selection.is_none()); - } - - #[tokio::test] - async fn wheel_scroll_keeps_in_progress_selection_and_extends_it() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( - pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let start_metrics = app.state.workspaces[0] - .runtime(pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) - .expect("initial scroll metrics"); - let top_row = info.inner_rect.y; - let col = info.inner_rect.x + 2; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, top_row)); - app.handle_mouse(mouse(MouseEventKind::ScrollUp, col, top_row)); - - let end_metrics = app.state.workspaces[0] - .runtime(pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) - .expect("scroll metrics after wheel"); - assert_eq!( - end_metrics.offset_from_bottom, - start_metrics.offset_from_bottom + 3 - ); - - let selection = app.state.selection.as_ref().expect("selection after wheel"); - assert!(selection.is_visible()); - assert_eq!( - selection.ordered_cells(), - ( - ( - (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32, - 2, - ), - (start_metrics.max_offset_from_bottom as u32, 2), - ) - ); - } - - #[test] - fn clicking_workspace_switches_on_mouse_up() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let target_row = app.state.view.workspace_card_areas[1].rect.y; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - 2, - target_row, - )); - assert_eq!(app.state.active, Some(0)); - assert!(app.state.workspace_press.is_some()); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.selected, 1); - assert!(app.state.workspace_press.is_none()); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.active, Some(1)); - assert_eq!(snapshot.selected, 1); - } - - #[test] - fn dragging_workspace_reorders_without_changing_identity() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - Workspace::test_new("a"), - Workspace::test_new("b"), - Workspace::test_new("c"), - ]; - let active_id = app.state.workspaces[1].id.clone(); - let selected_id = app.state.workspaces[2].id.clone(); - app.state.active = Some(1); - app.state.selected = 2; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let source_row = app.state.view.workspace_card_areas[1].rect.y; - let target_row = crate::ui::workspace_drop_indicator_row( - &app.state.view.workspace_card_areas, - app.state.workspace_list_rect(), - 0, - ) - .unwrap(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - 2, - source_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - 2, - target_row, - )); - assert!(matches!( - app.state.drag.as_ref().map(|drag| &drag.target), - Some(DragTarget::WorkspaceReorder { - source_ws_idx: 1, - insert_idx: Some(0), - }) - )); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - - let names: Vec<_> = app - .state - .workspaces - .iter() - .map(|ws| ws.display_name()) - .collect(); - assert_eq!(names, vec!["b", "a", "c"]); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.selected, 2); - assert_eq!(app.state.workspaces[0].id, active_id); - assert_eq!(app.state.workspaces[2].id, selected_id); - let snapshot = capture_snapshot(&app.state); - let captured_names: Vec<_> = snapshot - .workspaces - .iter() - .map(|ws| ws.custom_name.clone().unwrap()) - .collect(); - assert_eq!(captured_names, vec!["b", "a", "c"]); - } - - #[test] - fn clicking_tab_scroll_button_reveals_hidden_tabs_without_renaming() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(Some("logs")); - ws.test_add_tab(Some("review")); - ws.test_add_tab(Some("ops")); - ws.test_add_tab(Some("notes")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20)); - - let right = app.state.view.tab_scroll_right_hit_area; - assert!(right.width > 0); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - right.x + 1, - right.y, - )); - - assert_eq!(app.state.tab_scroll, 1); - assert!(!app.state.tab_scroll_follow_active); - assert_eq!(app.state.workspaces[0].active_tab, 0); - assert_eq!(app.state.view.tab_hit_areas[0].width, 0); - assert!(app.state.workspaces[0].tabs[0].custom_name.is_none()); - assert_eq!( - app.state.workspaces[0].tabs[1].custom_name.as_deref(), - Some("logs") - ); - } - - #[test] - fn clicking_last_visible_tab_at_right_edge_does_not_overscroll() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - for name in [ - "one", "two", "three", "four", "five", "six", "seven", "eight", - ] { - ws.test_add_tab(Some(name)); - } - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.tab_scroll = usize::MAX; - app.state.tab_scroll_follow_active = false; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20)); - - let last_idx = app.state.workspaces[0].tabs.len() - 1; - let target = app.state.view.tab_hit_areas[last_idx]; - let clamped_scroll = app.state.tab_scroll; - assert!(target.width > 0, "last tab should already be visible"); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - target.x + 1, - target.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - target.x + 1, - target.y, - )); - - assert_eq!(app.state.workspaces[0].active_tab, last_idx); - assert_eq!(app.state.tab_scroll, clamped_scroll); - assert!(app.state.view.tab_hit_areas[last_idx].width > 0); - } - - #[test] - fn dragging_tab_reorders_auto_and_custom_names_without_materializing_numbers() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(Some("foo")); - ws.test_add_tab(None); - let moved_root = ws.tabs[0].root_pane; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let source = app.state.view.tab_hit_areas[0]; - let last = app.state.view.tab_hit_areas[2]; - let drop_col = last.x + last.width; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - source.x + 1, - source.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - drop_col, - source.y, - )); - assert!(matches!( - app.state.drag.as_ref().map(|drag| &drag.target), - Some(DragTarget::TabReorder { - ws_idx: 0, - source_tab_idx: 0, - insert_idx: Some(3), - }) - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - drop_col, - source.y, - )); - - let labels: Vec<_> = app.state.workspaces[0] - .tabs - .iter() - .map(|tab| tab.display_name()) - .collect(); - assert_eq!(labels, vec!["foo", "2", "3"]); - assert_eq!( - app.state.workspaces[0].tabs[0].custom_name.as_deref(), - Some("foo") - ); - assert!(app.state.workspaces[0].tabs[1].custom_name.is_none()); - assert!(app.state.workspaces[0].tabs[2].custom_name.is_none()); - assert_eq!(app.state.workspaces[0].tabs[2].root_pane, moved_root); - assert_eq!(app.state.workspaces[0].active_tab, 2); - } - - #[test] - fn top_drop_slot_is_distinct_from_gap_below_first_workspace() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - assert_eq!(app.state.workspace_drop_index_at_row(0), Some(0)); - assert_eq!(app.state.workspace_drop_index_at_row(1), Some(0)); - assert_eq!(app.state.workspace_drop_index_at_row(2), Some(0)); - assert_eq!(app.state.workspace_drop_index_at_row(3), Some(1)); - } - - #[test] - fn bottom_drop_slot_stays_below_last_workspace_not_footer() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - Workspace::test_new("a"), - Workspace::test_new("b"), - Workspace::test_new("c"), - ]; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let cards = &app.state.view.workspace_card_areas; - let bottom_slot = crate::ui::workspace_drop_indicator_row( - cards, - app.state.workspace_list_rect(), - cards.len(), - ) - .unwrap(); - - let last = cards.last().unwrap().rect; - assert_eq!(bottom_slot, last.y + last.height); - assert!(bottom_slot < app.state.sidebar_footer_rect().y.saturating_sub(1)); - } - - #[test] - fn dragging_sidebar_divider_sets_manual_width() { - let mut app = app_for_mouse_test(); - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 30, 5)); - - assert_eq!(app.state.sidebar_width, 31); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.sidebar_width, Some(31)); - } - - #[test] - fn dragging_sidebar_section_divider_sets_split_ratio() { - let mut app = app_for_mouse_test(); - let divider = crate::ui::sidebar_section_divider_rect( - app.state.view.sidebar_rect, - app.state.sidebar_section_split, - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - divider.x + 1, - divider.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - divider.x + 1, - divider.y + 4, - )); - - assert!(app.state.sidebar_section_split > 0.5); - let snapshot = capture_snapshot(&app.state); - assert_eq!( - snapshot.sidebar_section_split, - Some(app.state.sidebar_section_split) - ); - } - - #[test] - fn dragging_pane_split_updates_captured_layout_ratio() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let before = capture_snapshot(&app.state); - let drag_row = border.area.y.saturating_add(1); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - border.pos, - drag_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - border.pos.saturating_add(6), - drag_row, - )); - - let after = capture_snapshot(&app.state); - assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after)); - } - - #[test] - fn double_clicking_sidebar_divider_resets_default_width() { - let mut app = app_for_mouse_test(); - app.state.default_sidebar_width = 26; - app.state.sidebar_width = 30; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - - assert_eq!(app.state.sidebar_width, 26); - assert!(app.state.drag.is_none()); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.sidebar_width, Some(26)); - } - - #[test] - fn wheel_routing_prefers_mouse_reporting() { - let input_state = crate::pane::InputState { - alternate_screen: true, - application_cursor: false, - bracketed_paste: false, - focus_reporting: false, - mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion, - mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr, - mouse_alternate_scroll: true, - }; - - assert_eq!(wheel_routing(input_state), WheelRouting::MouseReport); - } - - #[test] - fn wheel_routing_uses_alternate_scroll_in_fullscreen_without_mouse_reporting() { - let input_state = crate::pane::InputState { - alternate_screen: true, - application_cursor: false, - bracketed_paste: false, - focus_reporting: false, - mouse_protocol_mode: crate::input::MouseProtocolMode::None, - mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, - mouse_alternate_scroll: true, - }; - - assert_eq!(wheel_routing(input_state), WheelRouting::AlternateScroll); - } - - #[test] - fn wheel_routing_falls_back_to_host_scrollback() { - let input_state = crate::pane::InputState { - alternate_screen: false, - application_cursor: false, - bracketed_paste: false, - focus_reporting: false, - mouse_protocol_mode: crate::input::MouseProtocolMode::None, - mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, - mouse_alternate_scroll: true, - }; - - assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll); - } - - #[tokio::test] - async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_left_button() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(ratatui::layout::Direction::Vertical); - - let terminal_area = Rect::new(26, 2, 80, 18); - let pane_infos = ws.tabs[0].layout.panes(terminal_area); - let first_info = pane_infos - .iter() - .find(|p| p.id == first_pane) - .expect("first pane info") - .clone(); - let second_info = pane_infos - .iter() - .find(|p| p.id == second_pane) - .expect("second pane info") - .clone(); - - ws.tabs[0].runtimes.insert( - first_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( - first_info.inner_rect.width.max(1), - first_info.inner_rect.height.max(1), - b"", - ), - ); - ws.tabs[0].runtimes.insert( - second_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( - second_info.inner_rect.width.max(1), - second_info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - - ws.tabs[0].layout.focus_pane(first_pane); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - assert_eq!( - app.state.workspaces[0].tabs[0].layout.focused(), - first_pane, - "first pane should be focused before click" - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - second_info.inner_rect.x + 2, - second_info.inner_rect.y + 2, - )); - - assert_eq!( - app.state.workspaces[0].tabs[0].layout.focused(), - second_pane, - "left-clicking a pane with mouse reporting should move focus to it" - ); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_right_button() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(ratatui::layout::Direction::Vertical); - - let terminal_area = Rect::new(26, 2, 80, 18); - let pane_infos = ws.tabs[0].layout.panes(terminal_area); - let first_info = pane_infos - .iter() - .find(|p| p.id == first_pane) - .expect("first pane info") - .clone(); - let second_info = pane_infos - .iter() - .find(|p| p.id == second_pane) - .expect("second pane info") - .clone(); - - ws.tabs[0].runtimes.insert( - first_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( - first_info.inner_rect.width.max(1), - first_info.inner_rect.height.max(1), - b"", - ), - ); - ws.tabs[0].runtimes.insert( - second_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( - second_info.inner_rect.width.max(1), - second_info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - - ws.tabs[0].layout.focus_pane(first_pane); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - assert_eq!( - app.state.workspaces[0].tabs[0].layout.focused(), - first_pane, - "first pane should be focused before click" - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - second_info.inner_rect.x + 2, - second_info.inner_rect.y + 2, - )); - - assert_eq!( - app.state.workspaces[0].tabs[0].layout.focused(), - second_pane, - "right-clicking a pane with mouse reporting should move focus to it" - ); - assert_eq!( - app.state.mode, - Mode::ContextMenu, - "right-click should enter ContextMenu mode" - ); - assert!( - app.state.context_menu.is_some(), - "right-click should populate context_menu state" - ); - } -} diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs new file mode 100644 index 00000000..2f069fea --- /dev/null +++ b/src/app/input/mod.rs @@ -0,0 +1,321 @@ +//! Input handling — translates crossterm key/mouse events into state mutations. + +use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; + +use crate::input::TerminalKey; +use ratatui::layout::Direction; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ScrollbarClickTarget { + Thumb { grab_row_offset: u16 }, + Track { offset_from_bottom: usize }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg(test)] +enum WheelRouting { + HostScroll, + MouseReport, + AlternateScroll, +} + +const WORKSPACE_DRAG_THRESHOLD: u16 = 1; +const TAB_DRAG_THRESHOLD: u16 = 1; + +mod modal; +mod mouse; +mod navigate; +mod overlays; +mod selection; +mod settings; +mod sidebar; +mod terminal; + +pub(crate) use self::{ + modal::{ + handle_confirm_close_key, handle_context_menu_key, handle_global_menu_key, + handle_keybind_help_key, handle_rename_key, handle_resize_key, + }, + navigate::{handle_navigate_key, terminal_direct_navigation_action}, +}; +use self::{ + modal::{ + modal_action_from_key, ModalAction, ONBOARDING_NOTIFICATION_ACTIONS, + ONBOARDING_WELCOME_ACTIONS, RELEASE_NOTES_ACTIONS, + }, + settings::SettingsAction, +}; +use super::state::{AppState, Mode}; +use super::App; + +// --------------------------------------------------------------------------- +// Key handling +// --------------------------------------------------------------------------- + +impl App { + pub(super) async fn handle_key(&mut self, key: TerminalKey) { + match self.state.mode { + Mode::Terminal => self.handle_terminal_key(key).await, + _ => { + let key = key.as_key_event(); + match self.state.mode { + Mode::Onboarding => self.handle_onboarding_key(key), + Mode::ReleaseNotes => self.handle_release_notes_key(key), + Mode::Navigate => self.handle_navigate_key(key), + Mode::RenameWorkspace | Mode::RenameTab => { + 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), + Mode::Settings => self.handle_settings_key(key), + Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key), + Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key), + Mode::Terminal => unreachable!(), + } + } + } + } + + 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 _ = rt.send_paste(text).await; + } + } + } + + pub(crate) fn handle_onboarding_key(&mut self, key: KeyEvent) { + match self.state.onboarding_step { + 0 => match key.code { + KeyCode::Right | KeyCode::Char('l') => { + self.state.onboarding_step = 1; + } + _ => match modal_action_from_key(&key, ONBOARDING_WELCOME_ACTIONS) { + Some(ModalAction::Continue) => self.state.onboarding_step = 1, + _ => {} + }, + }, + _ => match key.code { + KeyCode::Up | KeyCode::Char('k') => self.state.onboarding_list.move_prev(), + KeyCode::Down | KeyCode::Char('j') => self.state.onboarding_list.move_next(4), + KeyCode::Left | KeyCode::Char('h') => { + self.state.onboarding_step = 0; + } + KeyCode::Char(c) if ('1'..='4').contains(&c) => { + self.state + .onboarding_list + .select((c as usize) - ('1' as usize)); + } + _ => match modal_action_from_key(&key, ONBOARDING_NOTIFICATION_ACTIONS) { + Some(ModalAction::Back) => self.state.onboarding_step = 0, + Some(ModalAction::Save) => self.complete_onboarding(), + _ => {} + }, + }, + } + } + + pub(crate) fn handle_release_notes_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Up | KeyCode::Char('k') => self.scroll_release_notes(-1), + KeyCode::Down | KeyCode::Char('j') => self.scroll_release_notes(1), + KeyCode::PageUp => self.scroll_release_notes(-8), + KeyCode::PageDown => self.scroll_release_notes(8), + KeyCode::Home => { + if let Some(notes) = &mut self.state.release_notes { + notes.scroll = 0; + } + } + KeyCode::End => { + let max_scroll = self.state.release_notes_max_scroll(); + if let Some(notes) = &mut self.state.release_notes { + notes.scroll = max_scroll; + } + } + _ => match modal_action_from_key(&key, RELEASE_NOTES_ACTIONS) { + Some(ModalAction::Close) => self.dismiss_release_notes(), + _ => {} + }, + } + } + + pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) { + if self.handle_overlay_mouse(mouse) { + return; + } + + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && self.state.on_sidebar_divider(mouse.column, mouse.row) + { + let now = std::time::Instant::now(); + let is_double_click = self + .last_sidebar_divider_click + .is_some_and(|last| now.duration_since(last) <= super::SIDEBAR_DOUBLE_CLICK_WINDOW); + self.last_sidebar_divider_click = Some(now); + + if is_double_click { + self.state.sidebar_width = self.state.default_sidebar_width; + self.state.sidebar_width_auto = false; + self.state.mark_session_dirty(); + self.state.drag = None; + return; + } + } + + if let Some(action) = self.state.handle_mouse(mouse) { + match action { + SettingsAction::SaveTheme(name) => self.save_theme(&name), + SettingsAction::SaveSound(enabled) => self.save_sound(enabled), + SettingsAction::SaveToast(enabled) => self.save_toast(enabled), + } + } + + if let Some(content) = self.state.request_clipboard_write.take() { + if self + .event_tx + .try_send(crate::events::AppEvent::ClipboardWrite { content }) + .is_err() + { + tracing::warn!("failed to queue clipboard write event"); + } + } + } +} + +// --------------------------------------------------------------------------- +// Mouse handling +// --------------------------------------------------------------------------- + +// 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, + self.pane_scrollback_limit_bytes, + self.host_terminal_theme, + ) { + ws.layout.focus_pane(new_id); + self.mark_session_dirty(); + self.mode = Mode::Terminal; + } + } + } +} + +#[cfg(test)] +fn state_with_workspaces(names: &[&str]) -> AppState { + let mut state = AppState::test_new(); + state.workspaces = names + .iter() + .map(|name| crate::workspace::Workspace::test_new(name)) + .collect(); + if !state.workspaces.is_empty() { + state.active = Some(0); + state.selected = 0; + state.mode = Mode::Navigate; + } + state +} + +#[cfg(test)] +fn app_for_mouse_test() -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.mode = Mode::Terminal; + app.state.update_available = None; + app.state.latest_release_notes_available = false; + app.state.view.sidebar_rect = ratatui::layout::Rect::new(0, 0, 26, 20); + app.state.view.terminal_area = ratatui::layout::Rect::new(26, 0, 80, 20); + app +} + +#[cfg(test)] +fn mouse( + kind: crossterm::event::MouseEventKind, + col: u16, + row: u16, +) -> crossterm::event::MouseEvent { + crossterm::event::MouseEvent { + kind, + column: col, + row, + modifiers: crossterm::event::KeyModifiers::empty(), + } +} + +#[cfg(test)] +fn numbered_lines_bytes(count: usize) -> Vec<u8> { + (0..count) + .map(|i| format!("{i:06}\r\n")) + .collect::<String>() + .into_bytes() +} + +#[cfg(test)] +fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot { + crate::persist::capture( + &state.workspaces, + state.active, + state.selected, + state.agent_panel_scope, + state.sidebar_width, + state.sidebar_section_split, + ) +} + +#[cfg(test)] +fn root_layout_ratio(snapshot: &crate::persist::SessionSnapshot) -> Option<f32> { + match &snapshot.workspaces.first()?.tabs.first()?.layout { + crate::persist::LayoutSnapshot::Split { ratio, .. } => Some(*ratio), + crate::persist::LayoutSnapshot::Pane(_) => None, + } +} + +#[cfg(test)] +fn unique_temp_path(name: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) +} + +#[cfg(test)] +fn wait_for_file(path: &std::path::Path) -> String { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + if let Ok(content) = std::fs::read_to_string(path) { + return content; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("timed out waiting for {}", path.display()); +} diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs new file mode 100644 index 00000000..2ba9b6f4 --- /dev/null +++ b/src/app/input/modal.rs @@ -0,0 +1,739 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Direction, Rect}; + +use crate::{ + app::state::{key_matches, AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode}, + layout::NavDirection, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ModalAction { + Continue, + Back, + Save, + Clear, + Cancel, + Confirm, + Apply, + Close, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ModalKeyBinding { + Enter, + Esc, + CtrlC, +} + +impl ModalKeyBinding { + fn matches(self, key: &KeyEvent) -> bool { + match self { + Self::Enter => key.code == KeyCode::Enter, + Self::Esc => key.code == KeyCode::Esc, + Self::CtrlC => { + key.code == KeyCode::Char('c') + && key.modifiers == crossterm::event::KeyModifiers::CONTROL + } + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct ModalActionSpec<A> { + pub action: A, + pub bindings: &'static [ModalKeyBinding], +} + +pub(super) fn modal_action_from_key<A: Copy>( + key: &KeyEvent, + specs: &[ModalActionSpec<A>], +) -> Option<A> { + specs + .iter() + .find(|spec| spec.bindings.iter().any(|binding| binding.matches(key))) + .map(|spec| spec.action) +} + +pub(super) fn modal_action_from_buttons<A: Copy>( + col: u16, + row: u16, + buttons: &[(Rect, A)], +) -> Option<A> { + buttons.iter().find_map(|(rect, action)| { + (col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height) + .then_some(*action) + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GlobalMenuAction { + Quit, + WhatsNew, + Keybinds, + ReloadKeybinds, + Settings, +} + +pub(super) fn global_menu_actions(state: &AppState) -> Vec<GlobalMenuAction> { + let mut actions = vec![ + GlobalMenuAction::Settings, + GlobalMenuAction::Keybinds, + GlobalMenuAction::ReloadKeybinds, + ]; + if state.update_available.is_some() || state.latest_release_notes_available { + actions.push(GlobalMenuAction::WhatsNew); + } + actions.push(GlobalMenuAction::Quit); + actions +} + +pub(super) fn open_global_menu(state: &mut AppState) { + state.global_menu = MenuListState::new(0); + state.mode = Mode::GlobalMenu; +} + +pub(super) fn open_keybind_help(state: &mut AppState) { + state.keybind_help.scroll = 0; + state.mode = Mode::KeybindHelp; +} + +fn open_update_release_notes(state: &mut AppState) { + let Some(notes) = crate::release_notes::load_latest() else { + return; + }; + + state.release_notes = Some(crate::app::state::ReleaseNotesState { + version: notes.version, + body: notes.body, + scroll: 0, + preview: notes.preview, + }); + state.mode = Mode::ReleaseNotes; +} + +pub(super) fn request_quit_or_detach(state: &mut AppState) { + if state.quit_detaches { + state.detach_requested = true; + } else { + state.should_quit = true; + } +} + +pub(super) fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) { + match action { + GlobalMenuAction::Quit => request_quit_or_detach(state), + GlobalMenuAction::WhatsNew => open_update_release_notes(state), + GlobalMenuAction::Keybinds => open_keybind_help(state), + GlobalMenuAction::ReloadKeybinds => { + state.request_reload_keybinds = true; + leave_modal(state); + } + GlobalMenuAction::Settings => super::settings::open_settings(state), + } +} + +pub(crate) fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) { + let actions = global_menu_actions(state); + match key.code { + KeyCode::Esc => leave_modal(state), + KeyCode::Up | KeyCode::Char('k') => state.global_menu.move_prev(), + KeyCode::Down | KeyCode::Char('j') => state.global_menu.move_next(actions.len()), + KeyCode::Enter => { + if let Some(action) = actions.get(state.global_menu.highlighted).copied() { + apply_global_menu_action(state, action); + } + } + _ => {} + } +} + +pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) { + match key.code { + KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1), + KeyCode::Down | KeyCode::Char('j') => state.scroll_keybind_help(1), + KeyCode::PageUp => state.scroll_keybind_help(-8), + KeyCode::PageDown => state.scroll_keybind_help(8), + KeyCode::Home => state.keybind_help.scroll = 0, + KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(), + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => leave_modal(state), + _ => {} + } +} + +pub(super) fn open_rename_workspace(state: &mut AppState, ws_idx: usize) { + state.selected = ws_idx; + state.name_input = state.workspaces[ws_idx].display_name(); + state.name_input_replace_on_type = false; + state.mode = Mode::RenameWorkspace; +} + +pub(super) fn open_rename_active_tab(state: &mut AppState, replace_on_type: bool) { + state.creating_new_tab = false; + state.requested_new_tab_name = None; + if let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) { + if let Some(name) = ws.active_tab_display_name() { + state.name_input = name; + state.name_input_replace_on_type = replace_on_type; + state.mode = Mode::RenameTab; + } + } +} + +fn next_new_tab_default_name(state: &AppState) -> String { + state + .active + .and_then(|i| state.workspaces.get(i)) + .map(|ws| (ws.tabs.len() + 1).to_string()) + .unwrap_or_else(|| "1".to_string()) +} + +pub(super) fn open_new_tab_dialog(state: &mut AppState) { + state.creating_new_tab = true; + state.requested_new_tab_name = None; + state.name_input = next_new_tab_default_name(state); + state.name_input_replace_on_type = true; + state.mode = Mode::RenameTab; +} + +pub(super) fn leave_modal(state: &mut AppState) { + if state.active.is_some() { + state.mode = Mode::Terminal; + } else { + state.mode = Mode::Navigate; + } +} + +pub(super) const ONBOARDING_WELCOME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec { + action: ModalAction::Continue, + bindings: &[ModalKeyBinding::Enter], +}]; + +pub(super) const ONBOARDING_NOTIFICATION_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ + ModalActionSpec { + action: ModalAction::Back, + bindings: &[ModalKeyBinding::Esc], + }, + ModalActionSpec { + action: ModalAction::Save, + bindings: &[ModalKeyBinding::Enter], + }, +]; + +pub(super) const RELEASE_NOTES_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec { + action: ModalAction::Close, + bindings: &[ModalKeyBinding::Enter, ModalKeyBinding::Esc], +}]; + +pub(super) const RENAME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ + ModalActionSpec { + action: ModalAction::Save, + bindings: &[ModalKeyBinding::Enter], + }, + ModalActionSpec { + action: ModalAction::Clear, + bindings: &[ModalKeyBinding::CtrlC], + }, + ModalActionSpec { + action: ModalAction::Cancel, + bindings: &[ModalKeyBinding::Esc], + }, +]; + +pub(super) const CONFIRM_CLOSE_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ + ModalActionSpec { + action: ModalAction::Confirm, + bindings: &[ModalKeyBinding::Enter], + }, + ModalActionSpec { + action: ModalAction::Cancel, + bindings: &[ModalKeyBinding::Esc], + }, +]; + +pub(super) const SETTINGS_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ + ModalActionSpec { + action: ModalAction::Apply, + bindings: &[ModalKeyBinding::Enter], + }, + ModalActionSpec { + action: ModalAction::Close, + bindings: &[ModalKeyBinding::Esc], + }, +]; + +pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) { + match action { + ModalAction::Save => { + let new_name = if state.name_input.trim().is_empty() { + state.name_input.clone() + } else { + state.name_input.trim().to_string() + }; + match state.mode { + Mode::RenameWorkspace if !state.workspaces.is_empty() => { + if !new_name.is_empty() { + state.workspaces[state.selected].set_custom_name(new_name); + state.mark_session_dirty(); + } + } + Mode::RenameTab if state.creating_new_tab => { + state.request_new_tab = true; + let default_name = next_new_tab_default_name(state); + state.requested_new_tab_name = + if new_name.is_empty() || new_name == default_name { + None + } else { + Some(new_name) + }; + } + Mode::RenameTab => { + if let Some(ws) = state.active.and_then(|i| state.workspaces.get_mut(i)) { + if let Some(tab) = ws.active_tab_mut() { + let keep_auto_name = + tab.is_auto_named() && new_name == tab.number.to_string(); + if !new_name.is_empty() && !keep_auto_name { + tab.set_custom_name(new_name); + state.mark_session_dirty(); + } + } + } + } + _ => {} + } + state.creating_new_tab = false; + state.name_input.clear(); + state.name_input_replace_on_type = false; + leave_modal(state); + } + ModalAction::Clear => { + state.name_input.clear(); + state.name_input_replace_on_type = false; + } + ModalAction::Cancel => { + state.creating_new_tab = false; + state.requested_new_tab_name = None; + state.name_input.clear(); + state.name_input_replace_on_type = false; + leave_modal(state); + } + _ => {} + } +} + +pub(crate) fn handle_rename_key(state: &mut AppState, key: KeyEvent) { + if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) { + apply_rename_action(state, action); + return; + } + + match key.code { + KeyCode::Backspace => { + if state.name_input_replace_on_type { + state.name_input.clear(); + state.name_input_replace_on_type = false; + } else { + state.name_input.pop(); + } + } + KeyCode::Char(c) => { + if state.name_input_replace_on_type { + state.name_input.clear(); + state.name_input_replace_on_type = false; + } + state.name_input.push(c); + } + _ => {} + } +} + +pub(crate) fn handle_resize_key(state: &mut AppState, key: KeyEvent) { + if key.code == KeyCode::Esc + || key.code == KeyCode::Enter + || key_matches( + &key, + state.keybinds.resize_mode.0, + state.keybinds.resize_mode.1, + ) + { + if state.active.is_some() { + state.mode = Mode::Terminal; + } else { + state.mode = Mode::Navigate; + } + return; + } + + match key.code { + 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), + _ => {} + } +} + +pub(super) fn open_confirm_close(state: &mut AppState) { + state.mode = Mode::ConfirmClose; +} + +pub(super) fn confirm_close_accept(state: &mut AppState) { + state.close_selected_workspace(); + if state.workspaces.is_empty() { + state.mode = Mode::Navigate; + } else { + state.mode = Mode::Terminal; + } +} + +pub(super) fn confirm_close_cancel(state: &mut AppState) { + state.mode = Mode::Navigate; +} + +pub(crate) fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) { + match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) { + Some(ModalAction::Confirm) => confirm_close_accept(state), + Some(ModalAction::Cancel) => confirm_close_cancel(state), + _ => {} + } +} + +pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx: usize) { + let item = menu.items().get(idx).copied(); + match (menu.kind, item) { + (ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => { + open_rename_workspace(state, ws_idx); + } + (ContextMenuKind::Workspace { ws_idx }, Some("Close")) => { + state.selected = ws_idx; + if state.confirm_close { + open_confirm_close(state); + } else { + state.close_selected_workspace(); + state.mode = Mode::Navigate; + } + } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + open_new_tab_dialog(state); + } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + open_rename_active_tab(state, false); + } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + state.close_tab(); + state.mode = if state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + (ContextMenuKind::Pane, Some("Split vertical")) => { + state.split_pane(Direction::Horizontal); + state.mode = Mode::Terminal; + } + (ContextMenuKind::Pane, Some("Split horizontal")) => { + state.split_pane(Direction::Vertical); + state.mode = Mode::Terminal; + } + (ContextMenuKind::Pane, Some("Fullscreen")) => { + state.toggle_fullscreen(); + state.mode = Mode::Terminal; + } + (ContextMenuKind::Pane, Some("Close pane")) => { + state.close_pane(); + state.mode = if state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + _ => leave_modal(state), + } +} + +pub(crate) fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + state.context_menu = None; + leave_modal(state); + } + KeyCode::Up => { + if let Some(menu) = &mut state.context_menu { + menu.list.move_prev(); + } + } + KeyCode::Down => { + if let Some(menu) = &mut state.context_menu { + menu.list.move_next(menu.items().len()); + } + } + KeyCode::Enter => { + if let Some(menu) = state.context_menu.take() { + let idx = menu.list.highlighted; + apply_context_menu_action(state, menu, idx); + } + } + _ => {} + } +} + +impl AppState { + pub(super) fn global_menu_item_at(&self, col: u16, row: u16) -> Option<GlobalMenuAction> { + let rect = self.global_menu_rect(); + if col <= rect.x + || col >= rect.x + rect.width.saturating_sub(1) + || row <= rect.y + || row >= rect.y + rect.height.saturating_sub(1) + { + return None; + } + let idx = (row - rect.y - 1) as usize; + global_menu_actions(self).get(idx).copied() + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::layout::Rect; + + use super::super::{capture_snapshot, state_with_workspaces}; + use super::*; + + #[test] + fn custom_resize_key_exits_resize_mode() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::Resize; + state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty()); + state.keybinds.resize_mode_label = "g".into(); + + handle_resize_key( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] + fn rename_modal_keyboard_and_mouse_share_actions() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::RenameWorkspace; + state.name_input = "hello".into(); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + assert!(state.name_input.is_empty()); + + state.name_input = "renamed".into(); + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + assert_eq!(state.mode, Mode::Terminal); + assert_eq!(state.workspaces[0].display_name(), "renamed"); + let snapshot = capture_snapshot(&state); + assert_eq!( + snapshot.workspaces[0].custom_name.as_deref(), + Some("renamed") + ); + + state.view.sidebar_rect = Rect::new(0, 0, 26, 20); + state.view.terminal_area = Rect::new(26, 0, 80, 20); + state.mode = Mode::RenameWorkspace; + state.name_input = "mouse".into(); + let inner = state.rename_modal_inner().unwrap(); + let (save, _, _) = crate::ui::rename_button_rects(inner); + let action = modal_action_from_buttons(save.x, save.y, &[(save, ModalAction::Save)]); + assert_eq!(action, Some(ModalAction::Save)); + } + + #[test] + fn tab_rename_updates_captured_snapshot() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::RenameTab; + state.name_input = "logs".into(); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + let snapshot = capture_snapshot(&state); + assert_eq!( + snapshot.workspaces[0].tabs[0].custom_name.as_deref(), + Some("logs") + ); + } + + #[test] + fn rename_cancel_returns_to_terminal_when_workspace_is_active() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::RenameTab; + state.name_input = "test".into(); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert!(state.name_input.is_empty()); + } + + #[test] + fn rename_modal_replaces_prefilled_text_on_first_type() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::RenameTab; + state.name_input = "2".into(); + state.name_input_replace_on_type = true; + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty()), + ); + assert_eq!(state.name_input, "n"); + assert!(!state.name_input_replace_on_type); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()), + ); + assert_eq!(state.name_input, "ne"); + } + + #[test] + fn open_rename_active_tab_can_prefill_default_new_tab_name() { + let mut state = state_with_workspaces(&["test"]); + state.workspaces[0].test_add_tab(None); + state.workspaces[0].switch_tab(1); + + open_rename_active_tab(&mut state, true); + + assert_eq!(state.mode, Mode::RenameTab); + assert_eq!(state.name_input, "2"); + assert!(state.name_input_replace_on_type); + } + + #[test] + fn cancel_new_tab_dialog_leaves_workspace_unchanged() { + let mut state = state_with_workspaces(&["test"]); + open_new_tab_dialog(&mut state); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert!(!state.creating_new_tab); + assert!(!state.request_new_tab); + assert!(state.requested_new_tab_name.is_none()); + assert_eq!(state.workspaces[0].tabs.len(), 1); + } + + #[test] + fn saving_new_tab_dialog_requests_creation_with_name() { + let mut state = state_with_workspaces(&["test"]); + open_new_tab_dialog(&mut state); + state.name_input = "logs".into(); + state.name_input_replace_on_type = false; + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert!(!state.creating_new_tab); + assert!(state.request_new_tab); + assert_eq!(state.requested_new_tab_name.as_deref(), Some("logs")); + } + + #[test] + fn saving_new_tab_dialog_with_default_name_keeps_tab_auto_named() { + let mut state = state_with_workspaces(&["test"]); + open_new_tab_dialog(&mut state); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert!(!state.creating_new_tab); + assert!(state.request_new_tab); + assert!(state.requested_new_tab_name.is_none()); + } + + #[test] + fn closing_first_auto_tab_resets_remaining_auto_tab_and_next_prompt() { + let mut state = state_with_workspaces(&["test"]); + open_new_tab_dialog(&mut state); + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + state.workspaces[0].test_add_tab(state.requested_new_tab_name.as_deref()); + state.request_new_tab = false; + state.requested_new_tab_name = None; + + state.workspaces[0].close_tab(0); + state.workspaces[0].switch_tab(0); + + assert_eq!(state.workspaces[0].tabs[0].display_name(), "1"); + assert!(state.workspaces[0].tabs[0].custom_name.is_none()); + + open_new_tab_dialog(&mut state); + assert_eq!(state.name_input, "2"); + } + + #[test] + fn renaming_auto_tab_to_its_default_number_keeps_it_auto_named() { + let mut state = state_with_workspaces(&["test"]); + state.workspaces[0].test_add_tab(None); + state.workspaces[0].switch_tab(1); + + open_rename_active_tab(&mut state, false); + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert!(state.workspaces[0].tabs[1].custom_name.is_none()); + assert_eq!(state.workspaces[0].tabs[1].display_name(), "2"); + } + + #[test] + fn confirm_close_keyboard_actions_are_direct_not_focused() { + let mut state = state_with_workspaces(&["a", "b"]); + state.mode = Mode::ConfirmClose; + state.selected = 1; + + handle_confirm_close_key( + &mut state, + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), + ); + assert_eq!(state.mode, Mode::Navigate); + assert_eq!(state.workspaces.len(), 2); + + state.mode = Mode::ConfirmClose; + handle_confirm_close_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + assert_eq!(state.workspaces.len(), 1); + } +} diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs new file mode 100644 index 00000000..0349d236 --- /dev/null +++ b/src/app/input/mouse.rs @@ -0,0 +1,1258 @@ +use bytes::Bytes; +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::{Direction, Rect}; +use tracing::warn; + +use crate::{ + app::state::{ + AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, + MenuListState, Mode, TabPressState, WorkspacePressState, + }, + layout::{PaneInfo, SplitBorder}, + selection::Selection, +}; + +#[cfg(test)] +use super::WheelRouting; +use super::{ + modal::{ + apply_context_menu_action, apply_global_menu_action, apply_rename_action, + confirm_close_accept, confirm_close_cancel, global_menu_actions, leave_modal, + modal_action_from_buttons, open_global_menu, open_new_tab_dialog, ModalAction, + }, + settings::SettingsAction, + ScrollbarClickTarget, TAB_DRAG_THRESHOLD, WORKSPACE_DRAG_THRESHOLD, +}; + +impl AppState { + pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> { + if self.mode == Mode::Onboarding { + self.handle_onboarding_mouse(mouse); + return None; + } + + if self.mode == Mode::Settings { + return self.handle_settings_mouse(mouse); + } + + let launcher_enabled = !self.sidebar_collapsed + && matches!( + self.mode, + Mode::Terminal + | Mode::Navigate + | Mode::Resize + | Mode::GlobalMenu + | Mode::KeybindHelp + ); + let launcher = self.global_launcher_rect(); + let launcher_hit = launcher_enabled + && mouse.column >= launcher.x + && mouse.column < launcher.x + launcher.width + && mouse.row >= launcher.y + && mouse.row < launcher.y + launcher.height; + + if matches!(mouse.kind, MouseEventKind::Moved) && self.mode == Mode::GlobalMenu { + let actions = global_menu_actions(self); + let hovered = self + .global_menu_item_at(mouse.column, mouse.row) + .and_then(|action| actions.iter().position(|item| *item == action)); + self.global_menu.hover(hovered); + return None; + } + + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && launcher_hit { + if self.mode == Mode::GlobalMenu { + leave_modal(self); + } else { + open_global_menu(self); + } + return None; + } + + if self.mode == Mode::GlobalMenu { + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + if let Some(action) = self.global_menu_item_at(mouse.column, mouse.row) { + apply_global_menu_action(self, action); + } else { + leave_modal(self); + } + } + return None; + } + + if self.mode == Mode::KeybindHelp { + return None; + } + + 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; + self.workspace_press = None; + + if self.mode == Mode::ConfirmClose { + let popup = self.confirm_close_rect(); + let inner = Rect::new( + popup.x + 1, + popup.y + 1, + popup.width.saturating_sub(2), + popup.height.saturating_sub(2), + ); + let (confirm, cancel) = crate::ui::confirm_close_button_rects(inner); + match modal_action_from_buttons( + mouse.column, + mouse.row, + &[ + (confirm, ModalAction::Confirm), + (cancel, ModalAction::Cancel), + ], + ) { + Some(ModalAction::Confirm) => confirm_close_accept(self), + Some(ModalAction::Cancel) | None => confirm_close_cancel(self), + _ => {} + } + return None; + } + + if matches!(self.mode, Mode::RenameWorkspace | Mode::RenameTab) { + let action = self + .rename_modal_inner() + .map(crate::ui::rename_button_rects) + .and_then(|(save, clear, cancel)| { + modal_action_from_buttons( + mouse.column, + mouse.row, + &[ + (save, ModalAction::Save), + (clear, ModalAction::Clear), + (cancel, ModalAction::Cancel), + ], + ) + }) + .unwrap_or(ModalAction::Cancel); + apply_rename_action(self, action); + return None; + } + + if self.mode == Mode::ContextMenu { + let item_idx = self.context_menu_item_at(mouse.column, mouse.row); + if let Some(menu) = self.context_menu.take() { + if let Some(idx) = item_idx { + apply_context_menu_action(self, menu, idx); + } else { + leave_modal(self); + } + } + return None; + } + + if self.on_sidebar_divider(mouse.column, mouse.row) { + self.drag = Some(DragState { + target: DragTarget::SidebarDivider, + }); + self.set_manual_sidebar_width(mouse.column); + return None; + } + + if self.on_sidebar_section_divider(mouse.column, mouse.row) { + self.drag = Some(DragState { + target: DragTarget::SidebarSectionDivider, + }); + self.set_sidebar_section_split(mouse.row); + return None; + } + + if !in_sidebar { + if let Some(border) = self.find_border_at(mouse.column, mouse.row) { + self.drag = Some(DragState { + target: DragTarget::PaneSplit { + path: border.path.clone(), + direction: border.direction, + area: border.area, + }, + }); + return None; + } + + if let Some((pane_id, target)) = + self.scrollbar_target_at(mouse.column, mouse.row) + { + self.focus_pane(pane_id); + match target { + ScrollbarClickTarget::Thumb { grab_row_offset } => { + self.drag = Some(DragState { + target: DragTarget::PaneScrollbar { + pane_id, + grab_row_offset, + }, + }); + } + ScrollbarClickTarget::Track { offset_from_bottom } => { + self.set_pane_scroll_offset(pane_id, offset_from_bottom); + } + } + if self.mode != Mode::Terminal { + self.mode = Mode::Terminal; + } + return None; + } + } + + if self.on_tab_scroll_left_button(mouse.column, mouse.row) { + self.scroll_tabs_left(); + return None; + } + if self.on_tab_scroll_right_button(mouse.column, mouse.row) { + self.scroll_tabs_right(); + return None; + } + if let (Some(ws_idx), Some(tab_idx)) = + (self.active, self.tab_at(mouse.column, mouse.row)) + { + self.tab_press = Some(TabPressState { + ws_idx, + tab_idx, + start_col: mouse.column, + start_row: mouse.row, + }); + return None; + } + if self.on_new_tab_button(mouse.column, mouse.row) { + open_new_tab_dialog(self); + return None; + } + + if in_sidebar { + if self.sidebar_collapsed { + if self.on_collapsed_sidebar_toggle(mouse.column, mouse.row) { + self.sidebar_collapsed = false; + return None; + } + + if let Some(idx) = self.collapsed_workspace_at_row(mouse.row) { + self.switch_workspace(idx); + self.mode = Mode::Terminal; + return None; + } + + if let Some((ws_idx, tab_idx, pane_id)) = + self.collapsed_agent_detail_target_at(mouse.row) + { + self.switch_workspace(ws_idx); + self.switch_tab(tab_idx); + self.focus_pane(pane_id); + self.mode = Mode::Terminal; + } + return None; + } + + let new_button = self.sidebar_new_button_rect(); + let on_new_button = mouse.row >= new_button.y + && mouse.row < new_button.y + new_button.height + && mouse.column >= new_button.x + && mouse.column < new_button.x + new_button.width; + if on_new_button { + self.request_new_workspace = true; + return None; + } + + if let Some(target) = + self.workspace_list_scrollbar_target_at(mouse.column, mouse.row) + { + match target { + ScrollbarClickTarget::Thumb { grab_row_offset } => { + self.drag = Some(DragState { + target: DragTarget::WorkspaceListScrollbar { grab_row_offset }, + }); + } + ScrollbarClickTarget::Track { offset_from_bottom } => { + self.set_workspace_list_offset_from_bottom(offset_from_bottom); + } + } + return None; + } + + if let Some(idx) = self.workspace_at_row(mouse.row) { + self.workspace_press = Some(WorkspacePressState { + ws_idx: idx, + start_col: mouse.column, + start_row: mouse.row, + }); + return None; + } + + if self.on_agent_panel_scope_toggle(mouse.column, mouse.row) { + self.agent_panel_scope = match self.agent_panel_scope { + AgentPanelScope::CurrentWorkspace => AgentPanelScope::AllWorkspaces, + AgentPanelScope::AllWorkspaces => AgentPanelScope::CurrentWorkspace, + }; + self.agent_panel_scroll = 0; + self.mark_session_dirty(); + return None; + } + + if let Some(target) = + self.agent_panel_scrollbar_target_at(mouse.column, mouse.row) + { + match target { + ScrollbarClickTarget::Thumb { grab_row_offset } => { + self.drag = Some(DragState { + target: DragTarget::AgentPanelScrollbar { grab_row_offset }, + }); + } + ScrollbarClickTarget::Track { offset_from_bottom } => { + self.set_agent_panel_offset_from_bottom(offset_from_bottom); + } + } + return None; + } + + if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row) + { + self.switch_workspace(ws_idx); + self.switch_tab(tab_idx); + self.focus_pane(pane_id); + self.mode = Mode::Terminal; + return None; + } + } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { + self.focus_pane(info.id); + if self.mode != Mode::Terminal { + self.mode = Mode::Terminal; + } + + if self.forward_pane_mouse_button(&info, mouse) { + self.selection = None; + return None; + } + + let (row, col) = ( + mouse.row - info.inner_rect.y, + mouse.column - info.inner_rect.x, + ); + self.selection = Some(Selection::anchor( + info.id, + row, + col, + self.pane_scroll_metrics(info.id), + )); + } 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; + self.focus_pane(id); + if self.mode != Mode::Terminal { + self.mode = Mode::Terminal; + } + } + } + + MouseEventKind::Drag(MouseButton::Left) => { + if self.selection.is_some() { + self.update_selection_drag(mouse.column, mouse.row); + return None; + } + + if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { + if self.forward_pane_mouse_button(&info, mouse) { + self.selection = None; + return None; + } + } + + let workspace_drop_index = self.workspace_drop_index_at_row(mouse.row); + let tab_drop_index = self.tab_drop_index_at(mouse.column, mouse.row); + if self.drag.is_none() { + if let Some(press) = &self.workspace_press { + let delta_col = mouse.column.abs_diff(press.start_col); + let delta_row = mouse.row.abs_diff(press.start_row); + if delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD { + self.drag = Some(DragState { + target: DragTarget::WorkspaceReorder { + source_ws_idx: press.ws_idx, + insert_idx: workspace_drop_index, + }, + }); + } + } else if let Some(press) = &self.tab_press { + let delta_col = mouse.column.abs_diff(press.start_col); + let delta_row = mouse.row.abs_diff(press.start_row); + if delta_col.max(delta_row) >= TAB_DRAG_THRESHOLD { + self.drag = Some(DragState { + target: DragTarget::TabReorder { + ws_idx: press.ws_idx, + source_tab_idx: press.tab_idx, + insert_idx: tab_drop_index, + }, + }); + } + } + } + + if let Some(DragState { + target: DragTarget::WorkspaceReorder { insert_idx, .. }, + }) = &mut self.drag + { + *insert_idx = workspace_drop_index; + } else if let Some(DragState { + target: + DragTarget::TabReorder { + ws_idx, insert_idx, .. + }, + }) = &mut self.drag + { + if self.active == Some(*ws_idx) { + *insert_idx = tab_drop_index; + } + } else if let Some(drag) = &self.drag { + match &drag.target { + DragTarget::WorkspaceReorder { .. } | DragTarget::TabReorder { .. } => {} + DragTarget::WorkspaceListScrollbar { grab_row_offset } => { + if let Some(offset_from_bottom) = + self.workspace_list_offset_for_drag_row(mouse.row, *grab_row_offset) + { + self.set_workspace_list_offset_from_bottom(offset_from_bottom); + } + } + DragTarget::AgentPanelScrollbar { grab_row_offset } => { + if let Some(offset_from_bottom) = + self.agent_panel_offset_for_drag_row(mouse.row, *grab_row_offset) + { + self.set_agent_panel_offset_from_bottom(offset_from_bottom); + } + } + DragTarget::PaneSplit { + path, + direction, + area, + } => { + let ratio = match direction { + Direction::Horizontal => { + (mouse.column.saturating_sub(area.x)) as f32 + / area.width.max(1) as f32 + } + Direction::Vertical => { + (mouse.row.saturating_sub(area.y)) as f32 + / area.height.max(1) as f32 + } + }; + let ratio = ratio.clamp(0.1, 0.9); + let path = path.clone(); + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + ws.layout.set_ratio_at(&path, ratio); + self.mark_session_dirty(); + } + } + DragTarget::PaneScrollbar { + pane_id, + grab_row_offset, + } => { + if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row( + *pane_id, + mouse.row, + *grab_row_offset, + ) { + self.set_pane_scroll_offset(*pane_id, offset_from_bottom); + } + } + DragTarget::SidebarDivider => { + self.set_manual_sidebar_width(mouse.column); + } + DragTarget::SidebarSectionDivider => { + self.set_sidebar_section_split(mouse.row); + } + DragTarget::ReleaseNotesScrollbar { .. } + | DragTarget::KeybindHelpScrollbar { .. } => {} + } + } + } + + MouseEventKind::Up(MouseButton::Left) => { + if self.selection.is_some() { + self.workspace_press = None; + self.tab_press = None; + self.drag = None; + 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(); + } + return None; + } + + if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { + if self.forward_pane_mouse_button(&info, mouse) { + self.selection = None; + self.workspace_press = None; + self.tab_press = None; + self.drag = None; + return None; + } + } + + let workspace_press = self.workspace_press.take(); + let tab_press = self.tab_press.take(); + match self.drag.take() { + Some(DragState { + target: + DragTarget::WorkspaceReorder { + source_ws_idx, + insert_idx: Some(insert_idx), + }, + }) => { + self.move_workspace(source_ws_idx, insert_idx); + } + Some(DragState { + target: + DragTarget::TabReorder { + ws_idx, + source_tab_idx, + insert_idx: Some(insert_idx), + }, + }) => { + if self.active == Some(ws_idx) { + self.move_tab(source_tab_idx, insert_idx); + self.mode = Mode::Terminal; + } + } + Some(_) => {} + None => { + if let Some(press) = workspace_press { + self.switch_workspace(press.ws_idx); + self.mode = Mode::Terminal; + return None; + } + if let Some(press) = tab_press { + if self.active == Some(press.ws_idx) { + self.switch_tab(press.tab_idx); + self.mode = Mode::Terminal; + return None; + } + } + 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::Up(MouseButton::Middle) | MouseEventKind::Drag(MouseButton::Middle) + if !in_sidebar => + { + if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { + let _ = self.forward_pane_mouse_button(&info, mouse); + } + } + + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => { + if self.on_tab_bar(mouse.column, mouse.row) { + match mouse.kind { + MouseEventKind::ScrollUp => self.scroll_tabs_left(), + MouseEventKind::ScrollDown => self.scroll_tabs_right(), + _ => {} + } + } else if !self.scroll_selection_with_wheel(mouse) { + self.selection = None; + self.handle_terminal_wheel(mouse); + } + } + + MouseEventKind::ScrollUp if in_sidebar => { + let agent_area = self.agent_panel_rect(); + let over_agent_panel = agent_area != Rect::default() + && mouse.row >= agent_area.y + && mouse.row < agent_area.y + agent_area.height; + if over_agent_panel { + if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( + self, agent_area, + )) { + self.scroll_agent_panel(-1); + } + } else if crate::ui::should_show_scrollbar( + crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()), + ) { + self.scroll_workspace_list(-1); + } else if self.selected > 0 { + self.selected -= 1; + self.ensure_workspace_visible(self.selected); + } + } + MouseEventKind::ScrollDown if in_sidebar => { + let agent_area = self.agent_panel_rect(); + let over_agent_panel = agent_area != Rect::default() + && mouse.row >= agent_area.y + && mouse.row < agent_area.y + agent_area.height; + if over_agent_panel { + if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( + self, agent_area, + )) { + self.scroll_agent_panel(1); + } + } else if crate::ui::should_show_scrollbar( + crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()), + ) { + self.scroll_workspace_list(1); + } else if !self.workspaces.is_empty() && self.selected < self.workspaces.len() - 1 { + self.selected += 1; + self.ensure_workspace_visible(self.selected); + } + } + + MouseEventKind::Moved if self.mode == Mode::ContextMenu => { + let hovered = self.context_menu_item_at(mouse.column, mouse.row); + if let Some(menu) = &mut self.context_menu { + menu.list.hover(hovered); + } + } + + MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => { + if self + .workspace_list_scrollbar_target_at(mouse.column, mouse.row) + .is_some() + { + return None; + } + if let Some(idx) = self.workspace_at_row(mouse.row) { + self.selected = idx; + self.context_menu = Some(ContextMenuState { + kind: ContextMenuKind::Workspace { ws_idx: idx }, + x: mouse.column, + y: mouse.row, + list: MenuListState::new(0), + }); + self.mode = Mode::ContextMenu; + } + } + + MouseEventKind::Down(MouseButton::Right) + if self.tab_at(mouse.column, mouse.row).is_some() => + { + if let (Some(ws_idx), Some(tab_idx)) = + (self.active, self.tab_at(mouse.column, mouse.row)) + { + self.switch_tab(tab_idx); + self.context_menu = Some(ContextMenuState { + kind: ContextMenuKind::Tab { ws_idx, tab_idx }, + x: mouse.column, + y: mouse.row, + list: MenuListState::new(0), + }); + self.mode = Mode::ContextMenu; + } + } + + MouseEventKind::Down(MouseButton::Right) if !in_sidebar => { + if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { + self.focus_pane(info.id); + self.context_menu = Some(ContextMenuState { + kind: ContextMenuKind::Pane, + x: mouse.column, + y: mouse.row, + list: MenuListState::new(0), + }); + self.mode = Mode::ContextMenu; + } + } + + _ => {} + } + + None + } + + pub(super) fn screen_rect(&self) -> Rect { + let sidebar = self.view.sidebar_rect; + let terminal = self.view.terminal_area; + let x = sidebar.x.min(terminal.x); + let y = sidebar.y.min(terminal.y); + let right = (sidebar.x + sidebar.width).max(terminal.x + terminal.width); + let bottom = (sidebar.y + sidebar.height).max(terminal.y + terminal.height); + Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y)) + } + + pub(crate) fn context_menu_rect(&self) -> Option<Rect> { + let menu = self.context_menu.as_ref()?; + let screen = self.screen_rect(); + let max_item_w = menu + .items() + .iter() + .map(|item| item.len() as u16) + .max() + .unwrap_or(0); + let menu_w = (max_item_w + 4).max(14).min(screen.width.max(1)); + let menu_h = (menu.items().len() as u16 + 2).min(screen.height.max(1)); + let x = menu.x.min(screen.x + screen.width.saturating_sub(menu_w)); + let y = menu.y.min(screen.y + screen.height.saturating_sub(menu_h)); + Some(Rect::new(x, y, menu_w, menu_h)) + } + + pub(crate) fn confirm_close_rect(&self) -> Rect { + crate::ui::confirm_close_popup_rect(self.view.terminal_area).unwrap_or_default() + } + + fn context_menu_item_at(&self, col: u16, row: u16) -> Option<usize> { + let menu_rect = self.context_menu_rect()?; + let inner_x = menu_rect.x + 1; + let inner_y = menu_rect.y + 1; + let inner_w = menu_rect.width.saturating_sub(2); + let inner_h = menu_rect.height.saturating_sub(2); + let item_count = self + .context_menu + .as_ref() + .map(|menu| menu.items().len() as u16) + .unwrap_or(0); + if col >= inner_x + && col < inner_x + inner_w + && row >= inner_y + && row < inner_y + inner_h.min(item_count) + { + Some((row - inner_y) as usize) + } else { + None + } + } + + pub(super) fn tab_at(&self, col: u16, row: u16) -> Option<usize> { + self.view + .tab_hit_areas + .iter() + .enumerate() + .find_map(|(idx, area)| { + (area.width > 0 + && row >= area.y + && row < area.y + area.height + && col >= area.x + && col < area.x + area.width) + .then_some(idx) + }) + } + + pub(super) fn on_tab_bar(&self, col: u16, row: u16) -> bool { + let area = self.view.tab_bar_rect; + area.width > 0 + && row >= area.y + && row < area.y + area.height + && col >= area.x + && col < area.x + area.width + } + + pub(super) fn on_tab_scroll_left_button(&self, col: u16, row: u16) -> bool { + let area = self.view.tab_scroll_left_hit_area; + area.width > 0 + && row >= area.y + && row < area.y + area.height + && col >= area.x + && col < area.x + area.width + } + + pub(super) fn on_tab_scroll_right_button(&self, col: u16, row: u16) -> bool { + let area = self.view.tab_scroll_right_hit_area; + area.width > 0 + && row >= area.y + && row < area.y + area.height + && col >= area.x + && col < area.x + area.width + } + + pub(super) fn tab_drop_index_at(&self, col: u16, row: u16) -> Option<usize> { + if !self.on_tab_bar(col, row) { + return None; + } + + let visible_tabs: Vec<_> = self + .view + .tab_hit_areas + .iter() + .enumerate() + .filter(|(_, rect)| rect.width > 0) + .collect(); + let (first_idx, first_rect) = *visible_tabs.first()?; + let (last_idx, last_rect) = *visible_tabs.last()?; + + if self.on_tab_scroll_left_button(col, row) { + return Some(0); + } + if self.on_tab_scroll_right_button(col, row) { + return self + .active + .and_then(|idx| self.workspaces.get(idx)) + .map(|ws| ws.tabs.len()); + } + + let left_edge = if first_idx == 0 { + first_rect.x + } else { + self.view.tab_scroll_left_hit_area.x + self.view.tab_scroll_left_hit_area.width + }; + let right_edge = if self + .active + .and_then(|idx| self.workspaces.get(idx)) + .is_some_and(|ws| last_idx + 1 >= ws.tabs.len()) + { + last_rect.x + last_rect.width + } else { + self.view.tab_scroll_right_hit_area.x.saturating_sub(1) + }; + + if col <= left_edge { + return Some(first_idx); + } + if col >= right_edge { + return Some(last_idx + 1); + } + + for (idx, rect) in visible_tabs { + let midpoint = rect.x + rect.width / 2; + if col < midpoint { + return Some(idx); + } + if col < rect.x + rect.width { + return Some(idx + 1); + } + } + + Some(last_idx + 1) + } + + pub(super) fn on_new_tab_button(&self, col: u16, row: u16) -> bool { + let area = self.view.new_tab_hit_area; + area.width > 0 + && row >= area.y + && row < area.y + area.height + && col >= area.x + && col < area.x + area.width + } + + pub(super) 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 + } + }) + } + + pub(super) 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 + }) + } + + pub(super) fn pane_mouse_target(&self, col: u16, row: u16) -> Option<&PaneInfo> { + self.pane_at(col, row) + .or_else(|| self.pane_frame_at(col, row)) + } + + pub(super) fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> { + self.view.pane_infos.iter().find(|info| info.id == pane_id) + } + + pub(super) fn pane_frame_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { + self.view.pane_infos.iter().find(|p| { + col >= p.rect.x + && col < p.rect.x + p.rect.width + && row >= p.rect.y + && row < p.rect.y + p.rect.height + }) + } + + pub(super) fn focus_pane(&mut self, pane_id: crate::layout::PaneId) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if ws.layout.focused() != pane_id { + ws.layout.focus_pane(pane_id); + self.mark_session_dirty(); + } + } + } + + pub(super) fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { + if let Some(rt) = ws.runtimes.get(&pane_id) { + rt.scroll_up(lines); + } + } + } + + pub(super) fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { + if let Some(rt) = ws.runtimes.get(&pane_id) { + rt.scroll_down(lines); + } + } + } + + pub(super) fn pane_scroll_metrics( + &self, + pane_id: crate::layout::PaneId, + ) -> Option<crate::pane::ScrollMetrics> { + self.active + .and_then(|i| self.workspaces.get(i)) + .and_then(|ws| ws.runtime(pane_id)) + .and_then(crate::pane::PaneRuntime::scroll_metrics) + } + + pub(super) fn handle_terminal_wheel(&mut self, mouse: MouseEvent) { + const LINES_PER_NOTCH: usize = 3; + + if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { + self.focus_pane(info.id); + if self.forward_pane_wheel(&info, mouse) { + return; + } + match mouse.kind { + MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH), + MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH), + _ => {} + } + return; + } + + if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() { + self.focus_pane(info.id); + match mouse.kind { + MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH), + MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH), + _ => {} + } + return; + } + + if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { + if let Some(rt) = ws.focused_runtime() { + match mouse.kind { + MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH), + MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH), + _ => {} + } + } + } + } + + pub(super) fn forward_pane_mouse_button(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { + let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else { + return false; + }; + let Some(rt) = ws.runtimes.get(&info.id) else { + return false; + }; + let column = mouse.column.saturating_sub(info.inner_rect.x); + let row = mouse.row.saturating_sub(info.inner_rect.y); + let Some(bytes) = rt.encode_mouse_button(mouse.kind, column, row, mouse.modifiers) else { + return false; + }; + rt.scroll_reset(); + if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { + warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse button event"); + } + true + } + + pub(super) fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { + let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else { + return false; + }; + let Some(rt) = ws.runtimes.get(&info.id) else { + return false; + }; + match rt.wheel_routing() { + Some(crate::pane::WheelRouting::HostScroll) | None => false, + Some(crate::pane::WheelRouting::MouseReport) => { + rt.scroll_reset(); + let column = mouse.column.saturating_sub(info.inner_rect.x); + let row = mouse.row.saturating_sub(info.inner_rect.y); + let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers) + else { + warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event"); + return true; + }; + if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { + warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event"); + } + true + } + Some(crate::pane::WheelRouting::AlternateScroll) => { + rt.scroll_reset(); + let Some(bytes) = rt.encode_alternate_scroll(mouse.kind) else { + return true; + }; + if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { + warn!(pane = info.id.raw(), err = %err, "failed to forward alternate-scroll key"); + } + true + } + } + } + + pub(super) fn set_pane_scroll_offset( + &self, + pane_id: crate::layout::PaneId, + offset_from_bottom: usize, + ) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { + if let Some(rt) = ws.runtimes.get(&pane_id) { + rt.set_scroll_offset_from_bottom(offset_from_bottom); + } + } + } + + pub(super) fn scrollbar_target_at( + &self, + col: u16, + row: u16, + ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> { + let ws = self.active.and_then(|i| self.workspaces.get(i))?; + let info = self.view.pane_infos.iter().find(|info| { + crate::ui::pane_scrollbar_rect(info).is_some_and(|track| { + col >= track.x + && col < track.x + track.width + && row >= track.y + && row < track.y + track.height + }) + })?; + let rt = ws.runtimes.get(&info.id)?; + let metrics = rt.scroll_metrics()?; + if metrics.max_offset_from_bottom == 0 { + return None; + } + let track = crate::ui::pane_scrollbar_rect(info)?; + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { + Some((info.id, ScrollbarClickTarget::Thumb { grab_row_offset })) + } else { + Some(( + info.id, + ScrollbarClickTarget::Track { + offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), + }, + )) + } + } + + pub(super) fn scrollbar_offset_for_pane_row( + &self, + pane_id: crate::layout::PaneId, + row: u16, + grab_row_offset: u16, + ) -> Option<usize> { + let ws = self.active.and_then(|i| self.workspaces.get(i))?; + let info = self + .view + .pane_infos + .iter() + .find(|info| info.id == pane_id)?; + let track = crate::ui::pane_scrollbar_rect(info)?; + let rt = ws.runtimes.get(&pane_id)?; + let metrics = rt.scroll_metrics()?; + if metrics.max_offset_from_bottom == 0 { + return None; + } + Some(crate::ui::scrollbar_offset_from_drag_row( + metrics, + track, + row, + grab_row_offset, + )) + } +} + +#[cfg(test)] +pub(super) fn wheel_routing(input_state: crate::pane::InputState) -> WheelRouting { + if input_state.mouse_protocol_mode.reporting_enabled() { + WheelRouting::MouseReport + } else if input_state.alternate_screen && input_state.mouse_alternate_scroll { + WheelRouting::AlternateScroll + } else { + WheelRouting::HostScroll + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind}; + use ratatui::layout::{Direction, Rect}; + + use super::super::{ + app_for_mouse_test, capture_snapshot, handle_context_menu_key, mouse, root_layout_ratio, + }; + use super::*; + use crate::{ + app::state::{ContextMenuKind, ContextMenuState, MenuListState, Mode}, + workspace::Workspace, + }; + + #[test] + fn hovering_context_menu_updates_highlight() { + let mut app = app_for_mouse_test(); + app.state.context_menu = Some(ContextMenuState { + kind: ContextMenuKind::Workspace { ws_idx: 0 }, + x: 2, + y: 2, + list: MenuListState::new(0), + }); + app.state.mode = Mode::ContextMenu; + + let menu = app.state.context_menu_rect().unwrap(); + app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2)); + + assert_eq!(app.state.context_menu.unwrap().list.highlighted, 1); + } + + #[test] + fn clicking_confirm_close_accepts_workspace_close() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; + app.state.active = Some(0); + app.state.selected = 1; + app.state.mode = Mode::ConfirmClose; + + let popup = app.state.confirm_close_rect(); + let inner = Rect::new( + popup.x + 1, + popup.y + 1, + popup.width.saturating_sub(2), + popup.height.saturating_sub(2), + ); + let (confirm, _) = crate::ui::confirm_close_button_rects(inner); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + confirm.x, + confirm.y, + )); + + assert_eq!(app.state.workspaces.len(), 1); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[test] + fn clicking_confirm_close_accepts_after_workspace_context_menu_close() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.state.context_menu = Some(ContextMenuState { + kind: ContextMenuKind::Workspace { ws_idx: 1 }, + x: 2, + y: 2, + list: MenuListState::new(1), + }); + app.state.mode = Mode::ContextMenu; + handle_context_menu_key( + &mut app.state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + assert_eq!(app.state.mode, Mode::ConfirmClose); + assert_eq!(app.state.selected, 1); + + let popup = app.state.confirm_close_rect(); + let inner = Rect::new( + popup.x + 1, + popup.y + 1, + popup.width.saturating_sub(2), + popup.height.saturating_sub(2), + ); + let (confirm, _) = crate::ui::confirm_close_button_rects(inner); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + confirm.x + 1, + confirm.y, + )); + + assert_eq!(app.state.workspaces.len(), 1); + assert_eq!(app.state.workspaces[0].display_name(), "a"); + } + + #[test] + fn dragging_pane_split_updates_captured_layout_ratio() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.workspaces[0].test_split(Direction::Horizontal); + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let border = app.state.view.split_borders[0].clone(); + let before = capture_snapshot(&app.state); + let drag_row = border.area.y.saturating_add(1); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + border.pos, + drag_row, + )); + app.handle_mouse(mouse( + MouseEventKind::Drag(MouseButton::Left), + border.pos.saturating_add(6), + drag_row, + )); + + let after = capture_snapshot(&app.state); + assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after)); + } + + #[test] + fn wheel_routing_prefers_mouse_reporting() { + let input_state = crate::pane::InputState { + alternate_screen: true, + application_cursor: false, + bracketed_paste: false, + focus_reporting: false, + mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion, + mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr, + mouse_alternate_scroll: true, + }; + + assert_eq!(wheel_routing(input_state), WheelRouting::MouseReport); + } + + #[test] + fn wheel_routing_uses_alternate_scroll_in_fullscreen_without_mouse_reporting() { + let input_state = crate::pane::InputState { + alternate_screen: true, + application_cursor: false, + bracketed_paste: false, + focus_reporting: false, + mouse_protocol_mode: crate::input::MouseProtocolMode::None, + mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, + mouse_alternate_scroll: true, + }; + + assert_eq!(wheel_routing(input_state), WheelRouting::AlternateScroll); + } + + #[test] + fn wheel_routing_falls_back_to_host_scrollback() { + let input_state = crate::pane::InputState { + alternate_screen: false, + application_cursor: false, + bracketed_paste: false, + focus_reporting: false, + mouse_protocol_mode: crate::input::MouseProtocolMode::None, + mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, + mouse_alternate_scroll: true, + }; + + assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll); + } +} diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs new file mode 100644 index 00000000..26769dfe --- /dev/null +++ b/src/app/input/navigate.rs @@ -0,0 +1,780 @@ +use std::process::{Command, Stdio}; + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::Direction; + +use crate::{ + app::{ + state::{key_matches, AppState, Mode}, + App, + }, + layout::NavDirection, +}; + +pub(crate) fn terminal_direct_navigation_action( + state: &AppState, + key: &KeyEvent, +) -> Option<NavigateAction> { + let kb = &state.keybinds; + if kb + .previous_workspace + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::PreviousWorkspace); + } + if kb + .next_workspace + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::NextWorkspace); + } + if kb + .previous_tab + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::PreviousTab); + } + if kb + .next_tab + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::NextTab); + } + if kb + .focus_pane_left + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::FocusPaneLeft); + } + if kb + .focus_pane_down + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::FocusPaneDown); + } + if kb + .focus_pane_up + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::FocusPaneUp); + } + if kb + .focus_pane_right + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::FocusPaneRight); + } + None +} + +impl App { + pub(super) fn handle_navigate_key(&mut self, key: KeyEvent) { + self.state.update_dismissed = true; + + if self.state.is_prefix(&key) || key.code == KeyCode::Esc { + leave_navigate_mode(&mut self.state); + return; + } + + if let Some(action) = navigate_action_for_key(&self.state, &key) { + execute_navigate_action(&mut self.state, action); + return; + } + + if handle_navigate_reserved_key(&mut self.state, key) { + return; + } + + if let Some(binding) = navigate_custom_command_for_key(&self.state, &key) { + self.launch_custom_command(binding); + } + } + + fn launch_custom_command(&mut self, binding: crate::config::CustomCommandKeybind) { + let previous_toast = self.state.toast.clone(); + let result = match binding.action { + crate::config::CustomCommandAction::Shell => self.spawn_custom_command(&binding), + crate::config::CustomCommandAction::Pane => self.spawn_pane_command(&binding.command), + }; + match result { + Ok(()) => leave_navigate_mode(&mut self.state), + Err(err) => { + self.state.toast = Some(crate::app::state::ToastNotification { + kind: crate::app::state::ToastKind::NeedsAttention, + title: "custom command failed".to_string(), + context: err.to_string(), + }); + self.sync_toast_deadline(previous_toast); + } + } + } + + fn custom_command_env(&self) -> (Vec<(String, String)>, Option<std::path::PathBuf>) { + let mut env = vec![( + crate::api::SOCKET_PATH_ENV_VAR.to_string(), + crate::api::socket_path().display().to_string(), + )]; + if let Ok(current_exe) = std::env::current_exe() { + env.push(( + "HERDR_BIN_PATH".to_string(), + current_exe.display().to_string(), + )); + } + + let mut cwd = None; + if let Some(ws_idx) = self.state.active { + env.push(( + "HERDR_ACTIVE_WORKSPACE_ID".to_string(), + self.public_workspace_id(ws_idx), + )); + if let Some(workspace) = self.state.workspaces.get(ws_idx) { + let tab_idx = workspace.active_tab_index(); + if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) { + env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id)); + } + if let Some(pane_id) = workspace.focused_pane_id() { + if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { + env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id)); + } + if let Some(pane_cwd) = workspace + .active_tab() + .and_then(|tab| tab.cwd_for_pane(pane_id)) + { + env.push(( + "HERDR_ACTIVE_PANE_CWD".to_string(), + pane_cwd.display().to_string(), + )); + if pane_cwd.is_dir() { + cwd = Some(pane_cwd); + } + } + } + } + } + (env, cwd) + } + + fn spawn_custom_command( + &self, + binding: &crate::config::CustomCommandKeybind, + ) -> std::io::Result<()> { + let mut command = Command::new("/bin/sh"); + command + .arg("-lc") + .arg(&binding.command) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let (env, cwd) = self.custom_command_env(); + command.envs(env); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + command.spawn()?; + Ok(()) + } + + fn spawn_pane_command(&mut self, command: &str) -> std::io::Result<()> { + let Some(ws_idx) = self.state.active else { + return Err(std::io::Error::other("no active workspace")); + }; + let (rows, cols) = self.state.estimate_pane_size(); + let new_rows = rows.max(4); + let new_cols = cols.max(10); + let (env, _) = self.custom_command_env(); + + let ws = self + .state + .workspaces + .get_mut(ws_idx) + .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; + let tab_idx = ws.active_tab_index(); + let previous_focus = ws + .focused_pane_id() + .ok_or_else(|| std::io::Error::other("no focused pane"))?; + let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); + let cwd = ws + .active_tab() + .and_then(|tab| tab.cwd_for_pane(previous_focus)); + let new_pane_id = ws.split_focused_command( + Direction::Horizontal, + new_rows, + new_cols, + cwd, + command, + &env, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + )?; + ws.active_tab_mut() + .expect("workspace must have an active tab") + .layout + .focus_pane(new_pane_id); + ws.active_tab_mut() + .expect("workspace must have an active tab") + .zoomed = true; + self.overlay_panes.insert( + new_pane_id, + super::super::OverlayPaneState { + ws_idx, + tab_idx, + previous_focus, + previous_zoomed, + }, + ); + self.state.mode = Mode::Terminal; + Ok(()) + } +} + +fn navigate_custom_command_for_key( + state: &AppState, + key: &KeyEvent, +) -> Option<crate::config::CustomCommandKeybind> { + state + .keybinds + .custom_commands + .iter() + .find(|binding| key_matches(key, binding.key.0, binding.key.1)) + .cloned() +} + +pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char('q') => { + super::modal::request_quit_or_detach(state); + leave_navigate_mode(state); + true + } + KeyCode::Enter => { + if !state.workspaces.is_empty() { + state.switch_workspace(state.selected); + leave_navigate_mode(state); + } + true + } + KeyCode::Char(c @ '1'..='9') => { + let idx = (c as usize) - ('1' as usize); + if idx < state.workspaces.len() { + state.switch_workspace(idx); + leave_navigate_mode(state); + } + true + } + KeyCode::Char('s') => { + super::settings::open_settings(state); + true + } + KeyCode::Char('?') => { + super::modal::open_keybind_help(state); + true + } + KeyCode::Up => { + if state.selected > 0 { + state.selected -= 1; + state.ensure_workspace_visible(state.selected); + } + true + } + KeyCode::Down => { + if !state.workspaces.is_empty() && state.selected < state.workspaces.len() - 1 { + state.selected += 1; + state.ensure_workspace_visible(state.selected); + } + true + } + KeyCode::Char('h') | KeyCode::Left => { + state.navigate_pane(NavDirection::Left); + true + } + KeyCode::Char('j') => { + state.navigate_pane(NavDirection::Down); + true + } + KeyCode::Char('k') => { + state.navigate_pane(NavDirection::Up); + true + } + KeyCode::Char('l') | KeyCode::Right => { + state.navigate_pane(NavDirection::Right); + true + } + KeyCode::Tab => { + state.cycle_pane(false); + true + } + KeyCode::BackTab => { + state.cycle_pane(true); + true + } + _ => false, + } +} + +#[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key +pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { + state.update_dismissed = true; + + if state.is_prefix(&key) || key.code == KeyCode::Esc { + leave_navigate_mode(state); + return; + } + + if let Some(action) = navigate_action_for_key(state, &key) { + execute_navigate_action(state, action); + return; + } + + let _ = handle_navigate_reserved_key(state, key); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NavigateAction { + NewWorkspace, + RenameWorkspace, + CloseWorkspace, + PreviousWorkspace, + NextWorkspace, + NewTab, + RenameTab, + PreviousTab, + NextTab, + CloseTab, + FocusPaneLeft, + FocusPaneDown, + FocusPaneUp, + FocusPaneRight, + SplitVertical, + SplitHorizontal, + ClosePane, + Fullscreen, + EnterResizeMode, + ToggleSidebar, + Detach, +} + +fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> { + let kb = &state.keybinds; + if key_matches(key, kb.new_workspace.0, kb.new_workspace.1) { + return Some(NavigateAction::NewWorkspace); + } + if key_matches(key, kb.rename_workspace.0, kb.rename_workspace.1) { + return Some(NavigateAction::RenameWorkspace); + } + if key_matches(key, kb.close_workspace.0, kb.close_workspace.1) { + return Some(NavigateAction::CloseWorkspace); + } + if kb + .previous_workspace + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::PreviousWorkspace); + } + if kb + .next_workspace + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::NextWorkspace); + } + if key_matches(key, kb.new_tab.0, kb.new_tab.1) { + return Some(NavigateAction::NewTab); + } + if kb + .rename_tab + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::RenameTab); + } + if kb + .previous_tab + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::PreviousTab); + } + if kb + .next_tab + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::NextTab); + } + if kb + .close_tab + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::CloseTab); + } + if key_matches(key, kb.split_vertical.0, kb.split_vertical.1) { + return Some(NavigateAction::SplitVertical); + } + if key_matches(key, kb.split_horizontal.0, kb.split_horizontal.1) { + return Some(NavigateAction::SplitHorizontal); + } + if key_matches(key, kb.close_pane.0, kb.close_pane.1) { + return Some(NavigateAction::ClosePane); + } + if key_matches(key, kb.fullscreen.0, kb.fullscreen.1) { + return Some(NavigateAction::Fullscreen); + } + if key_matches(key, kb.resize_mode.0, kb.resize_mode.1) { + return Some(NavigateAction::EnterResizeMode); + } + if key_matches(key, kb.toggle_sidebar.0, kb.toggle_sidebar.1) { + return Some(NavigateAction::ToggleSidebar); + } + if kb + .detach + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::Detach); + } + None +} + +pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { + match action { + NavigateAction::NewWorkspace => { + state.request_new_workspace = true; + leave_navigate_mode(state); + } + NavigateAction::RenameWorkspace => { + if !state.workspaces.is_empty() { + super::modal::open_rename_workspace(state, state.selected); + } + } + NavigateAction::CloseWorkspace => { + if !state.workspaces.is_empty() { + if state.confirm_close { + super::modal::open_confirm_close(state); + } else { + state.close_selected_workspace(); + leave_navigate_mode(state); + } + } + } + NavigateAction::PreviousWorkspace => { + state.previous_workspace(); + leave_navigate_mode(state); + } + NavigateAction::NextWorkspace => { + state.next_workspace(); + leave_navigate_mode(state); + } + NavigateAction::NewTab => super::modal::open_new_tab_dialog(state), + NavigateAction::RenameTab => super::modal::open_rename_active_tab(state, false), + NavigateAction::PreviousTab => { + state.previous_tab(); + leave_navigate_mode(state); + } + NavigateAction::NextTab => { + state.next_tab(); + leave_navigate_mode(state); + } + NavigateAction::CloseTab => { + state.close_tab(); + leave_navigate_mode(state); + } + NavigateAction::FocusPaneLeft => state.navigate_pane(NavDirection::Left), + NavigateAction::FocusPaneDown => state.navigate_pane(NavDirection::Down), + NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up), + NavigateAction::FocusPaneRight => state.navigate_pane(NavDirection::Right), + NavigateAction::SplitVertical => { + state.split_pane(Direction::Horizontal); + leave_navigate_mode(state); + } + NavigateAction::SplitHorizontal => { + state.split_pane(Direction::Vertical); + leave_navigate_mode(state); + } + NavigateAction::ClosePane => { + state.close_pane(); + leave_navigate_mode(state); + } + NavigateAction::Fullscreen => { + state.toggle_fullscreen(); + leave_navigate_mode(state); + } + NavigateAction::EnterResizeMode => state.mode = Mode::Resize, + NavigateAction::ToggleSidebar => { + state.sidebar_collapsed = !state.sidebar_collapsed; + leave_navigate_mode(state); + } + NavigateAction::Detach => { + state.detach_requested = true; + leave_navigate_mode(state); + } + } +} + +fn leave_navigate_mode(state: &mut AppState) { + if state.active.is_some() { + state.mode = Mode::Terminal; + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::layout::{Direction, Rect}; + + use super::super::{state_with_workspaces, unique_temp_path, wait_for_file}; + use super::*; + use crate::{app::App, config::Config, input::TerminalKey, workspace::Workspace}; + + #[test] + fn custom_rename_key_enters_rename_mode() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.rename_workspace = (KeyCode::Char('g'), KeyModifiers::empty()); + state.keybinds.rename_workspace_label = "g".into(); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::RenameWorkspace); + assert_eq!(state.name_input, "test"); + } + + #[test] + fn custom_new_workspace_key_requests_and_exits_navigate() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.new_workspace = (KeyCode::Char('g'), KeyModifiers::empty()); + state.keybinds.new_workspace_label = "g".into(); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), + ); + + assert!(state.request_new_workspace); + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] + fn custom_sidebar_toggle_key_toggles_and_exits_navigate() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.toggle_sidebar = (KeyCode::Char('g'), KeyModifiers::empty()); + state.keybinds.toggle_sidebar_label = "g".into(); + assert!(!state.sidebar_collapsed); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), + ); + + assert!(state.sidebar_collapsed); + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] + fn custom_resize_key_enters_resize_mode() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty()); + state.keybinds.resize_mode_label = "g".into(); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Resize); + } + + #[test] + fn movement_action_stays_in_navigate_mode() { + let mut state = state_with_workspaces(&["a", "b"]); + state.selected = 0; + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), + ); + + assert_eq!(state.selected, 1); + assert_eq!(state.mode, Mode::Navigate); + } + + #[test] + fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.focus_pane_left = Some((KeyCode::Left, KeyModifiers::ALT)); + state.keybinds.focus_pane_left_label = Some("alt+left".into()); + + let action = terminal_direct_navigation_action( + &state, + &KeyEvent::new(KeyCode::Left, KeyModifiers::ALT), + ); + + assert_eq!(action, Some(NavigateAction::FocusPaneLeft)); + } + + #[tokio::test] + async fn custom_command_runs_from_prefix_key_in_navigate_mode() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + let output_path = unique_temp_path("custom-command-keybind"); + let command = format!( + "printf '%s\\n%s\\n%s\\n' \"$HERDR_ACTIVE_WORKSPACE_ID\" \"$HERDR_ACTIVE_TAB_ID\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'", + output_path.display() + ); + app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { + key: (KeyCode::Char('g'), KeyModifiers::empty()), + label: "g".into(), + command, + action: crate::config::CustomCommandAction::Shell, + }]; + + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + assert_eq!(app.state.mode, Mode::Navigate); + + app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) + .await; + + let content = wait_for_file(&output_path); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], app.state.workspaces[0].id); + assert_eq!(lines[1], format!("{}:1", app.state.workspaces[0].id)); + assert_eq!(lines[2], format!("{}-1", app.state.workspaces[0].id)); + assert_eq!(app.state.mode, Mode::Terminal); + + let _ = std::fs::remove_file(output_path); + } + + #[tokio::test] + async fn pane_overlay_command_opens_and_closes_after_exit() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + None, + api_rx, + crate::api::EventHub::default(), + ); + let workspace = Workspace::new( + std::env::current_dir().unwrap_or_else(|_| "/".into()), + 24, + 80, + app.state.pane_scrollback_limit_bytes, + app.state.host_terminal_theme, + app.event_tx.clone(), + app.render_notify.clone(), + app.render_dirty.clone(), + ) + .expect("workspace should spawn"); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + let output_path = unique_temp_path("custom-pane-command"); + let command = format!("printf done > '{}'", output_path.display()); + app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { + key: (KeyCode::Char('g'), KeyModifiers::empty()), + label: "g".into(), + command, + action: crate::config::CustomCommandAction::Pane, + }]; + + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) + .await; + + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); + assert!(app.state.workspaces[0].tabs[0].zoomed); + + let _ = wait_for_file(&output_path); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + if app.drain_internal_events() + && app.state.workspaces[0].tabs[0].layout.pane_count() == 1 + { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); + assert!(!app.state.workspaces[0].tabs[0].zoomed); + assert_eq!(app.state.mode, Mode::Terminal); + let _ = std::fs::remove_file(output_path); + } + + #[test] + fn fullscreen_action_exits_navigate_mode() { + let mut state = state_with_workspaces(&["test"]); + state.workspaces[0].test_split(Direction::Horizontal); + state.keybinds.fullscreen = (KeyCode::Char('g'), KeyModifiers::empty()); + state.keybinds.fullscreen_label = "g".into(); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), + ); + + assert!(state.workspaces[0].zoomed); + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] + fn question_mark_opens_keybind_help_from_navigate() { + let mut state = state_with_workspaces(&["test"]); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT), + ); + + assert_eq!(state.mode, Mode::KeybindHelp); + } + + #[test] + fn new_tab_action_opens_dialog_without_creating_tab() { + let mut state = state_with_workspaces(&["test"]); + + execute_navigate_action(&mut state, NavigateAction::NewTab); + + assert_eq!(state.mode, Mode::RenameTab); + assert!(state.creating_new_tab); + assert_eq!(state.name_input, "2"); + assert!(state.name_input_replace_on_type); + assert!(!state.request_new_tab); + assert_eq!(state.workspaces[0].tabs.len(), 1); + } + + #[test] + fn persistence_mode_navigate_q_detaches_instead_of_quitting_server() { + let mut state = crate::app::state::AppState::test_new(); + state.quit_detaches = true; + + assert!(handle_navigate_reserved_key( + &mut state, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()) + )); + assert!(state.detach_requested); + assert!(!state.should_quit); + } +} diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs new file mode 100644 index 00000000..6475895e --- /dev/null +++ b/src/app/input/overlays.rs @@ -0,0 +1,483 @@ +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::Rect, + widgets::{Block, Borders}, +}; + +use crate::app::{ + state::{AppState, DragState, DragTarget, Mode}, + App, +}; + +use super::{ + modal::{leave_modal, modal_action_from_buttons, ModalAction}, + ScrollbarClickTarget, +}; + +impl App { + pub(super) fn handle_overlay_mouse(&mut self, mouse: MouseEvent) -> bool { + if self.state.mode == Mode::ReleaseNotes { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) + if self + .state + .release_notes_close_button_at(mouse.column, mouse.row) => + { + self.dismiss_release_notes(); + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some(target) = self + .state + .release_notes_scrollbar_target_at(mouse.column, mouse.row) + { + match target { + ScrollbarClickTarget::Thumb { grab_row_offset } => { + self.state.drag = Some(DragState { + target: DragTarget::ReleaseNotesScrollbar { grab_row_offset }, + }); + } + ScrollbarClickTarget::Track { offset_from_bottom } => { + self.state + .set_release_notes_offset_from_bottom(offset_from_bottom); + } + } + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(DragState { + target: DragTarget::ReleaseNotesScrollbar { grab_row_offset }, + }) = &self.state.drag + { + if let Some(offset_from_bottom) = self + .state + .release_notes_offset_for_drag_row(mouse.row, *grab_row_offset) + { + self.state + .set_release_notes_offset_from_bottom(offset_from_bottom); + } + } + } + MouseEventKind::Up(MouseButton::Left) => { + self.state.drag = None; + } + MouseEventKind::ScrollUp => self.scroll_release_notes(-3), + MouseEventKind::ScrollDown => self.scroll_release_notes(3), + _ => {} + } + return true; + } + + if self.state.mode == Mode::KeybindHelp { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) + if self + .state + .keybind_help_close_button_at(mouse.column, mouse.row) => + { + leave_modal(&mut self.state); + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some(target) = self + .state + .keybind_help_scrollbar_target_at(mouse.column, mouse.row) + { + match target { + ScrollbarClickTarget::Thumb { grab_row_offset } => { + self.state.drag = Some(DragState { + target: DragTarget::KeybindHelpScrollbar { grab_row_offset }, + }); + } + ScrollbarClickTarget::Track { offset_from_bottom } => { + self.state + .set_keybind_help_offset_from_bottom(offset_from_bottom); + } + } + } else { + let rect = self.state.keybind_help_popup_rect(); + let inside = mouse.column >= rect.x + && mouse.column < rect.x + rect.width + && mouse.row >= rect.y + && mouse.row < rect.y + rect.height; + if !inside { + leave_modal(&mut self.state); + } + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(DragState { + target: DragTarget::KeybindHelpScrollbar { grab_row_offset }, + }) = &self.state.drag + { + if let Some(offset_from_bottom) = self + .state + .keybind_help_offset_for_drag_row(mouse.row, *grab_row_offset) + { + self.state + .set_keybind_help_offset_from_bottom(offset_from_bottom); + } + } + } + MouseEventKind::Up(MouseButton::Left) => { + self.state.drag = None; + } + MouseEventKind::ScrollUp => self.state.scroll_keybind_help(-3), + MouseEventKind::ScrollDown => self.state.scroll_keybind_help(3), + _ => {} + } + return true; + } + + false + } +} + +impl AppState { + pub(super) fn onboarding_full_area(&self) -> Rect { + self.view.sidebar_rect.union(self.view.terminal_area) + } + + pub(super) fn onboarding_modal_inner(&self, popup_w: u16, popup_h: u16) -> Option<Rect> { + let area = self.onboarding_full_area(); + let popup_w = popup_w.min(area.width.saturating_sub(4)); + let popup_h = popup_h.min(area.height.saturating_sub(2)); + if popup_w < 4 || popup_h < 4 { + return None; + } + let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; + let popup = Rect::new(popup_x, popup_y, popup_w, popup_h); + Some(Block::default().borders(Borders::ALL).inner(popup)) + } + + fn release_notes_modal_inner(&self) -> Option<Rect> { + self.onboarding_modal_inner( + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + ) + } + + fn release_notes_close_button_at(&self, col: u16, row: u16) -> bool { + let Some(inner) = self.release_notes_modal_inner() else { + return false; + }; + if inner.height < 4 || inner.width < 12 { + return false; + } + let button = + crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); + col >= button.x + && col < button.x + button.width + && row >= button.y + && row < button.y + button.height + } + + pub(super) fn rename_modal_inner(&self) -> Option<Rect> { + self.onboarding_modal_inner(56, 7) + } + + fn release_notes_body_rect(&self) -> Option<Rect> { + let inner = self.release_notes_modal_inner()?; + if inner.height < 8 || inner.width < 4 { + return None; + } + let body = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content; + let preview = self + .release_notes + .as_ref() + .is_some_and(|notes| notes.preview); + Some(crate::ui::release_notes_sections(body, preview).notes_body) + } + + fn release_notes_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> { + let notes = self.release_notes.as_ref()?; + let body = self.release_notes_body_rect()?; + let viewport_rows = body.height.max(1) as usize; + let lines = crate::ui::release_notes_display_lines(notes, &self.palette); + + let rows_for_width = |wrap_width: usize| { + lines + .iter() + .map(|(width, _)| width.max(&1).div_ceil(wrap_width.max(1))) + .sum::<usize>() + }; + + let full_width = body.width.max(1) as usize; + let mut total_rows = rows_for_width(full_width); + let wrap_width = if total_rows > viewport_rows && full_width > 1 { + body.width.saturating_sub(1).max(1) as usize + } else { + full_width + }; + total_rows = rows_for_width(wrap_width); + + let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); + Some(crate::pane::ScrollMetrics { + offset_from_bottom: max_offset_from_bottom.saturating_sub(notes.scroll as usize), + max_offset_from_bottom, + viewport_rows, + }) + } + + pub(crate) fn release_notes_max_scroll(&self) -> u16 { + self.release_notes_scroll_metrics() + .map(|metrics| metrics.max_offset_from_bottom as u16) + .unwrap_or(0) + } + + fn release_notes_scrollbar_target_at( + &self, + col: u16, + row: u16, + ) -> Option<ScrollbarClickTarget> { + let body = self.release_notes_body_rect()?; + let metrics = self.release_notes_scroll_metrics()?; + let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; + if !(col >= track.x + && col < track.x + track.width + && row >= track.y + && row < track.y + track.height) + { + return None; + } + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { + Some(ScrollbarClickTarget::Thumb { grab_row_offset }) + } else { + Some(ScrollbarClickTarget::Track { + offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), + }) + } + } + + fn release_notes_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> { + let body = self.release_notes_body_rect()?; + let metrics = self.release_notes_scroll_metrics()?; + let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; + Some(crate::ui::scrollbar_offset_from_drag_row( + metrics, + track, + row, + grab_row_offset, + )) + } + + fn set_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) { + let max_scroll = self.release_notes_max_scroll() as usize; + if let Some(notes) = &mut self.release_notes { + notes.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; + } + } + + pub(super) fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) { + if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + return; + } + + match self.onboarding_step { + 0 => { + let Some(inner) = self.onboarding_modal_inner(64, 16) else { + return; + }; + let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1) + .actions + .unwrap_or_default(); + let button = crate::ui::onboarding_welcome_continue_rect(actions); + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && modal_action_from_buttons( + mouse.column, + mouse.row, + &[(button, ModalAction::Continue)], + ) == Some(ModalAction::Continue) + { + self.onboarding_step = 1; + } + } + _ => { + let Some(inner) = self.onboarding_modal_inner(56, 14) else { + return; + }; + let stack = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1); + if mouse.row >= stack.content.y && mouse.row < stack.content.y + 4 { + self.onboarding_list + .select((mouse.row - stack.content.y) as usize); + return; + } + + let (back, save) = crate::ui::onboarding_notification_button_rects( + stack.actions.unwrap_or_default(), + ); + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + match modal_action_from_buttons( + mouse.column, + mouse.row, + &[(back, ModalAction::Back), (save, ModalAction::Save)], + ) { + Some(ModalAction::Back) => self.onboarding_step = 0, + Some(ModalAction::Save) => self.request_complete_onboarding = true, + _ => {} + } + } + } + } + } + + pub(super) fn keybind_help_popup_rect(&self) -> Rect { + crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default() + } + + fn keybind_help_modal_inner(&self) -> Option<Rect> { + self.onboarding_modal_inner(76, 22) + } + + fn keybind_help_close_button_at(&self, col: u16, row: u16) -> bool { + let Some(inner) = self.keybind_help_modal_inner() else { + return false; + }; + if inner.height < 4 || inner.width < 12 { + return false; + } + let button = + crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); + col >= button.x + && col < button.x + button.width + && row >= button.y + && row < button.y + button.height + } + + fn keybind_help_body_rect(&self) -> Option<Rect> { + let inner = self.keybind_help_modal_inner()?; + if inner.height < 6 || inner.width < 4 { + return None; + } + Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) + } + + fn keybind_help_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> { + let body = self.keybind_help_body_rect()?; + let viewport_rows = body.height.max(1) as usize; + let wrap_width = body.width.max(1) as usize; + let total_rows = crate::ui::keybind_help_lines(self) + .into_iter() + .map(|(width, _)| width.max(1).div_ceil(wrap_width)) + .sum::<usize>(); + let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); + Some(crate::pane::ScrollMetrics { + offset_from_bottom: max_offset_from_bottom + .saturating_sub(self.keybind_help.scroll as usize), + max_offset_from_bottom, + viewport_rows, + }) + } + + fn keybind_help_scrollbar_target_at(&self, col: u16, row: u16) -> Option<ScrollbarClickTarget> { + let body = self.keybind_help_body_rect()?; + let metrics = self.keybind_help_scroll_metrics()?; + let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; + if !(col >= track.x + && col < track.x + track.width + && row >= track.y + && row < track.y + track.height) + { + return None; + } + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { + Some(ScrollbarClickTarget::Thumb { grab_row_offset }) + } else { + Some(ScrollbarClickTarget::Track { + offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), + }) + } + } + + fn keybind_help_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> { + let body = self.keybind_help_body_rect()?; + let metrics = self.keybind_help_scroll_metrics()?; + let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; + Some(crate::ui::scrollbar_offset_from_drag_row( + metrics, + track, + row, + grab_row_offset, + )) + } + + pub(crate) fn keybind_help_max_scroll(&self) -> u16 { + self.keybind_help_scroll_metrics() + .map(|metrics| metrics.max_offset_from_bottom as u16) + .unwrap_or(0) + } + + fn set_keybind_help_offset_from_bottom(&mut self, offset_from_bottom: usize) { + let max_scroll = self.keybind_help_max_scroll() as usize; + self.keybind_help.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; + } + + pub(super) fn scroll_keybind_help(&mut self, delta: i16) { + let max_scroll = self.keybind_help_max_scroll(); + let current = self.keybind_help.scroll as i16; + self.keybind_help.scroll = current.saturating_add(delta).clamp(0, max_scroll as i16) as u16; + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{MouseButton, MouseEventKind}; + use ratatui::layout::Rect; + + use super::super::{app_for_mouse_test, mouse}; + use super::*; + + #[test] + fn clicking_keybind_help_close_button_closes_overlay() { + let mut app = app_for_mouse_test(); + app.state.mode = Mode::KeybindHelp; + + let rect = app.state.keybind_help_popup_rect(); + let inner = Rect::new( + rect.x + 1, + rect.y + 1, + rect.width.saturating_sub(2), + rect.height.saturating_sub(2), + ); + let close = + crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + close.x, + close.y, + )); + + assert_eq!(app.state.mode, Mode::Navigate); + } + + #[test] + fn onboarding_hover_does_not_change_selection() { + let mut app = app_for_mouse_test(); + app.state.mode = Mode::Onboarding; + app.state.onboarding_step = 1; + app.state.onboarding_list.select(1); + + let inner = app.state.onboarding_modal_inner(56, 14).unwrap(); + let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content; + app.handle_mouse(mouse(MouseEventKind::Moved, content.x + 2, content.y)); + + assert_eq!(app.state.onboarding_list.selected, 1); + } + + #[test] + fn onboarding_click_selects_notification_option() { + let mut app = app_for_mouse_test(); + app.state.mode = Mode::Onboarding; + app.state.onboarding_step = 1; + app.state.onboarding_list.select(0); + + let inner = app.state.onboarding_modal_inner(56, 14).unwrap(); + let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + content.x + 2, + content.y + 2, + )); + + assert_eq!(app.state.onboarding_list.selected, 2); + } +} diff --git a/src/app/input/selection.rs b/src/app/input/selection.rs new file mode 100644 index 00000000..953e9b26 --- /dev/null +++ b/src/app/input/selection.rs @@ -0,0 +1,68 @@ +use crossterm::event::{MouseEvent, MouseEventKind}; + +use crate::app::state::AppState; + +impl AppState { + fn update_selection_cursor( + &mut self, + pane_id: crate::layout::PaneId, + screen_col: u16, + screen_row: u16, + ) { + let Some(info) = self.pane_info_by_id(pane_id).cloned() else { + return; + }; + let metrics = self.pane_scroll_metrics(pane_id); + if let Some(selection) = self.selection.as_mut() { + selection.drag(screen_col, screen_row, info.inner_rect, metrics); + } + } + + fn selection_edge_scroll_lines(distance: u16) -> usize { + usize::from(distance).saturating_mul(3).clamp(3, 15) + } + + pub(super) fn update_selection_drag(&mut self, screen_col: u16, screen_row: u16) { + let Some(pane_id) = self.selection.as_ref().map(|selection| selection.pane_id) else { + return; + }; + let Some(info) = self.pane_info_by_id(pane_id).cloned() else { + return; + }; + + let bottom = info.inner_rect.y + info.inner_rect.height.saturating_sub(1); + if screen_row < info.inner_rect.y { + self.scroll_pane_up( + pane_id, + Self::selection_edge_scroll_lines(info.inner_rect.y - screen_row), + ); + } else if screen_row > bottom { + self.scroll_pane_down( + pane_id, + Self::selection_edge_scroll_lines(screen_row - bottom), + ); + } + + self.update_selection_cursor(pane_id, screen_col, screen_row); + } + + pub(super) fn scroll_selection_with_wheel(&mut self, mouse: MouseEvent) -> bool { + const LINES_PER_NOTCH: usize = 3; + + let Some(selection) = self.selection.as_ref() else { + return false; + }; + if !selection.is_in_progress() { + return false; + } + let pane_id = selection.pane_id; + self.focus_pane(pane_id); + match mouse.kind { + MouseEventKind::ScrollUp => self.scroll_pane_up(pane_id, LINES_PER_NOTCH), + MouseEventKind::ScrollDown => self.scroll_pane_down(pane_id, LINES_PER_NOTCH), + _ => return false, + } + self.update_selection_cursor(pane_id, mouse.column, mouse.row); + true + } +} diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs new file mode 100644 index 00000000..9433b7d8 --- /dev/null +++ b/src/app/input/settings.rs @@ -0,0 +1,352 @@ +use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::Rect; + +use crate::app::{ + state::{AppState, SettingsSection, THEME_NAMES}, + App, Mode, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum SettingsAction { + SaveTheme(String), + SaveSound(bool), + SaveToast(bool), +} + +impl App { + pub(crate) fn handle_settings_key(&mut self, key: KeyEvent) { + if let Some(action) = update_settings_state(&mut self.state, key) { + match action { + SettingsAction::SaveTheme(name) => self.save_theme(&name), + SettingsAction::SaveSound(enabled) => self.save_sound(enabled), + SettingsAction::SaveToast(enabled) => self.save_toast(enabled), + } + } + } +} + +fn normalize_theme_name(name: &str) -> String { + name.to_lowercase().replace([' ', '_'], "-") +} + +fn current_theme_index(theme_name: &str) -> usize { + let normalized = normalize_theme_name(theme_name); + THEME_NAMES + .iter() + .position(|name| normalize_theme_name(name) == normalized) + .unwrap_or(0) +} + +fn preview_selected_theme(state: &mut AppState) { + use crate::app::state::Palette; + + let name = THEME_NAMES[state.settings.list.selected]; + if let Some(palette) = Palette::from_name(name) { + state.palette = palette; + state.theme_name = name.to_string(); + } +} + +fn cancel_settings(state: &mut AppState) { + if let Some(palette) = state.settings.original_palette.take() { + state.palette = palette; + } + if let Some(theme_name) = state.settings.original_theme.take() { + state.theme_name = theme_name; + } + super::modal::leave_modal(state); +} + +fn apply_settings(state: &mut AppState) -> Option<SettingsAction> { + match state.settings.section { + SettingsSection::Theme => { + let theme_name = state.theme_name.clone(); + state.settings.original_palette = None; + state.settings.original_theme = None; + super::modal::leave_modal(state); + Some(SettingsAction::SaveTheme(theme_name)) + } + _ => { + super::modal::leave_modal(state); + None + } + } +} + +pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<SettingsAction> { + match state.settings.section { + SettingsSection::Theme => match key.code { + KeyCode::Up | KeyCode::Char('k') => { + let previous = state.settings.list.selected; + state.settings.list.move_prev(); + if state.settings.list.selected != previous { + preview_selected_theme(state); + } + } + KeyCode::Down | KeyCode::Char('j') => { + let previous = state.settings.list.selected; + state.settings.list.move_next(THEME_NAMES.len()); + if state.settings.list.selected != previous { + preview_selected_theme(state); + } + } + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { + state.settings.section = SettingsSection::Sound; + state.settings.list.selected = usize::from(!state.sound_enabled()); + } + _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { + Some(super::modal::ModalAction::Apply) => return apply_settings(state), + Some(super::modal::ModalAction::Close) => cancel_settings(state), + _ => {} + }, + }, + SettingsSection::Sound => match key.code { + KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { + state.settings.list.selected = 1 - state.settings.list.selected.min(1); + } + KeyCode::Enter | KeyCode::Char(' ') => { + let enabled = state.settings.list.selected == 0; + state.sound.enabled = enabled; + return Some(SettingsAction::SaveSound(enabled)); + } + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { + state.settings.section = SettingsSection::Toast; + state.settings.list.selected = usize::from(!state.toast_config.enabled); + } + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { + state.settings.section = SettingsSection::Theme; + state.settings.list.selected = current_theme_index(&state.theme_name); + } + _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { + Some(super::modal::ModalAction::Close) => cancel_settings(state), + _ => {} + }, + }, + SettingsSection::Toast => match key.code { + KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { + state.settings.list.selected = 1 - state.settings.list.selected.min(1); + } + KeyCode::Enter | KeyCode::Char(' ') => { + let enabled = state.settings.list.selected == 0; + state.toast_config.enabled = enabled; + return Some(SettingsAction::SaveToast(enabled)); + } + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { + state.settings.section = SettingsSection::Sound; + state.settings.list.selected = usize::from(!state.sound_enabled()); + } + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { + state.settings.section = SettingsSection::Theme; + state.settings.list.selected = current_theme_index(&state.theme_name); + } + _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { + Some(super::modal::ModalAction::Close) => cancel_settings(state), + _ => {} + }, + }, + } + + None +} + +pub(super) fn open_settings(state: &mut AppState) { + state.settings.original_palette = Some(state.palette.clone()); + state.settings.original_theme = Some(state.theme_name.clone()); + state.settings.section = SettingsSection::Theme; + state.settings.list.selected = current_theme_index(&state.theme_name); + state.mode = Mode::Settings; +} + +impl AppState { + fn settings_popup_rect(&self) -> Rect { + crate::ui::centered_popup_rect(self.screen_rect(), 56, 20).unwrap_or_default() + } + + fn settings_inner_rect(&self) -> Rect { + let popup = self.settings_popup_rect(); + Rect::new( + popup.x + 1, + popup.y + 1, + popup.width.saturating_sub(2), + popup.height.saturating_sub(2), + ) + } + + fn settings_tab_at(&self, col: u16, row: u16) -> Option<SettingsSection> { + let inner = self.settings_inner_rect(); + let tab_y = inner.y + 1; + if row != tab_y { + return None; + } + let mut x = inner.x; + for section in SettingsSection::ALL { + let width = section.label().len() as u16 + 2; + if col >= x && col < x + width { + return Some(*section); + } + x += width + 1; + } + None + } + + pub(crate) fn settings_content_rect(&self) -> Rect { + let inner = self.settings_inner_rect(); + crate::ui::modal_stack_areas(inner, 3, 2, 0, 1).content + } + + fn settings_list_index_at(&self, col: u16, row: u16) -> Option<usize> { + let area = self.settings_content_rect(); + if row < area.y || row >= area.y + area.height || col < area.x || col >= area.x + area.width + { + return None; + } + + match self.settings.section { + SettingsSection::Theme => { + let max_visible = area.height as usize; + let scroll = if self.settings.list.selected >= max_visible { + self.settings.list.selected - max_visible + 1 + } else { + 0 + }; + let idx = scroll + (row - area.y) as usize; + (idx < THEME_NAMES.len()).then_some(idx) + } + SettingsSection::Sound | SettingsSection::Toast => { + let list_y = area.y + 2; + if row >= list_y && row < list_y + 2 { + Some((row - list_y) as usize) + } else { + None + } + } + } + } + + pub(super) fn handle_settings_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + if let Some(section) = self.settings_tab_at(mouse.column, mouse.row) { + self.settings.section = section; + self.settings.list.select(match section { + SettingsSection::Theme => current_theme_index(&self.theme_name), + SettingsSection::Sound => usize::from(!self.sound_enabled()), + SettingsSection::Toast => usize::from(!self.toast_config.enabled), + }); + return None; + } + if let Some(idx) = self.settings_list_index_at(mouse.column, mouse.row) { + self.settings.list.select(idx); + return match self.settings.section { + SettingsSection::Theme => { + preview_selected_theme(self); + None + } + SettingsSection::Sound => { + let enabled = idx == 0; + self.sound.enabled = enabled; + Some(SettingsAction::SaveSound(enabled)) + } + SettingsSection::Toast => { + let enabled = idx == 0; + self.toast_config.enabled = enabled; + Some(SettingsAction::SaveToast(enabled)) + } + }; + } + + let inner = self.settings_inner_rect(); + let (apply, close) = crate::ui::settings_button_rects(inner); + match super::modal::modal_action_from_buttons( + mouse.column, + mouse.row, + &[ + (apply, super::modal::ModalAction::Apply), + (close, super::modal::ModalAction::Close), + ], + ) { + Some(super::modal::ModalAction::Apply) => apply_settings(self), + Some(super::modal::ModalAction::Close) => { + cancel_settings(self); + None + } + _ => { + cancel_settings(self); + None + } + } + } + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind}; + + use super::super::{app_for_mouse_test, mouse, state_with_workspaces}; + use super::*; + + #[test] + fn settings_cancel_restores_previewed_theme_from_other_sections() { + let mut state = state_with_workspaces(&["test"]); + let original_palette = state.palette.clone(); + let original_theme = state.theme_name.clone(); + + open_settings(&mut state); + update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), + ); + assert_ne!(state.theme_name, original_theme); + + update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), + ); + assert_eq!( + state.settings.section, + crate::app::state::SettingsSection::Sound + ); + + update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert_eq!(state.theme_name, original_theme); + assert_eq!(state.palette.accent, original_palette.accent); + assert_eq!(state.palette.panel_bg, original_palette.panel_bg); + } + + #[test] + fn settings_sound_toggle_returns_save_action() { + let mut state = state_with_workspaces(&["test"]); + open_settings(&mut state); + state.settings.section = crate::app::state::SettingsSection::Sound; + state.settings.list.selected = 0; + + let action = update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(action, Some(SettingsAction::SaveSound(true))); + assert!(state.sound.enabled); + assert_eq!(state.mode, Mode::Settings); + } + + #[test] + fn settings_hover_does_not_change_selection() { + let mut app = app_for_mouse_test(); + open_settings(&mut app.state); + app.state.settings.list.select(0); + + let area = app.state.settings_content_rect(); + app.handle_mouse(mouse(MouseEventKind::Moved, area.x + 2, area.y + 2)); + + assert_eq!(app.state.settings.list.selected, 0); + } +} diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs new file mode 100644 index 00000000..34845861 --- /dev/null +++ b/src/app/input/sidebar.rs @@ -0,0 +1,1219 @@ +use ratatui::layout::Rect; + +use crate::app::state::{AppState, Mode}; + +use super::ScrollbarClickTarget; + +impl AppState { + pub(super) fn workspace_list_rect(&self) -> Rect { + let sidebar = self.view.sidebar_rect; + if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 { + return Rect::default(); + } + crate::ui::workspace_list_rect(sidebar, self.sidebar_section_split) + } + + pub(super) fn agent_panel_rect(&self) -> Rect { + let sidebar = self.view.sidebar_rect; + if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 { + return Rect::default(); + } + let (_, detail_area) = + crate::ui::expanded_sidebar_sections(sidebar, self.sidebar_section_split); + detail_area + } + + pub(super) fn workspace_list_scrollbar_target_at( + &self, + col: u16, + row: u16, + ) -> Option<ScrollbarClickTarget> { + let area = self.workspace_list_rect(); + let metrics = crate::ui::workspace_list_scroll_metrics(self, area); + let track = crate::ui::workspace_list_scrollbar_rect(self, area)?; + if col < track.x + || col >= track.x + track.width + || row < track.y + || row >= track.y + track.height + { + return None; + } + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { + Some(ScrollbarClickTarget::Thumb { grab_row_offset }) + } else { + Some(ScrollbarClickTarget::Track { + offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), + }) + } + } + + pub(super) fn workspace_list_offset_for_drag_row( + &self, + row: u16, + grab_row_offset: u16, + ) -> Option<usize> { + let area = self.workspace_list_rect(); + let metrics = crate::ui::workspace_list_scroll_metrics(self, area); + let track = crate::ui::workspace_list_scrollbar_rect(self, area)?; + Some(crate::ui::scrollbar_offset_from_drag_row( + metrics, + track, + row, + grab_row_offset, + )) + } + + pub(super) fn set_workspace_list_offset_from_bottom(&mut self, offset_from_bottom: usize) { + let area = self.workspace_list_rect(); + let metrics = crate::ui::workspace_list_scroll_metrics(self, area); + self.workspace_scroll = metrics + .max_offset_from_bottom + .saturating_sub(offset_from_bottom); + } + + pub(super) fn scroll_workspace_list(&mut self, delta: i16) { + if delta.is_negative() { + self.workspace_scroll = self + .workspace_scroll + .saturating_sub(delta.unsigned_abs() as usize); + return; + } + + for _ in 0..delta as usize { + let cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect); + let Some(last) = cards.last() else { + break; + }; + if last.ws_idx + 1 >= self.workspaces.len() { + break; + } + self.workspace_scroll = self.workspace_scroll.saturating_add(1); + } + } + + pub(super) fn agent_panel_scrollbar_target_at( + &self, + col: u16, + row: u16, + ) -> Option<ScrollbarClickTarget> { + let area = self.agent_panel_rect(); + let metrics = crate::ui::agent_panel_scroll_metrics(self, area); + let track = crate::ui::agent_panel_scrollbar_rect(self, area)?; + if col < track.x + || col >= track.x + track.width + || row < track.y + || row >= track.y + track.height + { + return None; + } + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { + Some(ScrollbarClickTarget::Thumb { grab_row_offset }) + } else { + Some(ScrollbarClickTarget::Track { + offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), + }) + } + } + + pub(super) fn agent_panel_offset_for_drag_row( + &self, + row: u16, + grab_row_offset: u16, + ) -> Option<usize> { + let area = self.agent_panel_rect(); + let metrics = crate::ui::agent_panel_scroll_metrics(self, area); + let track = crate::ui::agent_panel_scrollbar_rect(self, area)?; + Some(crate::ui::scrollbar_offset_from_drag_row( + metrics, + track, + row, + grab_row_offset, + )) + } + + pub(super) fn set_agent_panel_offset_from_bottom(&mut self, offset_from_bottom: usize) { + let area = self.agent_panel_rect(); + let metrics = crate::ui::agent_panel_scroll_metrics(self, area); + self.agent_panel_scroll = metrics + .max_offset_from_bottom + .saturating_sub(offset_from_bottom); + } + + pub(super) fn scroll_agent_panel(&mut self, delta: i16) { + let area = self.agent_panel_rect(); + let max_scroll = crate::ui::agent_panel_scroll_metrics(self, area).max_offset_from_bottom; + if delta.is_negative() { + self.agent_panel_scroll = self + .agent_panel_scroll + .saturating_sub(delta.unsigned_abs() as usize); + } else { + self.agent_panel_scroll = self + .agent_panel_scroll + .saturating_add(delta as usize) + .min(max_scroll); + } + } + + pub(crate) fn sidebar_footer_rect(&self) -> Rect { + let ws_area = self.workspace_list_rect(); + if ws_area == Rect::default() { + return Rect::default(); + } + let y = ws_area.y + ws_area.height.saturating_sub(1); + Rect::new(ws_area.x, y, ws_area.width, 1) + } + + pub(crate) fn sidebar_new_button_rect(&self) -> Rect { + let footer = self.sidebar_footer_rect(); + let width = 5u16.min(footer.width.max(1)); + Rect::new(footer.x, footer.y, width, footer.height) + } + + pub(crate) fn global_launcher_rect(&self) -> Rect { + let footer = self.sidebar_footer_rect(); + let width = if self.update_available.is_some() { + 8 + } else { + 6 + } + .min(footer.width.max(1)); + let x = footer.x + footer.width.saturating_sub(width); + Rect::new(x, footer.y, width, footer.height) + } + + pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> { + let mut labels = vec!["settings", "keybinds", "reload keybinds"]; + if self.update_available.is_some() { + labels.push("update ready"); + } else if self.latest_release_notes_available { + labels.push("what's new"); + } + labels.push(if self.quit_detaches { "detach" } else { "quit" }); + labels + } + + pub(crate) fn global_menu_rect(&self) -> Rect { + let screen = self.screen_rect(); + let launcher = self.global_launcher_rect(); + let labels = self.global_menu_labels(); + let content_width = labels + .iter() + .map(|label| { + let extra = if *label == "update ready" { 2 } else { 0 }; + label.chars().count() as u16 + extra + }) + .max() + .unwrap_or(8) + .saturating_add(2); + let menu_w = content_width.saturating_add(2).min(screen.width.max(1)); + let menu_h = (labels.len() as u16 + 2).min(screen.height.max(1)); + let max_x = screen.x + screen.width.saturating_sub(menu_w); + let desired_x = launcher.x + launcher.width.saturating_sub(menu_w); + let x = desired_x.min(max_x); + let y = launcher.y.saturating_sub(menu_h); + Rect::new(x, y, menu_w, menu_h) + } + + pub(super) fn on_sidebar_divider(&self, col: u16, row: u16) -> bool { + if self.sidebar_collapsed { + return false; + } + let sidebar = self.view.sidebar_rect; + sidebar.width > 0 + && col == sidebar.x + sidebar.width.saturating_sub(1) + && row >= sidebar.y + && row < sidebar.y + sidebar.height + } + + pub(super) fn on_collapsed_sidebar_toggle(&self, col: u16, row: u16) -> bool { + if !self.sidebar_collapsed { + return false; + } + let rect = crate::ui::collapsed_sidebar_toggle_rect(self.view.sidebar_rect); + rect.width > 0 + && col >= rect.x + && col < rect.x + rect.width + && row >= rect.y + && row < rect.y + rect.height + } + + pub(super) fn set_manual_sidebar_width(&mut self, divider_col: u16) { + let sidebar = self.view.sidebar_rect; + let width = divider_col.saturating_sub(sidebar.x).saturating_add(1); + self.sidebar_width = + width.clamp(crate::ui::MIN_SIDEBAR_WIDTH, crate::ui::MAX_SIDEBAR_WIDTH); + self.mark_session_dirty(); + } + + pub(super) fn on_sidebar_section_divider(&self, col: u16, row: u16) -> bool { + if self.sidebar_collapsed { + return false; + } + let rect = crate::ui::sidebar_section_divider_rect( + self.view.sidebar_rect, + self.sidebar_section_split, + ); + rect.width > 0 + && col >= rect.x + && col < rect.x + rect.width + && row >= rect.y + && row < rect.y + rect.height + } + + pub(super) fn set_sidebar_section_split(&mut self, row: u16) { + let sidebar = self.view.sidebar_rect; + let content_height = sidebar.height; + if content_height < 6 { + return; + } + let relative_y = row.saturating_sub(sidebar.y); + let ratio = (relative_y as f32) / (content_height as f32); + self.sidebar_section_split = ratio.clamp(0.1, 0.9); + self.mark_session_dirty(); + } + + pub(super) fn workspace_at_row(&self, row: u16) -> Option<usize> { + let footer = self.sidebar_footer_rect(); + if footer == Rect::default() { + return None; + } + + let cards = if self.view.workspace_card_areas.is_empty() { + crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) + } else { + self.view.workspace_card_areas.clone() + }; + + cards.iter().find_map(|card| { + (row >= card.rect.y && row < card.rect.y + card.rect.height).then_some(card.ws_idx) + }) + } + + pub(super) fn collapsed_workspace_at_row(&self, row: u16) -> Option<usize> { + if !self.sidebar_collapsed { + return None; + } + + let (ws_area, _, _) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect); + if ws_area == Rect::default() || row < ws_area.y || row >= ws_area.y + ws_area.height { + return None; + } + + let idx = (row - ws_area.y) as usize; + (idx < self.workspaces.len()).then_some(idx) + } + + fn collapsed_detail_workspace_idx(&self) -> Option<usize> { + if matches!( + self.mode, + Mode::Navigate + | Mode::RenameWorkspace + | Mode::Resize + | Mode::ConfirmClose + | Mode::ContextMenu + | Mode::Settings + | Mode::GlobalMenu + | Mode::KeybindHelp + ) { + Some(self.selected) + } else { + self.active + } + } + + pub(super) fn collapsed_agent_detail_target_at( + &self, + row: u16, + ) -> Option<(usize, usize, crate::layout::PaneId)> { + if !self.sidebar_collapsed { + return None; + } + + let (_, _, detail_area) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect); + let detail_content_area = Rect::new( + detail_area.x, + detail_area.y, + detail_area.width, + detail_area.height.saturating_sub(1), + ); + if detail_content_area == Rect::default() + || row < detail_content_area.y + || row >= detail_content_area.y + detail_content_area.height + { + return None; + } + + let ws_idx = self.collapsed_detail_workspace_idx()?; + let ws = self.workspaces.get(ws_idx)?; + let detail_idx = (row - detail_content_area.y) as usize; + let details = ws.pane_details(); + let detail = details.get(detail_idx)?; + Some((ws_idx, detail.tab_idx, detail.pane_id)) + } + + pub(super) fn workspace_drop_index_at_row(&self, row: u16) -> Option<usize> { + let area = self.workspace_list_rect(); + let footer = self.sidebar_footer_rect(); + if area == Rect::default() || row < area.y || row >= footer.y { + return None; + } + + let cards = if self.view.workspace_card_areas.is_empty() { + crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) + } else { + self.view.workspace_card_areas.clone() + }; + if cards.is_empty() { + return Some(0); + } + + let mut insert_indices = Vec::with_capacity(cards.len() + 1); + insert_indices.push(cards[0].ws_idx); + insert_indices.extend(cards.iter().skip(1).map(|card| card.ws_idx)); + insert_indices.push(cards.last().map(|card| card.ws_idx + 1).unwrap_or(0)); + + let mut best: Option<(usize, u16)> = None; + for insert_idx in insert_indices { + let Some(slot_row) = crate::ui::workspace_drop_indicator_row(&cards, area, insert_idx) + else { + continue; + }; + let distance = row.abs_diff(slot_row); + match best { + Some((best_idx, best_distance)) + if distance > best_distance + || (distance == best_distance && insert_idx < best_idx) => {} + _ => best = Some((insert_idx, distance)), + } + } + + best.map(|(insert_idx, _)| insert_idx) + } + + pub(super) fn on_agent_panel_scope_toggle(&self, col: u16, row: u16) -> bool { + if self.sidebar_collapsed { + return false; + } + + let (_, detail_area) = crate::ui::expanded_sidebar_sections( + self.view.sidebar_rect, + self.sidebar_section_split, + ); + let rect = crate::ui::agent_panel_toggle_rect(detail_area, self.agent_panel_scope); + rect.width > 0 + && col >= rect.x + && col < rect.x + rect.width + && row >= rect.y + && row < rect.y + rect.height + } + + pub(super) fn agent_detail_target_at( + &self, + row: u16, + ) -> Option<(usize, usize, crate::layout::PaneId)> { + if self.sidebar_collapsed { + return None; + } + + let detail_area = self.agent_panel_rect(); + let metrics = crate::ui::agent_panel_scroll_metrics(self, detail_area); + let body = crate::ui::agent_panel_body_rect( + detail_area, + crate::ui::should_show_scrollbar(metrics), + ); + if body.height < 2 || row < body.y || row >= body.y + body.height { + return None; + } + + let mut row_y = body.y; + for detail in crate::ui::agent_panel_entries(self) + .into_iter() + .skip(self.agent_panel_scroll) + { + if row_y.saturating_add(1) >= body.y + body.height { + break; + } + if row == row_y || row == row_y + 1 { + return Some((detail.ws_idx, detail.tab_idx, detail.pane_id)); + } + row_y = row_y.saturating_add(2); + if row_y < body.y + body.height { + row_y = row_y.saturating_add(1); + } + } + None + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use crossterm::event::{MouseButton, MouseEventKind}; + use ratatui::layout::Rect; + + use super::super::{app_for_mouse_test, capture_snapshot, mouse, unique_temp_path}; + use crate::{ + app::state::{AgentPanelScope, DragTarget, Mode}, + detect::Agent, + workspace::Workspace, + }; + + #[test] + fn clicking_launcher_opens_global_menu() { + let mut app = app_for_mouse_test(); + let rect = app.state.global_launcher_rect(); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + rect.x + rect.width.saturating_sub(1), + rect.y, + )); + + assert_eq!(app.state.mode, Mode::GlobalMenu); + } + + #[test] + fn hovering_global_menu_updates_highlight() { + let mut app = app_for_mouse_test(); + let launcher = app.state.global_launcher_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + launcher.x, + launcher.y, + )); + + let menu = app.state.global_menu_rect(); + app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2)); + + assert_eq!(app.state.global_menu.highlighted, 1); + } + + #[test] + fn clicking_keybinds_menu_item_opens_help() { + let mut app = app_for_mouse_test(); + let launcher = app.state.global_launcher_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + launcher.x, + launcher.y, + )); + + let menu = app.state.global_menu_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + menu.x + 2, + menu.y + 2, + )); + + assert_eq!(app.state.mode, Mode::KeybindHelp); + } + + #[test] + fn clicking_settings_menu_item_opens_settings() { + let mut app = app_for_mouse_test(); + let launcher = app.state.global_launcher_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + launcher.x, + launcher.y, + )); + + let menu = app.state.global_menu_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + menu.x + 2, + menu.y + 1, + )); + + assert_eq!(app.state.mode, Mode::Settings); + } + + #[test] + fn clicking_reload_keybinds_menu_item_requests_reload() { + let mut app = app_for_mouse_test(); + let launcher = app.state.global_launcher_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + launcher.x, + launcher.y, + )); + + let menu = app.state.global_menu_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + menu.x + 2, + menu.y + 3, + )); + + assert!(app.state.request_reload_keybinds); + assert_eq!(app.state.mode, Mode::Navigate); + } + + #[test] + fn update_pending_menu_surfaces_update_ready_entry() { + let mut app = app_for_mouse_test(); + app.state.update_available = Some("0.3.2".into()); + app.state.latest_release_notes_available = true; + + let launcher = app.state.global_launcher_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + launcher.x, + launcher.y, + )); + + assert_eq!( + app.state.global_menu_labels(), + vec![ + "settings", + "keybinds", + "reload keybinds", + "update ready", + "quit" + ] + ); + assert!(!app.state.should_quit); + } + + #[test] + fn persistence_mode_menu_surfaces_detach_action() { + let mut app = app_for_mouse_test(); + app.state.quit_detaches = true; + + let launcher = app.state.global_launcher_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + launcher.x, + launcher.y, + )); + + assert_eq!( + app.state.global_menu_labels(), + vec!["settings", "keybinds", "reload keybinds", "detach"] + ); + + let menu = app.state.global_menu_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + menu.x + 2, + menu.y + 4, + )); + + assert!(app.state.detach_requested); + assert!(!app.state.should_quit); + } + + #[test] + fn whats_new_remains_in_menu_for_latest_installed_release_notes() { + let mut app = app_for_mouse_test(); + app.state.latest_release_notes_available = true; + + assert_eq!( + app.state.global_menu_labels(), + vec![ + "settings", + "keybinds", + "reload keybinds", + "what's new", + "quit" + ] + ); + } + + #[test] + fn clicking_agent_detail_row_switches_to_correct_tab_and_pane() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + ws.tabs[0].set_custom_name("main".into()); + let first_pane = ws.tabs[0].root_pane; + ws.tabs[0] + .panes + .get_mut(&first_pane) + .unwrap() + .detected_agent = Some(Agent::Pi); + let second_tab = ws.test_add_tab(Some("logs")); + let second_pane = ws.tabs[second_tab].root_pane; + ws.tabs[second_tab] + .panes + .get_mut(&second_pane) + .unwrap() + .detected_agent = Some(Agent::Claude); + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 16)); + + assert_eq!(app.state.workspaces[0].active_tab, 1); + assert_eq!( + app.state.workspaces[0].tabs[1].layout.focused(), + second_pane + ); + assert_eq!(app.state.mode, Mode::Terminal); + let snapshot = capture_snapshot(&app.state); + assert_eq!(snapshot.workspaces[0].active_tab, second_tab); + assert_eq!( + snapshot.workspaces[0].tabs[second_tab].focused, + Some(second_pane.raw()) + ); + } + + #[test] + fn clicking_agent_panel_toggle_switches_scope() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.agent_panel_scroll = 3; + + let (_, detail_area) = crate::ui::expanded_sidebar_sections( + app.state.view.sidebar_rect, + app.state.sidebar_section_split, + ); + let toggle = crate::ui::agent_panel_toggle_rect(detail_area, app.state.agent_panel_scope); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + toggle.x, + toggle.y, + )); + + assert_eq!(app.state.agent_panel_scope, AgentPanelScope::AllWorkspaces); + assert_eq!(app.state.agent_panel_scroll, 0); + let snapshot = capture_snapshot(&app.state); + assert_eq!(snapshot.agent_panel_scope, AgentPanelScope::AllWorkspaces); + } + + #[test] + fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() { + let mut app = app_for_mouse_test(); + let mut first = Workspace::test_new("one"); + let first_pane = first.tabs[0].root_pane; + first.tabs[0] + .panes + .get_mut(&first_pane) + .unwrap() + .detected_agent = Some(Agent::Pi); + + let mut second = Workspace::test_new("two"); + let second_pane = second.tabs[0].root_pane; + second.tabs[0] + .panes + .get_mut(&second_pane) + .unwrap() + .detected_agent = Some(Agent::Claude); + + app.state.workspaces = vec![first, second]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.agent_panel_scope = AgentPanelScope::AllWorkspaces; + + let (_, detail_area) = crate::ui::expanded_sidebar_sections( + app.state.view.sidebar_rect, + app.state.sidebar_section_split, + ); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + detail_area.x + 2, + detail_area.y + 6, + )); + + assert_eq!(app.state.active, Some(1)); + assert_eq!(app.state.selected, 1); + assert_eq!(app.state.workspaces[1].active_tab, 0); + assert_eq!( + app.state.workspaces[1].tabs[0].layout.focused(), + second_pane + ); + } + + #[test] + fn scrolling_agent_panel_with_wheel_updates_agent_panel_scroll() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let first_pane = ws.tabs[0].root_pane; + ws.tabs[0] + .panes + .get_mut(&first_pane) + .unwrap() + .detected_agent = Some(Agent::Pi); + + for (tab_name, agent) in [ + ("logs", Agent::Claude), + ("review", Agent::Codex), + ("ops", Agent::Gemini), + ] { + let tab_idx = ws.test_add_tab(Some(tab_name)); + let pane_id = ws.tabs[tab_idx].root_pane; + ws.tabs[tab_idx] + .panes + .get_mut(&pane_id) + .unwrap() + .detected_agent = Some(agent); + } + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + let detail_area = app.state.agent_panel_rect(); + assert!(crate::ui::should_show_scrollbar( + crate::ui::agent_panel_scroll_metrics(&app.state, detail_area) + )); + + app.handle_mouse(mouse( + MouseEventKind::ScrollDown, + detail_area.x + 1, + detail_area.y + 4, + )); + + assert_eq!(app.state.agent_panel_scroll, 1); + assert_eq!(app.state.selected, 0); + } + + #[test] + fn clicking_scrolled_agent_detail_row_switches_to_correct_tab_and_pane() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let first_pane = ws.tabs[0].root_pane; + ws.tabs[0] + .panes + .get_mut(&first_pane) + .unwrap() + .detected_agent = Some(Agent::Pi); + + let second_tab = ws.test_add_tab(Some("logs")); + let second_pane = ws.tabs[second_tab].root_pane; + ws.tabs[second_tab] + .panes + .get_mut(&second_pane) + .unwrap() + .detected_agent = Some(Agent::Claude); + + for (tab_name, agent) in [("review", Agent::Codex), ("ops", Agent::Gemini)] { + let tab_idx = ws.test_add_tab(Some(tab_name)); + let pane_id = ws.tabs[tab_idx].root_pane; + ws.tabs[tab_idx] + .panes + .get_mut(&pane_id) + .unwrap() + .detected_agent = Some(agent); + } + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.agent_panel_scroll = 1; + + let detail_area = app.state.agent_panel_rect(); + let body = crate::ui::agent_panel_body_rect(detail_area, true); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + body.x + 1, + body.y, + )); + + assert_eq!(app.state.workspaces[0].active_tab, second_tab); + assert_eq!( + app.state.workspaces[0].tabs[second_tab].layout.focused(), + second_pane + ); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[test] + fn clicking_collapsed_agent_row_switches_to_correct_tab_and_pane() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let first_pane = ws.tabs[0].root_pane; + ws.tabs[0] + .panes + .get_mut(&first_pane) + .unwrap() + .detected_agent = Some(Agent::Pi); + let second_tab = ws.test_add_tab(Some("logs")); + let second_pane = ws.tabs[second_tab].root_pane; + ws.tabs[second_tab] + .panes + .get_mut(&second_pane) + .unwrap() + .detected_agent = Some(Agent::Claude); + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.sidebar_collapsed = true; + app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20); + app.state.view.terminal_area = Rect::new(4, 0, 80, 20); + + let (_, _, detail_area) = + crate::ui::collapsed_sidebar_sections(app.state.view.sidebar_rect); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + detail_area.x, + detail_area.y + 1, + )); + + assert_eq!(app.state.workspaces[0].active_tab, 1); + assert_eq!( + app.state.workspaces[0].tabs[1].layout.focused(), + second_pane + ); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[test] + fn clicking_collapsed_sidebar_toggle_expands_sidebar() { + let mut app = app_for_mouse_test(); + app.state.sidebar_collapsed = true; + app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20); + app.state.view.terminal_area = Rect::new(4, 0, 80, 20); + + let toggle = crate::ui::collapsed_sidebar_toggle_rect(app.state.view.sidebar_rect); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + toggle.x, + toggle.y, + )); + + assert!(!app.state.sidebar_collapsed); + } + + #[test] + fn clicking_workspace_switches_on_mouse_up() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; + app.state.active = Some(0); + app.state.selected = 0; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let target_row = app.state.view.workspace_card_areas[1].rect.y; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + 2, + target_row, + )); + assert_eq!(app.state.active, Some(0)); + assert!(app.state.workspace_press.is_some()); + + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); + assert_eq!(app.state.active, Some(1)); + assert_eq!(app.state.selected, 1); + assert!(app.state.workspace_press.is_none()); + let snapshot = capture_snapshot(&app.state); + assert_eq!(snapshot.active, Some(1)); + assert_eq!(snapshot.selected, 1); + } + + #[test] + fn dragging_workspace_reorders_without_changing_identity() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![ + Workspace::test_new("a"), + Workspace::test_new("b"), + Workspace::test_new("c"), + ]; + let active_id = app.state.workspaces[1].id.clone(); + let selected_id = app.state.workspaces[2].id.clone(); + app.state.active = Some(1); + app.state.selected = 2; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let source_row = app.state.view.workspace_card_areas[1].rect.y; + let target_row = crate::ui::workspace_drop_indicator_row( + &app.state.view.workspace_card_areas, + app.state.workspace_list_rect(), + 0, + ) + .unwrap(); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + 2, + source_row, + )); + app.handle_mouse(mouse( + MouseEventKind::Drag(MouseButton::Left), + 2, + target_row, + )); + assert!(matches!( + app.state.drag.as_ref().map(|drag| &drag.target), + Some(DragTarget::WorkspaceReorder { + source_ws_idx: 1, + insert_idx: Some(0), + }) + )); + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); + + let names: Vec<_> = app + .state + .workspaces + .iter() + .map(|ws| ws.display_name()) + .collect(); + assert_eq!(names, vec!["b", "a", "c"]); + assert_eq!(app.state.active, Some(0)); + assert_eq!(app.state.selected, 2); + assert_eq!(app.state.workspaces[0].id, active_id); + assert_eq!(app.state.workspaces[2].id, selected_id); + let snapshot = capture_snapshot(&app.state); + let captured_names: Vec<_> = snapshot + .workspaces + .iter() + .map(|ws| ws.custom_name.clone().unwrap()) + .collect(); + assert_eq!(captured_names, vec!["b", "a", "c"]); + } + + #[test] + fn clicking_tab_scroll_button_reveals_hidden_tabs_without_renaming() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + ws.test_add_tab(Some("logs")); + ws.test_add_tab(Some("review")); + ws.test_add_tab(Some("ops")); + ws.test_add_tab(Some("notes")); + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20)); + + let right = app.state.view.tab_scroll_right_hit_area; + assert!(right.width > 0); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + right.x + 1, + right.y, + )); + + assert_eq!(app.state.tab_scroll, 1); + assert!(!app.state.tab_scroll_follow_active); + assert_eq!(app.state.workspaces[0].active_tab, 0); + assert_eq!(app.state.view.tab_hit_areas[0].width, 0); + assert!(app.state.workspaces[0].tabs[0].custom_name.is_none()); + assert_eq!( + app.state.workspaces[0].tabs[1].custom_name.as_deref(), + Some("logs") + ); + } + + #[test] + fn clicking_last_visible_tab_at_right_edge_does_not_overscroll() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + for name in [ + "one", "two", "three", "four", "five", "six", "seven", "eight", + ] { + ws.test_add_tab(Some(name)); + } + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.tab_scroll = usize::MAX; + app.state.tab_scroll_follow_active = false; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20)); + + let last_idx = app.state.workspaces[0].tabs.len() - 1; + let target = app.state.view.tab_hit_areas[last_idx]; + let clamped_scroll = app.state.tab_scroll; + assert!(target.width > 0, "last tab should already be visible"); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + target.x + 1, + target.y, + )); + app.handle_mouse(mouse( + MouseEventKind::Up(MouseButton::Left), + target.x + 1, + target.y, + )); + + assert_eq!(app.state.workspaces[0].active_tab, last_idx); + assert_eq!(app.state.tab_scroll, clamped_scroll); + assert!(app.state.view.tab_hit_areas[last_idx].width > 0); + } + + #[test] + fn dragging_tab_reorders_auto_and_custom_names_without_materializing_numbers() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + ws.test_add_tab(Some("foo")); + ws.test_add_tab(None); + let moved_root = ws.tabs[0].root_pane; + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + + let source = app.state.view.tab_hit_areas[0]; + let last = app.state.view.tab_hit_areas[2]; + let drop_col = last.x + last.width; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + source.x + 1, + source.y, + )); + app.handle_mouse(mouse( + MouseEventKind::Drag(MouseButton::Left), + drop_col, + source.y, + )); + assert!(matches!( + app.state.drag.as_ref().map(|drag| &drag.target), + Some(DragTarget::TabReorder { + ws_idx: 0, + source_tab_idx: 0, + insert_idx: Some(3), + }) + )); + app.handle_mouse(mouse( + MouseEventKind::Up(MouseButton::Left), + drop_col, + source.y, + )); + + let labels: Vec<_> = app.state.workspaces[0] + .tabs + .iter() + .map(|tab| tab.display_name()) + .collect(); + assert_eq!(labels, vec!["foo", "2", "3"]); + assert_eq!( + app.state.workspaces[0].tabs[0].custom_name.as_deref(), + Some("foo") + ); + assert!(app.state.workspaces[0].tabs[1].custom_name.is_none()); + assert!(app.state.workspaces[0].tabs[2].custom_name.is_none()); + assert_eq!(app.state.workspaces[0].tabs[2].root_pane, moved_root); + assert_eq!(app.state.workspaces[0].active_tab, 2); + } + + fn temp_git_repo(branch: &str) -> std::path::PathBuf { + let repo = unique_temp_path("sidebar-drop-slot-repo"); + fs::create_dir_all(repo.join(".git")).unwrap(); + fs::write( + repo.join(".git/HEAD"), + format!("ref: refs/heads/{branch}\n"), + ) + .unwrap(); + repo + } + + #[test] + fn top_drop_slot_is_distinct_from_gap_below_first_workspace() { + let mut app = app_for_mouse_test(); + let first_repo = temp_git_repo("main"); + let second_repo = temp_git_repo("main"); + + let mut first = Workspace::test_new("a"); + let first_root = first.tabs[0].root_pane; + first.identity_cwd = first_repo.clone(); + first.tabs[0] + .pane_cwds + .insert(first_root, first_repo.clone()); + + let mut second = Workspace::test_new("b"); + let second_root = second.tabs[0].root_pane; + second.identity_cwd = second_repo.clone(); + second.tabs[0] + .pane_cwds + .insert(second_root, second_repo.clone()); + + app.state.workspaces = vec![first, second]; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + + assert_eq!(app.state.workspace_drop_index_at_row(0), Some(0)); + assert_eq!(app.state.workspace_drop_index_at_row(1), Some(0)); + assert_eq!(app.state.workspace_drop_index_at_row(2), Some(0)); + assert_eq!(app.state.workspace_drop_index_at_row(3), Some(1)); + + let _ = fs::remove_dir_all(first_repo); + let _ = fs::remove_dir_all(second_repo); + } + + #[test] + fn bottom_drop_slot_stays_below_last_workspace_not_footer() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![ + Workspace::test_new("a"), + Workspace::test_new("b"), + Workspace::test_new("c"), + ]; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + + let cards = &app.state.view.workspace_card_areas; + let bottom_slot = crate::ui::workspace_drop_indicator_row( + cards, + app.state.workspace_list_rect(), + cards.len(), + ) + .unwrap(); + + let last = cards.last().unwrap().rect; + assert_eq!(bottom_slot, last.y + last.height); + assert!(bottom_slot < app.state.sidebar_footer_rect().y.saturating_sub(1)); + } + + #[test] + fn dragging_sidebar_divider_sets_manual_width() { + let mut app = app_for_mouse_test(); + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); + app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 30, 5)); + + assert_eq!(app.state.sidebar_width, 31); + let snapshot = capture_snapshot(&app.state); + assert_eq!(snapshot.sidebar_width, Some(31)); + } + + #[test] + fn dragging_sidebar_section_divider_sets_split_ratio() { + let mut app = app_for_mouse_test(); + let divider = crate::ui::sidebar_section_divider_rect( + app.state.view.sidebar_rect, + app.state.sidebar_section_split, + ); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + divider.x + 1, + divider.y, + )); + app.handle_mouse(mouse( + MouseEventKind::Drag(MouseButton::Left), + divider.x + 1, + divider.y + 4, + )); + + assert!(app.state.sidebar_section_split > 0.5); + let snapshot = capture_snapshot(&app.state); + assert_eq!( + snapshot.sidebar_section_split, + Some(app.state.sidebar_section_split) + ); + } + + #[test] + fn double_clicking_sidebar_divider_resets_default_width() { + let mut app = app_for_mouse_test(); + app.state.default_sidebar_width = 26; + app.state.sidebar_width = 30; + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 25, 5)); + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); + + assert_eq!(app.state.sidebar_width, 26); + assert!(app.state.drag.is_none()); + let snapshot = capture_snapshot(&app.state); + assert_eq!(snapshot.sidebar_width, Some(26)); + } +} diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs new file mode 100644 index 00000000..910a49bd --- /dev/null +++ b/src/app/input/terminal.rs @@ -0,0 +1,440 @@ +use bytes::Bytes; +use crossterm::event::KeyCode; +use tracing::{debug, warn}; + +use crate::{ + app::{App, Mode}, + input::TerminalKey, +}; + +struct PreparedPaneInput { + ws_idx: usize, + pane_id: crate::layout::PaneId, + bytes: Bytes, +} + +fn is_modifier_only_key(code: &KeyCode) -> bool { + matches!(code, KeyCode::Modifier(_)) +} + +impl App { + pub(crate) fn handle_terminal_key_headless(&mut self, key: TerminalKey) { + let Some(input) = self.prepare_terminal_key_forward(key) else { + return; + }; + if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) { + let _ = runtime.try_send_bytes(input.bytes); + } + } + + fn prepare_terminal_key_forward(&mut self, key: TerminalKey) -> Option<PreparedPaneInput> { + self.state.clear_selection(); + self.state.update_dismissed = true; + + let key_event = key.as_key_event(); + + if let Some(action) = super::terminal_direct_navigation_action(&self.state, &key_event) { + debug!( + code = ?key_event.code, + modifiers = ?key_event.modifiers, + kind = ?key_event.kind, + action = ?action, + "intercepted terminal direct navigation key before forwarding to pane" + ); + super::navigate::execute_navigate_action(&mut self.state, action); + return None; + } + + if self.state.is_prefix(&key_event) { + self.state.mode = Mode::Navigate; + return None; + } + + if is_modifier_only_key(&key_event.code) { + debug!( + code = ?key_event.code, + modifiers = ?key_event.modifiers, + kind = ?key_event.kind, + "dropping modifier-only terminal key event instead of forwarding it to pane" + ); + return None; + } + + let ws_idx = self.state.active?; + let ws = self.state.workspaces.get(ws_idx)?; + let pane_id = ws.focused_pane_id()?; + let rt = ws.runtimes.get(&pane_id)?; + rt.scroll_reset(); + let protocol = rt.keyboard_protocol(); + let bytes = rt.encode_terminal_key(key); + + if matches!(key_event.code, KeyCode::Esc) + || key_event + .modifiers + .contains(crossterm::event::KeyModifiers::ALT) + { + debug!( + code = ?key_event.code, + modifiers = ?key_event.modifiers, + kind = ?key_event.kind, + protocol = ?protocol, + encoded = ?bytes, + "forwarding potentially-ambiguous terminal key to pane" + ); + } + + if bytes.is_empty() { + if key.kind != crossterm::event::KeyEventKind::Release + && !matches!( + key.code, + KeyCode::CapsLock + | KeyCode::ScrollLock + | KeyCode::NumLock + | KeyCode::PrintScreen + | KeyCode::Pause + | KeyCode::Menu + | KeyCode::KeypadBegin + | KeyCode::Media(_) + | KeyCode::Modifier(_) + ) + { + warn!(code = ?key_event.code, mods = ?key_event.modifiers, state = ?key_event.state, "key produced empty encoding"); + } + return None; + } + + Some(PreparedPaneInput { + ws_idx, + pane_id, + bytes: Bytes::from(bytes), + }) + } + + pub(super) async fn handle_terminal_key(&mut self, key: TerminalKey) { + let Some(input) = self.prepare_terminal_key_forward(key) else { + return; + }; + if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) { + let _ = runtime.send_bytes(input.bytes).await; + } + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind}; + use ratatui::layout::Rect; + + use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes}; + use super::*; + use crate::{config::Config, workspace::Workspace}; + + #[tokio::test] + async fn dragging_selection_above_pane_autoscrolls_and_extends_into_scrollback() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); + let info = pane_infos[0].clone(); + ws.tabs[0].runtimes.insert( + pane_id, + crate::pane::PaneRuntime::test_with_scrollback_bytes( + info.inner_rect.width, + info.inner_rect.height, + 16 * 1024, + &numbered_lines_bytes(64), + ), + ); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + + let start_metrics = app.state.workspaces[0] + .runtime(pane_id) + .and_then(crate::pane::PaneRuntime::scroll_metrics) + .expect("initial scroll metrics"); + let start_row = info.inner_rect.y; + let start_col = info.inner_rect.x + 2; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + start_col, + start_row, + )); + app.handle_mouse(mouse( + MouseEventKind::Drag(MouseButton::Left), + start_col, + info.inner_rect.y.saturating_sub(1), + )); + + let end_metrics = app.state.workspaces[0] + .runtime(pane_id) + .and_then(crate::pane::PaneRuntime::scroll_metrics) + .expect("scroll metrics after drag"); + assert_eq!( + end_metrics.offset_from_bottom, + start_metrics.offset_from_bottom + 3 + ); + + let selection = app.state.selection.as_ref().expect("selection after drag"); + assert!(selection.is_visible()); + assert_eq!( + selection.ordered_cells(), + ( + ( + (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32, + 2, + ), + (start_metrics.max_offset_from_bottom as u32, 2), + ) + ); + } + + #[tokio::test] + async fn releasing_dragged_selection_clears_highlight_after_copy() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); + let info = pane_infos[0].clone(); + ws.tabs[0].runtimes.insert( + pane_id, + crate::pane::PaneRuntime::test_with_scrollback_bytes( + info.inner_rect.width, + info.inner_rect.height, + 16 * 1024, + &numbered_lines_bytes(64), + ), + ); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + + let row = info.inner_rect.y; + let start_col = info.inner_rect.x + 1; + let end_col = info.inner_rect.x + 4; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + start_col, + row, + )); + app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); + assert!(app.state.selection.is_some()); + + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); + + assert!(app.state.selection.is_none()); + } + + #[tokio::test] + async fn wheel_scroll_keeps_in_progress_selection_and_extends_it() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); + let info = pane_infos[0].clone(); + ws.tabs[0].runtimes.insert( + pane_id, + crate::pane::PaneRuntime::test_with_scrollback_bytes( + info.inner_rect.width, + info.inner_rect.height, + 16 * 1024, + &numbered_lines_bytes(64), + ), + ); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + + let start_metrics = app.state.workspaces[0] + .runtime(pane_id) + .and_then(crate::pane::PaneRuntime::scroll_metrics) + .expect("initial scroll metrics"); + let top_row = info.inner_rect.y; + let col = info.inner_rect.x + 2; + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, top_row)); + app.handle_mouse(mouse(MouseEventKind::ScrollUp, col, top_row)); + + let end_metrics = app.state.workspaces[0] + .runtime(pane_id) + .and_then(crate::pane::PaneRuntime::scroll_metrics) + .expect("scroll metrics after wheel"); + assert_eq!( + end_metrics.offset_from_bottom, + start_metrics.offset_from_bottom + 3 + ); + + let selection = app.state.selection.as_ref().expect("selection after wheel"); + assert!(selection.is_visible()); + assert_eq!( + selection.ordered_cells(), + ( + ( + (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32, + 2, + ), + (start_metrics.max_offset_from_bottom as u32, 2), + ) + ); + } + + #[tokio::test] + async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_left_button() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let first_pane = ws.tabs[0].root_pane; + let second_pane = ws.test_split(ratatui::layout::Direction::Vertical); + + let terminal_area = Rect::new(26, 2, 80, 18); + let pane_infos = ws.tabs[0].layout.panes(terminal_area); + let first_info = pane_infos + .iter() + .find(|p| p.id == first_pane) + .unwrap() + .clone(); + let second_info = pane_infos + .iter() + .find(|p| p.id == second_pane) + .unwrap() + .clone(); + + ws.tabs[0].runtimes.insert( + first_pane, + crate::pane::PaneRuntime::test_with_screen_bytes( + first_info.inner_rect.width.max(1), + first_info.inner_rect.height.max(1), + b"", + ), + ); + ws.tabs[0].runtimes.insert( + second_pane, + crate::pane::PaneRuntime::test_with_screen_bytes( + second_info.inner_rect.width.max(1), + second_info.inner_rect.height.max(1), + b"\x1b[?1002h", + ), + ); + + ws.tabs[0].layout.focus_pane(first_pane); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + second_info.inner_rect.x + 2, + second_info.inner_rect.y + 2, + )); + + assert_eq!( + app.state.workspaces[0].tabs[0].layout.focused(), + second_pane + ); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[tokio::test] + async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_right_button() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let first_pane = ws.tabs[0].root_pane; + let second_pane = ws.test_split(ratatui::layout::Direction::Vertical); + + let terminal_area = Rect::new(26, 2, 80, 18); + let pane_infos = ws.tabs[0].layout.panes(terminal_area); + let first_info = pane_infos + .iter() + .find(|p| p.id == first_pane) + .unwrap() + .clone(); + let second_info = pane_infos + .iter() + .find(|p| p.id == second_pane) + .unwrap() + .clone(); + + ws.tabs[0].runtimes.insert( + first_pane, + crate::pane::PaneRuntime::test_with_screen_bytes( + first_info.inner_rect.width.max(1), + first_info.inner_rect.height.max(1), + b"", + ), + ); + ws.tabs[0].runtimes.insert( + second_pane, + crate::pane::PaneRuntime::test_with_screen_bytes( + second_info.inner_rect.width.max(1), + second_info.inner_rect.height.max(1), + b"\x1b[?1002h", + ), + ); + + ws.tabs[0].layout.focus_pane(first_pane); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Right), + second_info.inner_rect.x + 2, + second_info.inner_rect.y + 2, + )); + + assert_eq!( + app.state.workspaces[0].tabs[0].layout.focused(), + second_pane + ); + assert_eq!(app.state.mode, Mode::ContextMenu); + assert!(app.state.context_menu.is_some()); + } + + #[tokio::test] + async fn terminal_direct_focus_pane_shortcut_switches_focus_without_leaving_terminal_mode() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); + app.state.view.pane_infos = app.state.workspaces[0] + .active_tab() + .unwrap() + .layout + .panes(Rect::new(0, 0, 80, 24)); + let focused_before = app.state.workspaces[0].layout.focused(); + app.state.keybinds.focus_pane_left = Some((KeyCode::Char('h'), KeyModifiers::ALT)); + app.state.keybinds.focus_pane_left_label = Some("alt+h".into()); + + app.handle_terminal_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::ALT)) + .await; + + assert_ne!(app.state.workspaces[0].layout.focused(), focused_before); + assert_eq!(app.state.mode, Mode::Terminal); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 7f821da2..51a6678e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2,15 +2,19 @@ //! //! - `state.rs` — AppState, Mode, and pure data structs //! - `actions.rs` — state mutations (testable without PTYs/async) -//! - `api.rs` — API request handling and response building //! - `input.rs` — key/mouse → action translation -//! - `runtime_commands.rs` — custom command launching and overlay restoration -mod actions; +pub(crate) mod actions; mod api; +mod api_helpers; +mod config_io; +mod creation; +mod ids; mod input; -mod runtime_commands; +mod runtime; +mod session; pub mod state; +mod theme_sync; use std::collections::{HashMap, HashSet}; use std::future::pending; @@ -20,7 +24,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16); -const ANIMATION_INTERVAL: Duration = Duration::from_millis(16); +pub(crate) const ANIMATION_INTERVAL: Duration = Duration::from_millis(16); const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); @@ -31,44 +35,49 @@ use crossterm::terminal; use ratatui::layout::Rect; use ratatui::DefaultTerminal; use tokio::sync::{mpsc, Notify}; -use tracing::{error, info}; +use tracing::info; use crate::config::Config; use crate::events::AppEvent; -use crate::workspace::Workspace; - -#[cfg(test)] -use self::api::api_request_changes_ui; pub use state::{AppState, Mode, ToastKind, ViewState}; /// Full application: AppState + runtime concerns (event channels, async I/O). +#[derive(Debug, Clone, Copy)] +pub(crate) struct OverlayPaneState { + ws_idx: usize, + tab_idx: usize, + previous_focus: crate::layout::PaneId, + previous_zoomed: bool, +} + pub struct App { pub state: AppState, pub event_tx: mpsc::Sender<AppEvent>, - event_rx: mpsc::Receiver<AppEvent>, - api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>, - event_hub: crate::api::EventHub, - last_focus: Option<(usize, crate::layout::PaneId)>, - no_session: bool, - input_rx: Option<mpsc::Receiver<crate::raw_input::RawInputEvent>>, - last_terminal_size: Option<(u16, u16)>, - config_diagnostic_deadline: Option<Instant>, - toast_deadline: Option<Instant>, - last_git_remote_status_refresh: Instant, - last_sidebar_divider_click: Option<Instant>, - next_resize_poll: Instant, - next_animation_tick: Option<Instant>, - next_auto_update_check: Option<Instant>, - session_save_deadline: Option<Instant>, - last_render_at: Option<Instant>, - suppressed_repeat_keys: HashSet<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, - render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, - overlay_panes: HashMap<crate::layout::PaneId, runtime_commands::OverlayPaneState>, + pub(crate) event_rx: mpsc::Receiver<AppEvent>, + pub(crate) api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>, + pub(crate) event_hub: crate::api::EventHub, + pub(crate) last_focus: Option<(usize, crate::layout::PaneId)>, + pub(crate) no_session: bool, + pub(crate) input_rx: Option<mpsc::Receiver<crate::raw_input::RawInputEvent>>, + pub(crate) last_terminal_size: Option<(u16, u16)>, + pub(crate) config_diagnostic_deadline: Option<Instant>, + pub(crate) toast_deadline: Option<Instant>, + pub(crate) last_git_remote_status_refresh: Instant, + pub(crate) last_sidebar_divider_click: Option<Instant>, + pub(crate) next_resize_poll: Instant, + pub(crate) next_animation_tick: Option<Instant>, + pub(crate) next_auto_update_check: Option<Instant>, + pub(crate) session_save_deadline: Option<Instant>, + pub(crate) last_render_at: Option<Instant>, + pub(crate) suppressed_repeat_keys: + HashSet<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, + pub render_notify: Arc<Notify>, + pub render_dirty: Arc<AtomicBool>, + pub(crate) overlay_panes: HashMap<crate::layout::PaneId, OverlayPaneState>, } -enum LoopEvent { +pub(crate) enum LoopEvent { Timer, Internal(AppEvent), Api(crate::api::ApiRequestMessage), @@ -99,6 +108,10 @@ fn repeat_key_identity( (key.code, key.modifiers) } +fn auto_updates_enabled(no_session: bool) -> bool { + !no_session && !cfg!(debug_assertions) +} + /// Resolve the palette from config: base theme + optional custom overrides. fn resolve_palette(config: &crate::config::Config) -> state::Palette { // Start with the named theme (default: catppuccin) @@ -228,9 +241,12 @@ impl App { selected, mode, should_quit: false, + quit_detaches: !no_session, + detach_requested: false, request_new_workspace: false, request_new_tab: false, request_reload_keybinds: false, + request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, request_complete_onboarding: false, @@ -307,9 +323,10 @@ impl App { ws.refresh_git_ahead_behind(); } - // Background auto-update (skipped in --no-session / test mode) - // Check once at startup, then periodically from the main loop. - if !no_session { + // Background auto-update is disabled in monolithic no-session mode + // and in debug/test builds so local development never mutates the + // running binary out from under spawned test processes. + if auto_updates_enabled(no_session) { let update_tx = event_tx.clone(); std::thread::spawn(move || crate::update::auto_update(update_tx)); } @@ -334,7 +351,7 @@ impl App { last_sidebar_divider_click: None, next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL, next_animation_tick: None, - next_auto_update_check: (!no_session) + next_auto_update_check: auto_updates_enabled(no_session) .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), session_save_deadline: None, last_render_at: None, @@ -351,42 +368,6 @@ impl App { } } - fn schedule_session_save(&mut self) { - if !self.no_session { - self.session_save_deadline = Some(Instant::now() + SESSION_SAVE_DEBOUNCE); - } - } - - fn sync_session_save_schedule(&mut self) { - if self.state.session_dirty { - self.state.session_dirty = false; - self.schedule_session_save(); - } - } - - fn save_session_now(&mut self) { - if self.no_session { - self.session_save_deadline = None; - return; - } - - if self.state.workspaces.is_empty() { - crate::persist::clear(); - } else { - let snap = crate::persist::capture( - &self.state.workspaces, - self.state.active, - self.state.selected, - self.state.agent_panel_scope, - self.state.sidebar_width, - self.state.sidebar_section_split, - ); - crate::persist::save(&snap); - } - - self.session_save_deadline = None; - } - pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { if self.input_rx.is_none() { self.input_rx = Some(crate::raw_input::spawn_input_reader()); @@ -510,453 +491,6 @@ impl App { Ok(()) } - fn drain_api_requests(&mut self) -> bool { - let mut changed = false; - while let Ok(msg) = self.api_rx.try_recv() { - changed |= self.handle_api_request_message(msg); - } - changed - } - - async fn handle_raw_input_batch(&mut self, first: crate::raw_input::RawInputEvent) -> bool { - let mut changed = self.handle_raw_input_event(first).await; - - while let Some(rx) = self.input_rx.as_mut() { - match rx.try_recv() { - Ok(event) => changed |= self.handle_raw_input_event(event).await, - Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, - Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { - self.input_rx = None; - break; - } - } - } - - changed - } - - async fn handle_raw_input_event(&mut self, event: crate::raw_input::RawInputEvent) -> bool { - match event { - crate::raw_input::RawInputEvent::Key(key) => { - let key_id = repeat_key_identity(&key); - match key.kind { - crossterm::event::KeyEventKind::Press => { - if self.state.mode == Mode::Terminal { - self.suppressed_repeat_keys.remove(&key_id); - } else { - self.suppressed_repeat_keys.insert(key_id); - } - self.handle_key(key).await; - true - } - crossterm::event::KeyEventKind::Repeat => { - if self.state.mode == Mode::Terminal - && !self.suppressed_repeat_keys.contains(&key_id) - { - self.handle_key(key).await; - true - } else { - false - } - } - crossterm::event::KeyEventKind::Release => { - self.suppressed_repeat_keys.remove(&key_id); - false - } - } - } - crate::raw_input::RawInputEvent::Paste(text) => { - self.handle_paste(text).await; - true - } - crate::raw_input::RawInputEvent::Mouse(mouse) => { - self.handle_mouse(mouse); - true - } - crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { - let next_theme = self.state.host_terminal_theme.with_color(kind, color); - if next_theme == self.state.host_terminal_theme { - return false; - } - self.state.host_terminal_theme = next_theme; - self.apply_host_terminal_theme_to_panes(); - true - } - crate::raw_input::RawInputEvent::Unsupported => false, - } - } - - fn query_host_terminal_theme(&self) { - use std::io::Write; - - let _ = std::io::stdout() - .write_all(crate::terminal_theme::HOST_COLOR_QUERY_SEQUENCE.as_bytes()); - let _ = std::io::stdout().flush(); - } - - fn apply_host_terminal_theme_to_panes(&self) { - if self.state.host_terminal_theme.is_empty() { - return; - } - - for workspace in &self.state.workspaces { - for tab in &workspace.tabs { - for runtime in tab.runtimes.values() { - runtime.apply_host_terminal_theme(self.state.host_terminal_theme); - } - } - } - - self.render_dirty.store(true, Ordering::Release); - self.render_notify.notify_one(); - } - - fn handle_resize_poll(&mut self) -> bool { - let Ok(size) = terminal::size() else { - return false; - }; - if self.last_terminal_size != Some(size) { - self.last_terminal_size = Some(size); - return true; - } - false - } - - fn handle_scheduled_tasks(&mut self, now: Instant) -> bool { - let mut changed = false; - - self.sync_animation_timer(now); - - if now >= self.next_resize_poll { - changed |= self.handle_resize_poll(); - self.next_resize_poll = now + RESIZE_POLL_INTERVAL; - } - - if self - .config_diagnostic_deadline - .is_some_and(|deadline| now >= deadline) - { - self.config_diagnostic_deadline = None; - self.state.config_diagnostic = None; - changed = true; - } - - if self.toast_deadline.is_some_and(|deadline| now >= deadline) { - self.toast_deadline = None; - self.state.toast = None; - changed = true; - } - - if self - .next_animation_tick - .is_some_and(|deadline| now >= deadline) - { - self.state.spinner_tick = self.state.spinner_tick.wrapping_add(1); - self.next_animation_tick = Some(now + ANIMATION_INTERVAL); - changed = true; - } - - if self - .git_refresh_deadline() - .is_some_and(|deadline| now >= deadline) - { - for ws in &mut self.state.workspaces { - ws.refresh_git_ahead_behind(); - } - self.last_git_remote_status_refresh = now; - changed = true; - } - - if self - .next_auto_update_check - .is_some_and(|deadline| now >= deadline) - { - self.run_auto_update_check(); - } - - if self - .session_save_deadline - .is_some_and(|deadline| now >= deadline) - { - self.save_session_now(); - } - - self.sync_animation_timer(now); - changed - } - - fn sync_animation_timer(&mut self, now: Instant) { - if self.agent_panel_has_animation() { - self.next_animation_tick - .get_or_insert(now + ANIMATION_INTERVAL); - } else { - self.next_animation_tick = None; - } - } - - fn agent_panel_has_animation(&self) -> bool { - match self.state.agent_panel_scope { - crate::app::state::AgentPanelScope::CurrentWorkspace => self - .state - .active - .and_then(|idx| self.state.workspaces.get(idx)) - .is_some_and(Workspace::has_working_pane), - crate::app::state::AgentPanelScope::AllWorkspaces => self - .state - .workspaces - .iter() - .any(Workspace::has_working_pane), - } - } - - fn can_render_now(&self, now: Instant) -> bool { - match self.last_render_at { - Some(last_render_at) => now.duration_since(last_render_at) >= MIN_RENDER_INTERVAL, - None => true, - } - } - - fn run_auto_update_check(&mut self) { - self.next_auto_update_check = self - .state - .update_available - .is_none() - .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL); - - if self.state.update_available.is_some() { - return; - } - - let update_tx = self.event_tx.clone(); - std::thread::spawn(move || crate::update::auto_update(update_tx)); - } - - fn git_refresh_deadline(&self) -> Option<Instant> { - (!self.state.workspaces.is_empty()) - .then_some(self.last_git_remote_status_refresh + GIT_REMOTE_STATUS_REFRESH_INTERVAL) - } - - fn next_loop_deadline(&self, now: Instant, needs_render: bool) -> Option<Instant> { - let render_deadline = if needs_render { - self.last_render_at - .map(|last_render_at| last_render_at + MIN_RENDER_INTERVAL) - .filter(|deadline| *deadline > now) - } else { - None - }; - - [ - Some(self.next_resize_poll), - self.config_diagnostic_deadline, - self.toast_deadline, - self.next_animation_tick, - self.git_refresh_deadline(), - self.next_auto_update_check, - self.session_save_deadline, - render_deadline, - ] - .into_iter() - .flatten() - .min() - } - - fn drain_internal_events(&mut self) -> bool { - let mut had_event = false; - while let Ok(ev) = self.event_rx.try_recv() { - had_event = true; - self.handle_internal_event(ev); - } - had_event - } - - fn handle_internal_event(&mut self, ev: AppEvent) { - if let AppEvent::ClipboardWrite { content } = ev { - crate::selection::write_osc52_bytes(&content); - return; - } - - let overlay_state = if let AppEvent::PaneDied { pane_id } = &ev { - self.overlay_panes.remove(pane_id) - } else { - None - }; - - if let AppEvent::PaneDied { pane_id } = &ev { - if let Some((ws_idx, _)) = self.find_pane(*pane_id) { - if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneExited, - data: crate::api::schema::EventData::PaneExited { - pane_id: public_pane_id, - workspace_id: self.public_workspace_id(ws_idx), - }, - }); - } - } - } - - let released_agent = if let AppEvent::HookAgentReleased { - pane_id, - known_agent, - .. - } = &ev - { - known_agent.map(|agent| (*pane_id, agent)) - } else { - None - }; - - let previous_toast = self.state.toast.clone(); - let pane_updates = self.state.handle_app_event(ev); - for update in &pane_updates { - self.emit_pane_state_update(update); - } - if let Some((pane_id, agent)) = released_agent { - if pane_updates.iter().any(|update| update.pane_id == pane_id) { - if let Some((ws_idx, _)) = self.find_pane(pane_id) { - if let Some(runtime) = self.state.workspaces[ws_idx].runtimes.get(&pane_id) { - runtime.begin_graceful_release(agent); - } - } - } - } - if let Some(overlay) = overlay_state { - self.restore_overlay_after_exit(overlay); - } - self.sync_toast_deadline(previous_toast); - } - - fn emit_pane_state_update(&self, update: &crate::app::actions::PaneStateUpdate) { - let Some(pane_id) = self.public_pane_id(update.ws_idx, update.pane_id) else { - return; - }; - let workspace_id = self.public_workspace_id(update.ws_idx); - - if update.previous_agent_label != update.agent_label { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneAgentDetected, - data: crate::api::schema::EventData::PaneAgentDetected { - pane_id: pane_id.clone(), - workspace_id: workspace_id.clone(), - agent: update.agent_label.clone(), - }, - }); - } - - if update.previous_state != update.state { - let agent_status = self - .state - .workspaces - .get(update.ws_idx) - .and_then(|ws| ws.pane_state(update.pane_id)) - .map(|pane| pane_agent_status(pane.state, pane.seen)) - .unwrap_or_else(|| pane_agent_status(update.state, true)); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneAgentStatusChanged, - data: crate::api::schema::EventData::PaneAgentStatusChanged { - pane_id, - workspace_id, - agent_status, - }, - }); - } - } - - fn sync_toast_deadline( - &mut self, - previous_toast: Option<crate::app::state::ToastNotification>, - ) { - if self.state.toast != previous_toast { - self.toast_deadline = self.state.toast.as_ref().map(|toast| { - let duration = match toast.kind { - ToastKind::NeedsAttention => Duration::from_secs(8), - ToastKind::Finished => Duration::from_secs(5), - ToastKind::UpdateInstalled => Duration::from_secs(3), - }; - Instant::now() + duration - }); - } - } - - fn emit_event(&self, event: crate::api::schema::EventEnvelope) { - self.event_hub.push(event); - } - - fn sync_focus_events(&mut self) { - let current_focus = self.state.active.and_then(|idx| { - self.state - .workspaces - .get(idx) - .and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id))) - }); - if current_focus == self.last_focus { - return; - } - - if let Some((ws_idx, pane_id)) = self.last_focus { - self.send_pane_focus_event(ws_idx, pane_id, crate::ghostty::FocusEvent::Lost); - } - if let Some((ws_idx, pane_id)) = current_focus { - self.send_pane_focus_event(ws_idx, pane_id, crate::ghostty::FocusEvent::Gained); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::WorkspaceFocused, - data: crate::api::schema::EventData::WorkspaceFocused { - workspace_id: self.public_workspace_id(ws_idx), - }, - }); - if let Some(tab_id) = - self.public_tab_id(ws_idx, self.state.workspaces[ws_idx].active_tab) - { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::TabFocused, - data: crate::api::schema::EventData::TabFocused { - tab_id, - workspace_id: self.public_workspace_id(ws_idx), - }, - }); - } - if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneFocused, - data: crate::api::schema::EventData::PaneFocused { - pane_id: public_pane_id, - workspace_id: self.public_workspace_id(ws_idx), - }, - }); - } - } - - self.last_focus = current_focus; - } - - fn send_pane_focus_event( - &self, - ws_idx: usize, - pane_id: crate::layout::PaneId, - event: crate::ghostty::FocusEvent, - ) { - let Some(runtime) = self - .state - .workspaces - .get(ws_idx) - .and_then(|ws| ws.runtime(pane_id)) - else { - return; - }; - runtime.try_send_focus_event(event); - } - - fn find_pane( - &self, - pane_id: crate::layout::PaneId, - ) -> Option<(usize, &crate::pane::PaneState)> { - self.state - .workspaces - .iter() - .enumerate() - .find_map(|(ws_idx, ws)| ws.pane_state(pane_id).map(|pane| (ws_idx, pane))) - } - pub(crate) fn dismiss_release_notes(&mut self) { let preview = self .state @@ -1018,29 +552,7 @@ impl App { } } - fn update_config_file<F>(&mut self, error_context: &str, update: F) - where - F: FnOnce(&str) -> String, - { - let path = crate::config::config_path(); - if let Some(parent) = path.parent() { - if let Err(err) = std::fs::create_dir_all(parent) { - self.state.config_diagnostic = - Some(format!("failed to save {error_context}: {err}")); - self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(5)); - return; - } - } - - let content = std::fs::read_to_string(&path).unwrap_or_default(); - let new_content = update(&content); - if let Err(err) = std::fs::write(&path, new_content) { - self.state.config_diagnostic = Some(format!("failed to save {error_context}: {err}")); - self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(5)); - } - } - - fn reload_keybinds(&mut self) { + pub(crate) fn reload_keybinds(&mut self) { let previous_toast = self.state.toast.clone(); match crate::config::load_live_keybinds() { Ok(live) => { @@ -1067,216 +579,145 @@ impl App { } self.sync_toast_deadline(previous_toast); } +} - fn save_theme(&mut self, name: &str) { - self.update_config_file("theme", |content| { - crate::config::upsert_section_value(content, "theme", "name", &format!("\"{name}\"")) - }); - } - - fn save_sound(&mut self, enabled: bool) { - self.update_config_file("sound setting", |content| { - crate::config::upsert_section_bool(content, "ui.sound", "enabled", enabled) - }); - } - - fn save_toast(&mut self, enabled: bool) { - self.update_config_file("toast setting", |content| { - crate::config::upsert_section_bool(content, "ui.toast", "enabled", enabled) - }); - } - - fn seed_cwd_from_workspace(&self, ws_idx: usize) -> Option<std::path::PathBuf> { - self.state.workspaces.get(ws_idx)?.resolved_identity_cwd() - } - - fn workspace_creation_source(&self) -> Option<usize> { - if self.state.mode == Mode::Navigate - && self.state.workspaces.get(self.state.selected).is_some() - { - return Some(self.state.selected); - } - - self.state.active.or_else(|| { - self.state - .workspaces - .get(self.state.selected) - .map(|_| self.state.selected) - }) - } - - /// Create a workspace with a real PTY (needs event_tx). - fn create_workspace(&mut self) { - let initial_cwd = self - .workspace_creation_source() - .and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx)) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| std::path::PathBuf::from("/")); - if let Err(e) = self.create_workspace_with_options(initial_cwd, true) { - error!(err = %e, "failed to create workspace"); - self.state.mode = Mode::Navigate; - } - } +// --------------------------------------------------------------------------- +// Input routing for headless server mode +// --------------------------------------------------------------------------- - fn create_tab(&mut self) { - let custom_name = self.state.requested_new_tab_name.take(); - let initial_cwd = self - .state - .active - .and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx)) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| std::path::PathBuf::from("/")); - match self.create_tab_with_options(initial_cwd, true) { - Ok(tab_idx) => { - if let Some(name) = custom_name { - if let Some(ws) = self - .state - .active - .and_then(|ws_idx| self.state.workspaces.get_mut(ws_idx)) - { - if let Some(tab) = ws.tabs.get_mut(tab_idx) { - tab.set_custom_name(name); +impl App { + /// Routes raw input bytes from a client through the existing input pipeline. + /// + /// The input bytes are parsed into `RawInputEvent`s and then processed. + /// In terminal mode, keys are routed through the same semantic + /// key-handling path as monolithic herdr so they are re-encoded for the + /// focused pane's negotiated keyboard protocol instead of passing host + /// terminal escape sequences through unchanged. + #[cfg(test)] + pub(crate) fn route_client_input(&mut self, data: Vec<u8>) { + let events = crate::raw_input::parse_raw_input_bytes_sync(&data); + self.route_client_events(events, true); + } + + pub(crate) fn route_client_events( + &mut self, + events: Vec<crate::raw_input::RawInputEvent>, + apply_host_terminal_theme: bool, + ) { + for event in events { + match event { + crate::raw_input::RawInputEvent::Key(key) => { + let key_id = repeat_key_identity(&key); + match key.kind { + crossterm::event::KeyEventKind::Press => { + if self.state.mode == Mode::Terminal { + self.suppressed_repeat_keys.remove(&key_id); + self.handle_terminal_key_headless(key); + } else { + self.suppressed_repeat_keys.insert(key_id); + self.handle_non_terminal_key(key); + } + } + crossterm::event::KeyEventKind::Repeat => { + if self.state.mode == Mode::Terminal + && !self.suppressed_repeat_keys.contains(&key_id) + { + self.handle_terminal_key_headless(key); + } + // Repeats in non-terminal modes are ignored + // (same as monolithic behavior). + } + crossterm::event::KeyEventKind::Release => { + self.suppressed_repeat_keys.remove(&key_id); } - self.schedule_session_save(); } } + crate::raw_input::RawInputEvent::Mouse(mouse) => { + self.handle_mouse_event_headless(mouse); + } + crate::raw_input::RawInputEvent::Paste(text) => { + if self.state.mode == Mode::Terminal { + if let Some(ws_idx) = self.state.active { + if let Some(ws) = self.state.workspaces.get(ws_idx) { + if let Some(focused) = ws.focused_pane_id() { + if let Some(runtime) = ws.runtimes.get(&focused) { + let _ = runtime.try_send_bytes(bytes::Bytes::from( + if runtime + .input_state() + .map(|s| s.bracketed_paste) + .unwrap_or(false) + { + format!("\x1b[200~{text}\x1b[201~") + } else { + text + }, + )); + } + } + } + } + } + } + crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { + if apply_host_terminal_theme { + self.update_host_terminal_theme(kind, color); + } + } + crate::raw_input::RawInputEvent::Unsupported => {} } - Err(e) => { - error!(err = %e, "failed to create tab"); - } - } - } - - fn create_tab_with_options( - &mut self, - initial_cwd: std::path::PathBuf, - focus: bool, - ) -> std::io::Result<usize> { - let Some(ws_idx) = self.state.active else { - return self.create_workspace_with_options(initial_cwd, focus); - }; - let (rows, cols) = self.state.estimate_pane_size(); - let ws = &mut self.state.workspaces[ws_idx]; - let idx = ws.create_tab( - rows, - cols, - initial_cwd, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - )?; - if focus { - ws.switch_tab(idx); - self.state.mode = Mode::Terminal; } - self.schedule_session_save(); - Ok(idx) } - fn create_workspace_with_options( - &mut self, - initial_cwd: std::path::PathBuf, - focus: bool, - ) -> std::io::Result<usize> { - let (rows, cols) = self.state.estimate_pane_size(); - let mut ws = Workspace::new( - initial_cwd, - rows, - cols, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - self.event_tx.clone(), - self.render_notify.clone(), - self.render_dirty.clone(), - )?; - ws.refresh_git_ahead_behind(); - self.state.workspaces.push(ws); - let idx = self.state.workspaces.len() - 1; - if focus || self.state.active.is_none() { - self.state.switch_workspace(idx); - self.state.mode = Mode::Terminal; + /// Handles a key event in non-terminal mode for the headless server. + /// + /// Uses the standalone handler functions that work on `&mut AppState` + /// since the server doesn't have the async context of the monolithic App. + fn handle_non_terminal_key(&mut self, key: crate::input::TerminalKey) { + let key_event = key.as_key_event(); + match self.state.mode { + Mode::Navigate => { + input::handle_navigate_key(&mut self.state, key_event); + } + Mode::RenameWorkspace | Mode::RenameTab => { + input::handle_rename_key(&mut self.state, key_event); + } + Mode::Resize => { + input::handle_resize_key(&mut self.state, key_event); + } + Mode::ConfirmClose => { + input::handle_confirm_close_key(&mut self.state, key_event); + } + Mode::ContextMenu => { + input::handle_context_menu_key(&mut self.state, key_event); + } + Mode::KeybindHelp => { + input::handle_keybind_help_key(&mut self.state, key_event); + } + Mode::GlobalMenu => { + input::handle_global_menu_key(&mut self.state, key_event); + } + Mode::Onboarding => { + self.handle_onboarding_key(key_event); + } + Mode::ReleaseNotes => { + self.handle_release_notes_key(key_event); + } + Mode::Settings => { + self.handle_settings_key(key_event); + } + Mode::Terminal => { + // Should not be called in terminal mode. + } } - self.schedule_session_save(); - Ok(idx) - } -} - -fn tab_attention_priority(state: crate::detect::AgentState, seen: bool) -> u8 { - match (state, seen) { - (crate::detect::AgentState::Blocked, _) => 4, - (crate::detect::AgentState::Idle, false) => 3, - (crate::detect::AgentState::Working, _) => 2, - (crate::detect::AgentState::Idle, true) => 1, - (crate::detect::AgentState::Unknown, _) => 0, - } -} - -fn parse_api_key(key: &str) -> Option<crossterm::event::KeyEvent> { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - let normalized = key.trim(); - match normalized { - "Enter" | "enter" => Some(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())), - "Tab" | "tab" => Some(KeyEvent::new(KeyCode::Tab, KeyModifiers::empty())), - "Esc" | "esc" => Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())), - "Backspace" | "backspace" => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty())), - "Up" | "up" => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::empty())), - "Down" | "down" => Some(KeyEvent::new(KeyCode::Down, KeyModifiers::empty())), - "Left" | "left" => Some(KeyEvent::new(KeyCode::Left, KeyModifiers::empty())), - "Right" | "right" => Some(KeyEvent::new(KeyCode::Right, KeyModifiers::empty())), - "C-c" | "c-c" | "ctrl+c" => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), - _ if normalized.len() == 1 => normalized - .chars() - .next() - .map(|ch| KeyEvent::new(KeyCode::Char(ch), KeyModifiers::empty())), - _ => None, - } -} - -fn encode_api_text(runtime: &crate::pane::PaneRuntime, text: &str) -> Vec<u8> { - let bracketed = runtime - .input_state() - .map(|state| state.bracketed_paste) - .unwrap_or(false); - if bracketed { - format!("\x1b[200~{text}\x1b[201~").into_bytes() - } else { - text.as_bytes().to_vec() - } -} - -fn encode_api_keys( - runtime: &crate::pane::PaneRuntime, - keys: &[String], -) -> Result<Vec<Vec<u8>>, String> { - let mut encoded_keys = Vec::with_capacity(keys.len()); - for key in keys { - let Some(key_event) = parse_api_key(key) else { - return Err(key.clone()); - }; - encoded_keys.push(runtime.encode_terminal_key(key_event.into())); - } - Ok(encoded_keys) -} - -fn detect_state_from_api(state: crate::api::schema::PaneAgentState) -> crate::detect::AgentState { - match state { - crate::api::schema::PaneAgentState::Idle => crate::detect::AgentState::Idle, - crate::api::schema::PaneAgentState::Working => crate::detect::AgentState::Working, - crate::api::schema::PaneAgentState::Blocked => crate::detect::AgentState::Blocked, - crate::api::schema::PaneAgentState::Unknown => crate::detect::AgentState::Unknown, } -} -fn pane_agent_status( - state: crate::detect::AgentState, - seen: bool, -) -> crate::api::schema::AgentStatus { - match (state, seen) { - (crate::detect::AgentState::Idle, false) => crate::api::schema::AgentStatus::Done, - (crate::detect::AgentState::Idle, true) => crate::api::schema::AgentStatus::Idle, - (crate::detect::AgentState::Working, _) => crate::api::schema::AgentStatus::Working, - (crate::detect::AgentState::Blocked, _) => crate::api::schema::AgentStatus::Blocked, - (crate::detect::AgentState::Unknown, _) => crate::api::schema::AgentStatus::Unknown, + /// Handles a mouse event for the headless server. + /// + /// Delegates to the same mouse handling logic used in the monolithic + /// mode (hit-testing against the rendered UI), which works because + /// the server's AppState maintains view geometry from virtual rendering. + fn handle_mouse_event_headless(&mut self, mouse: crossterm::event::MouseEvent) { + self.handle_mouse(mouse); } } @@ -1285,6 +726,7 @@ mod tests { use super::*; use crate::config::Config; use crate::detect::{Agent, AgentState}; + use crate::pane::PaneRuntime; use crate::workspace::Workspace; use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; use std::sync::{Mutex, OnceLock}; @@ -1541,8 +983,8 @@ mod tests { ), }; - assert!(!api_request_changes_ui(&read_only)); - assert!(api_request_changes_ui(&mutating)); + assert!(!crate::api::request_changes_ui(&read_only)); + assert!(crate::api::request_changes_ui(&mutating)); } #[test] @@ -1614,6 +1056,22 @@ mod tests { } #[test] + fn server_stop_request_sets_should_quit_flag() { + let mut app = test_app(); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req_server_stop".into(), + method: crate::api::schema::Method::ServerStop( + crate::api::schema::EmptyParams::default(), + ), + }); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + + assert_eq!(response["result"]["type"], "ok"); + assert!(app.state.should_quit); + } + + #[test] fn pane_close_request_closes_only_the_target_tab_when_other_tabs_exist() { let mut app = test_app(); let mut workspace = Workspace::test_new("api-pane-close"); @@ -1758,4 +1216,230 @@ mod tests { "Working→Idle should still apply after temporary queue pressure" ); } + + #[test] + fn route_client_input_dispatches_navigate_mode_keybinds() { + let mut app = test_app(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + + // Start in navigate mode. + app.state.mode = Mode::Navigate; + + // Send Ctrl+B then Esc (prefix → leave navigate mode). + // Ctrl+B is 0x02 in raw terminal input. + // After entering navigate mode and pressing Esc, we should leave navigate mode. + let esc_bytes = vec![0x1b]; // Esc + app.route_client_input(esc_bytes); + // Esc in navigate mode should leave navigate mode. + assert_eq!( + app.state.mode, + Mode::Terminal, + "Esc should leave navigate mode and return to Terminal mode" + ); + } + + #[test] + fn route_client_input_q_detaches_in_persistence_mode() { + let mut app = test_app(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.quit_detaches = true; + + // Start in navigate mode. + app.state.mode = Mode::Navigate; + assert!(!app.state.detach_requested); + + let q_bytes = b"q".to_vec(); + app.route_client_input(q_bytes); + + assert!( + app.state.detach_requested, + "q should detach in persistence mode" + ); + assert_eq!( + app.state.mode, + Mode::Terminal, + "q should leave navigate mode" + ); + } + + #[test] + fn route_client_input_prefix_then_q_detaches_in_persistence_mode() { + let mut app = test_app(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.quit_detaches = true; + + // Start in terminal mode (default after workspace creation). + app.state.mode = Mode::Terminal; + assert!(!app.state.detach_requested); + + // Send Ctrl+B (prefix key, raw byte 0x02). + let prefix_bytes = vec![0x02]; + app.route_client_input(prefix_bytes); + + assert_eq!( + app.state.mode, + Mode::Navigate, + "prefix key should enter navigate mode" + ); + assert!( + !app.state.detach_requested, + "prefix key should not set detach flag" + ); + + let q_bytes = b"q".to_vec(); + app.route_client_input(q_bytes); + + assert!( + app.state.detach_requested, + "q should detach in persistence mode" + ); + assert_eq!( + app.state.mode, + Mode::Terminal, + "q should leave navigate mode" + ); + } + + #[tokio::test] + async fn route_client_input_reencodes_terminal_keys_for_focused_pane_protocol() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = PaneRuntime::test_with_channel(80, 24); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + // Ghostty/kitty-style Ctrl-C should be normalized back to the pane's + // negotiated encoding instead of being forwarded verbatim. + app.route_client_input(b"\x1b[99;5u".to_vec()); + + assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from(vec![3])); + } + + #[tokio::test] + async fn route_client_input_splits_multi_event_payloads_before_forwarding() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = PaneRuntime::test_with_channel(80, 24); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.route_client_input(b"ab".to_vec()); + + assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"a")); + assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"b")); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn route_client_input_handles_mouse_events() { + let mut app = test_app(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + + // Send a mouse scroll-up event via SGR encoding. + let mouse_bytes = b"\x1b[<64;10;5M".to_vec(); + // This should not panic even though mouse handling is simplified + // in headless mode. + app.route_client_input(mouse_bytes); + // No assertions on specific behavior — just no panic. + } + + #[test] + fn route_client_input_advances_onboarding_modal() { + let mut app = test_app(); + app.state.mode = Mode::Onboarding; + app.state.onboarding_step = 0; + + app.route_client_input(b"\r".to_vec()); + + assert_eq!(app.state.onboarding_step, 1); + assert_eq!(app.state.mode, Mode::Onboarding); + } + + #[test] + fn route_client_input_closes_release_notes_modal() { + let mut app = test_app(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::ReleaseNotes; + app.state.release_notes = Some(release_notes_state()); + + app.route_client_input(b"\x1b".to_vec()); + + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app.state.release_notes.is_none()); + } + + #[test] + fn route_client_input_closes_settings_modal() { + let mut app = test_app(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Settings; + app.state.settings.original_theme = Some(app.state.theme_name.clone()); + app.state.settings.original_palette = Some(app.state.palette.clone()); + + app.route_client_input(b"\x1b".to_vec()); + + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[test] + fn route_client_input_updates_host_terminal_theme_from_osc_response() { + let mut app = test_app(); + + app.route_client_input(b"\x1b]11;#123456\x07".to_vec()); + + assert_eq!( + app.state.host_terminal_theme.background, + Some(crate::terminal_theme::RgbColor { + r: 0x12, + g: 0x34, + b: 0x56, + }) + ); + } + + #[test] + fn parse_raw_input_bytes_with_ranges_tracks_offsets() { + // Verify that the range-aware parser correctly tracks byte offsets + // for events within a multi-event input buffer. + let input = b"\x1b[Aa".to_vec(); // Up arrow + 'a' + let events = crate::raw_input::parse_raw_input_bytes_with_ranges(&input); + + assert_eq!(events.len(), 2, "should parse Up arrow and 'a'"); + // Up arrow: \x1b[A = 3 bytes starting at offset 0 + assert_eq!(events[0].start, 0); + assert_eq!(events[0].len, 3); + // 'a': 1 byte starting at offset 3 + assert_eq!(events[1].start, 3); + assert_eq!(events[1].len, 1); + + // Verify the raw bytes for each event are correct. + assert_eq!( + &input[events[0].start..events[0].start + events[0].len], + b"\x1b[A" + ); + assert_eq!( + &input[events[1].start..events[1].start + events[1].len], + b"a" + ); + } } diff --git a/src/app/runtime.rs b/src/app/runtime.rs new file mode 100644 index 00000000..d82f6440 --- /dev/null +++ b/src/app/runtime.rs @@ -0,0 +1,261 @@ +use std::time::Instant; + +use crossterm::terminal; + +use super::{ + auto_updates_enabled, repeat_key_identity, App, Mode, ANIMATION_INTERVAL, + AUTO_UPDATE_CHECK_INTERVAL, GIT_REMOTE_STATUS_REFRESH_INTERVAL, MIN_RENDER_INTERVAL, + RESIZE_POLL_INTERVAL, +}; +use crate::workspace::Workspace; + +impl App { + pub(crate) fn drain_api_requests(&mut self) -> bool { + let mut changed = false; + while let Ok(msg) = self.api_rx.try_recv() { + changed |= self.handle_api_request_message(msg); + } + changed + } + + pub(super) fn handle_api_request_message( + &mut self, + msg: crate::api::ApiRequestMessage, + ) -> bool { + let changed = crate::api::request_changes_ui(&msg.request); + let response = self.handle_api_request(msg.request); + let _ = msg.respond_to.send(response); + changed + } + + pub(super) async fn handle_raw_input_batch( + &mut self, + first: crate::raw_input::RawInputEvent, + ) -> bool { + let mut changed = self.handle_raw_input_event(first).await; + + while let Some(rx) = self.input_rx.as_mut() { + match rx.try_recv() { + Ok(event) => changed |= self.handle_raw_input_event(event).await, + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + self.input_rx = None; + break; + } + } + } + + changed + } + + pub(super) async fn handle_raw_input_event( + &mut self, + event: crate::raw_input::RawInputEvent, + ) -> bool { + match event { + crate::raw_input::RawInputEvent::Key(key) => { + let key_id = repeat_key_identity(&key); + match key.kind { + crossterm::event::KeyEventKind::Press => { + if self.state.mode == Mode::Terminal { + self.suppressed_repeat_keys.remove(&key_id); + } else { + self.suppressed_repeat_keys.insert(key_id); + } + self.handle_key(key).await; + true + } + crossterm::event::KeyEventKind::Repeat => { + if self.state.mode == Mode::Terminal + && !self.suppressed_repeat_keys.contains(&key_id) + { + self.handle_key(key).await; + true + } else { + false + } + } + crossterm::event::KeyEventKind::Release => { + self.suppressed_repeat_keys.remove(&key_id); + false + } + } + } + crate::raw_input::RawInputEvent::Paste(text) => { + self.handle_paste(text).await; + true + } + crate::raw_input::RawInputEvent::Mouse(mouse) => { + self.handle_mouse(mouse); + true + } + crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { + self.update_host_terminal_theme(kind, color) + } + crate::raw_input::RawInputEvent::Unsupported => false, + } + } + + fn handle_resize_poll(&mut self) -> bool { + let Ok(size) = terminal::size() else { + return false; + }; + if self.last_terminal_size != Some(size) { + self.last_terminal_size = Some(size); + return true; + } + false + } + + pub(crate) fn handle_scheduled_tasks(&mut self, now: Instant) -> bool { + let mut changed = false; + + self.sync_animation_timer(now); + + if now >= self.next_resize_poll { + changed |= self.handle_resize_poll(); + self.next_resize_poll = now + RESIZE_POLL_INTERVAL; + } + + if self + .config_diagnostic_deadline + .is_some_and(|deadline| now >= deadline) + { + self.config_diagnostic_deadline = None; + self.state.config_diagnostic = None; + changed = true; + } + + if self.toast_deadline.is_some_and(|deadline| now >= deadline) { + self.toast_deadline = None; + self.state.toast = None; + changed = true; + } + + if self + .next_animation_tick + .is_some_and(|deadline| now >= deadline) + { + self.state.spinner_tick = self.state.spinner_tick.wrapping_add(1); + self.next_animation_tick = Some(now + ANIMATION_INTERVAL); + changed = true; + } + + if self + .git_refresh_deadline() + .is_some_and(|deadline| now >= deadline) + { + for ws in &mut self.state.workspaces { + ws.refresh_git_ahead_behind(); + } + self.last_git_remote_status_refresh = now; + changed = true; + } + + if self + .next_auto_update_check + .is_some_and(|deadline| now >= deadline) + { + self.run_auto_update_check(); + } + + if self + .session_save_deadline + .is_some_and(|deadline| now >= deadline) + { + self.save_session_now(); + } + + self.sync_animation_timer(now); + changed + } + + pub(crate) fn sync_animation_timer(&mut self, now: Instant) { + if self.agent_panel_has_animation() { + self.next_animation_tick + .get_or_insert(now + ANIMATION_INTERVAL); + } else { + self.next_animation_tick = None; + } + } + + fn agent_panel_has_animation(&self) -> bool { + match self.state.agent_panel_scope { + crate::app::state::AgentPanelScope::CurrentWorkspace => self + .state + .active + .and_then(|idx| self.state.workspaces.get(idx)) + .is_some_and(Workspace::has_working_pane), + crate::app::state::AgentPanelScope::AllWorkspaces => self + .state + .workspaces + .iter() + .any(Workspace::has_working_pane), + } + } + + pub(crate) fn can_render_now(&self, now: Instant) -> bool { + match self.last_render_at { + Some(last_render_at) => now.duration_since(last_render_at) >= MIN_RENDER_INTERVAL, + None => true, + } + } + + pub(crate) fn run_auto_update_check(&mut self) { + if !auto_updates_enabled(self.no_session) { + self.next_auto_update_check = None; + return; + } + + self.next_auto_update_check = self + .state + .update_available + .is_none() + .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL); + + if self.state.update_available.is_some() { + return; + } + + let update_tx = self.event_tx.clone(); + std::thread::spawn(move || crate::update::auto_update(update_tx)); + } + + pub(crate) fn git_refresh_deadline(&self) -> Option<Instant> { + (!self.state.workspaces.is_empty()) + .then_some(self.last_git_remote_status_refresh + GIT_REMOTE_STATUS_REFRESH_INTERVAL) + } + + pub(crate) fn next_loop_deadline(&self, now: Instant, needs_render: bool) -> Option<Instant> { + let render_deadline = if needs_render { + self.last_render_at + .map(|last_render_at| last_render_at + MIN_RENDER_INTERVAL) + .filter(|deadline| *deadline > now) + } else { + None + }; + + [ + Some(self.next_resize_poll), + self.config_diagnostic_deadline, + self.toast_deadline, + self.next_animation_tick, + self.git_refresh_deadline(), + self.next_auto_update_check, + self.session_save_deadline, + render_deadline, + ] + .into_iter() + .flatten() + .min() + } + + pub(crate) fn drain_internal_events(&mut self) -> bool { + let mut had_event = false; + while let Ok(ev) = self.event_rx.try_recv() { + had_event = true; + self.handle_internal_event(ev); + } + had_event + } +} diff --git a/src/app/runtime_commands.rs b/src/app/runtime_commands.rs deleted file mode 100644 index ad9b85bc..00000000 --- a/src/app/runtime_commands.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! Runtime command launching and overlay pane lifecycle helpers for [`App`]. - -use std::process::{Command, Stdio}; - -use ratatui::layout::Direction; - -use super::{App, Mode}; - -#[derive(Debug, Clone, Copy)] -pub(super) struct OverlayPaneState { - ws_idx: usize, - tab_idx: usize, - previous_focus: crate::layout::PaneId, - previous_zoomed: bool, -} - -impl App { - pub(super) fn launch_custom_command(&mut self, binding: crate::config::CustomCommandKeybind) { - let previous_toast = self.state.toast.clone(); - let result = match binding.action { - crate::config::CustomCommandAction::Shell => self.spawn_custom_command(&binding), - crate::config::CustomCommandAction::Pane => self.spawn_pane_command(&binding.command), - }; - match result { - Ok(()) => { - if self.state.active.is_some() { - self.state.mode = Mode::Terminal; - } - } - Err(err) => { - self.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "custom command failed".to_string(), - context: err.to_string(), - }); - self.sync_toast_deadline(previous_toast); - } - } - } - - fn custom_command_env(&self) -> (Vec<(String, String)>, Option<std::path::PathBuf>) { - let mut env = vec![( - crate::api::SOCKET_PATH_ENV_VAR.to_string(), - crate::api::socket_path().display().to_string(), - )]; - if let Ok(current_exe) = std::env::current_exe() { - env.push(( - "HERDR_BIN_PATH".to_string(), - current_exe.display().to_string(), - )); - } - - let mut cwd = None; - if let Some(ws_idx) = self.state.active { - env.push(( - "HERDR_ACTIVE_WORKSPACE_ID".to_string(), - self.public_workspace_id(ws_idx), - )); - if let Some(workspace) = self.state.workspaces.get(ws_idx) { - let tab_idx = workspace.active_tab_index(); - if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) { - env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id)); - } - if let Some(pane_id) = workspace.focused_pane_id() { - if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { - env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id)); - } - if let Some(pane_cwd) = workspace - .active_tab() - .and_then(|tab| tab.cwd_for_pane(pane_id)) - { - env.push(( - "HERDR_ACTIVE_PANE_CWD".to_string(), - pane_cwd.display().to_string(), - )); - if pane_cwd.is_dir() { - cwd = Some(pane_cwd); - } - } - } - } - } - (env, cwd) - } - - fn spawn_custom_command( - &self, - binding: &crate::config::CustomCommandKeybind, - ) -> std::io::Result<()> { - let mut command = Command::new("/bin/sh"); - command - .arg("-lc") - .arg(&binding.command) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - let (env, cwd) = self.custom_command_env(); - command.envs(env); - if let Some(cwd) = cwd { - command.current_dir(cwd); - } - command.spawn()?; - Ok(()) - } - - fn spawn_pane_command(&mut self, command: &str) -> std::io::Result<()> { - let Some(ws_idx) = self.state.active else { - return Err(std::io::Error::other("no active workspace")); - }; - let (rows, cols) = self.state.estimate_pane_size(); - let new_rows = rows.max(4); - let new_cols = cols.max(10); - let (env, _) = self.custom_command_env(); - - let ws = self - .state - .workspaces - .get_mut(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let tab_idx = ws.active_tab_index(); - let previous_focus = ws - .focused_pane_id() - .ok_or_else(|| std::io::Error::other("no focused pane"))?; - let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); - let cwd = ws - .active_tab() - .and_then(|tab| tab.cwd_for_pane(previous_focus)); - let new_pane_id = ws.split_focused_command( - Direction::Horizontal, - new_rows, - new_cols, - cwd, - command, - &env, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - )?; - ws.active_tab_mut() - .expect("workspace must have an active tab") - .layout - .focus_pane(new_pane_id); - ws.active_tab_mut() - .expect("workspace must have an active tab") - .zoomed = true; - self.overlay_panes.insert( - new_pane_id, - OverlayPaneState { - ws_idx, - tab_idx, - previous_focus, - previous_zoomed, - }, - ); - self.state.mode = Mode::Terminal; - Ok(()) - } - - pub(super) fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) { - let Some(ws) = self.state.workspaces.get_mut(overlay.ws_idx) else { - return; - }; - if overlay.tab_idx >= ws.tabs.len() { - return; - } - - ws.active_tab = overlay.tab_idx; - let tab = &mut ws.tabs[overlay.tab_idx]; - if tab.panes.contains_key(&overlay.previous_focus) { - tab.layout.focus_pane(overlay.previous_focus); - } - tab.zoomed = overlay.previous_zoomed; - - if self.state.active == Some(overlay.ws_idx) { - self.state.mode = Mode::Terminal; - } - } -} diff --git a/src/app/session.rs b/src/app/session.rs new file mode 100644 index 00000000..c075fd4c --- /dev/null +++ b/src/app/session.rs @@ -0,0 +1,41 @@ +use std::time::Instant; + +use super::{App, SESSION_SAVE_DEBOUNCE}; + +impl App { + pub(super) fn schedule_session_save(&mut self) { + if !self.no_session { + self.session_save_deadline = Some(Instant::now() + SESSION_SAVE_DEBOUNCE); + } + } + + pub(crate) fn sync_session_save_schedule(&mut self) { + if self.state.session_dirty { + self.state.session_dirty = false; + self.schedule_session_save(); + } + } + + pub(crate) fn save_session_now(&mut self) { + if self.no_session { + self.session_save_deadline = None; + return; + } + + if self.state.workspaces.is_empty() { + crate::persist::clear(); + } else { + let snap = crate::persist::capture( + &self.state.workspaces, + self.state.active, + self.state.selected, + self.state.agent_panel_scope, + self.state.sidebar_width, + self.state.sidebar_section_split, + ); + crate::persist::save(&snap); + } + + self.session_save_deadline = None; + } +} diff --git a/src/app/state.rs b/src/app/state.rs index b1e69098..67f89606 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -586,9 +586,17 @@ pub struct AppState { pub selected: usize, pub mode: Mode, pub should_quit: bool, + /// In persistence mode, client quit actions detach instead of stopping the server. + pub quit_detaches: bool, + /// Set when the current client should detach from the persistent session. + /// The server's event loop checks this and handles client detach. + pub detach_requested: bool, pub request_new_workspace: bool, pub request_new_tab: bool, pub request_reload_keybinds: bool, + /// Set when UI interaction requested a clipboard write that must be + /// handled by the outer App/event loop instead of directly from AppState. + pub request_clipboard_write: Option<Vec<u8>>, pub creating_new_tab: bool, pub requested_new_tab_name: Option<String>, pub request_complete_onboarding: bool, @@ -657,6 +665,10 @@ impl AppState { self.sound.enabled } + pub fn is_prefix(&self, key: &crossterm::event::KeyEvent) -> bool { + key_matches(key, self.prefix_code, 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) @@ -666,7 +678,6 @@ impl AppState { } } -#[cfg(test)] pub fn key_matches( key: &crossterm::event::KeyEvent, expected_code: KeyCode, @@ -700,9 +711,12 @@ impl AppState { selected: 0, mode: Mode::Navigate, should_quit: false, + quit_detaches: false, + detach_requested: false, request_new_workspace: false, request_new_tab: false, request_reload_keybinds: false, + request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, request_complete_onboarding: false, @@ -759,8 +773,10 @@ impl AppState { new_workspace_label: "n".into(), rename_workspace: (KeyCode::Char('n'), KeyModifiers::SHIFT), rename_workspace_label: "shift+n".into(), - close_workspace: (KeyCode::Char('d'), KeyModifiers::empty()), - close_workspace_label: "d".into(), + close_workspace: (KeyCode::Char('d'), KeyModifiers::SHIFT), + close_workspace_label: "shift+d".into(), + detach: None, + detach_label: None, previous_workspace: None, previous_workspace_label: None, next_workspace: None, diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs new file mode 100644 index 00000000..df47f6a3 --- /dev/null +++ b/src/app/theme_sync.rs @@ -0,0 +1,51 @@ +use std::sync::atomic::Ordering; + +use super::App; + +impl App { + pub(super) fn query_host_terminal_theme(&self) { + use std::io::Write; + + let _ = std::io::stdout() + .write_all(crate::terminal_theme::HOST_COLOR_QUERY_SEQUENCE.as_bytes()); + let _ = std::io::stdout().flush(); + } + + pub(super) fn update_host_terminal_theme( + &mut self, + kind: crate::terminal_theme::DefaultColorKind, + color: crate::terminal_theme::RgbColor, + ) -> bool { + let next_theme = self.state.host_terminal_theme.with_color(kind, color); + self.set_host_terminal_theme(next_theme) + } + + pub(crate) fn set_host_terminal_theme( + &mut self, + theme: crate::terminal_theme::TerminalTheme, + ) -> bool { + if theme.is_empty() || theme == self.state.host_terminal_theme { + return false; + } + self.state.host_terminal_theme = theme; + self.apply_host_terminal_theme_to_panes(); + true + } + + fn apply_host_terminal_theme_to_panes(&self) { + if self.state.host_terminal_theme.is_empty() { + return; + } + + for workspace in &self.state.workspaces { + for tab in &workspace.tabs { + for runtime in tab.runtimes.values() { + runtime.apply_host_terminal_theme(self.state.host_terminal_theme); + } + } + } + + self.render_dirty.store(true, Ordering::Release); + self.render_notify.notify_one(); + } +} |
