aboutsummaryrefslogtreecommitdiff
path: root/src/app/input.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/input.rs')
-rw-r--r--src/app/input.rs5416
1 files changed, 0 insertions, 5416 deletions
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(&notes.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"
- );
- }
-}