diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-21 20:19:54 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-21 20:22:57 +0300 |
| commit | 866aaf740e10fcfce169aaf81380ea0257fbe272 (patch) | |
| tree | f815392b7c6b24a7c0de14e655bd635656383813 /src/app/input | |
| parent | ca579c402d5668560ca13059f82c03178be18b2a (diff) | |
refactor: separate server client boundaries
Diffstat (limited to 'src/app/input')
| -rw-r--r-- | src/app/input/mod.rs | 33 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 19 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 194 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 68 | ||||
| -rw-r--r-- | src/app/input/selection.rs | 72 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 84 |
6 files changed, 339 insertions, 131 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 245f935b..7c2bbe03 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -70,7 +70,13 @@ impl App { } Mode::Resize => handle_resize_key(&mut self.state, key), Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key_event), - Mode::ContextMenu => handle_context_menu_key(&mut self.state, key_event), + Mode::ContextMenu => { + handle_context_menu_key( + &mut self.state, + &mut self.terminal_runtimes, + key_event, + ); + } Mode::Settings => self.handle_settings_key(key_event), Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event), Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key_event), @@ -85,7 +91,10 @@ impl App { return; } if let Some(ws_idx) = self.state.active { - if let Some(rt) = self.state.focused_runtime_in_workspace(ws_idx) { + if let Some(rt) = self + .state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + { let _ = rt.send_paste(text).await; } } @@ -183,7 +192,7 @@ impl App { let previous_agent_panel_scope = self.state.agent_panel_scope; let previous_settings_section = self.state.settings.section; - if let Some(action) = self.state.handle_mouse(mouse) { + if let Some(action) = self.state.handle_mouse(&mut self.terminal_runtimes, mouse) { match action { SettingsAction::SaveTheme(name) => self.save_theme(&name), SettingsAction::SaveSound(enabled) => self.save_sound(enabled), @@ -232,7 +241,11 @@ impl App { // 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) { + pub(crate) fn split_pane( + &mut self, + terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, + 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 @@ -245,11 +258,7 @@ impl AppState { .and_then(|i| self.workspaces.get(i)) .and_then(|ws| { let tab = ws.active_tab()?; - tab.cwd_for_pane( - tab.layout.focused(), - &self.terminals, - &self.terminal_runtimes, - ) + tab.cwd_for_pane(tab.layout.focused(), &self.terminals, terminal_runtimes) }); if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { @@ -263,8 +272,7 @@ impl AppState { &self.default_shell, ) { let new_id = new_pane.pane_id; - self.terminal_runtimes - .insert(new_pane.terminal.id.clone(), new_pane.runtime); + terminal_runtimes.insert(new_pane.terminal.id.clone(), new_pane.runtime); self.terminals .insert(new_pane.terminal.id.clone(), new_pane.terminal); ws.layout.focus_pane(new_id); @@ -332,10 +340,11 @@ fn numbered_lines_bytes(count: usize) -> Vec<u8> { #[cfg(test)] fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot { + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); crate::persist::capture( &state.workspaces, &state.terminals, - &state.terminal_runtimes, + &terminal_runtimes, state.active, state.selected, state.agent_panel_scope, diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 084f44a9..868ee3d3 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -498,7 +498,12 @@ pub(crate) fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) { } } -pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx: usize) { +pub(super) fn apply_context_menu_action( + state: &mut AppState, + terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, + menu: ContextMenuState, + idx: usize, +) { let item = menu.items().get(idx).copied(); match (menu.kind, item) { (ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => { @@ -554,11 +559,11 @@ pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuS state.mode = Mode::Terminal; } (ContextMenuKind::Pane { .. }, Some("Split vertical")) => { - state.split_pane(Direction::Horizontal); + state.split_pane(terminal_runtimes, Direction::Horizontal); state.mode = Mode::Terminal; } (ContextMenuKind::Pane { .. }, Some("Split horizontal")) => { - state.split_pane(Direction::Vertical); + state.split_pane(terminal_runtimes, Direction::Vertical); state.mode = Mode::Terminal; } (ContextMenuKind::Pane { .. }, Some("Zoom")) => { @@ -577,7 +582,11 @@ pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuS } } -pub(crate) fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) { +pub(crate) fn handle_context_menu_key( + state: &mut AppState, + terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, + key: KeyEvent, +) { match key.code { KeyCode::Esc => { state.context_menu = None; @@ -596,7 +605,7 @@ pub(crate) fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) { KeyCode::Enter => { if let Some(menu) = state.context_menu.take() { let idx = menu.list.highlighted; - apply_context_menu_action(state, menu, idx); + apply_context_menu_action(state, terminal_runtimes, menu, idx); } } _ => {} diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index e3f13d77..4d49a917 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -10,6 +10,7 @@ use crate::{ }, layout::{PaneInfo, SplitBorder}, selection::Selection, + terminal::TerminalRuntimeRegistry, }; #[cfg(test)] @@ -25,7 +26,11 @@ use super::{ }; impl AppState { - pub(crate) fn handle_pane_mouse_only(&mut self, mouse: MouseEvent) { + pub(crate) fn handle_pane_mouse_only( + &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, + mouse: MouseEvent, + ) { if self.mode != Mode::Terminal { return; } @@ -38,16 +43,20 @@ impl AppState { | MouseEventKind::ScrollDown | MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => { - self.forward_pane_reported_wheel(&info, mouse); + self.forward_pane_reported_wheel(terminal_runtimes, &info, mouse); } MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { - self.forward_pane_mouse_button(&info, mouse); + self.forward_pane_mouse_button(terminal_runtimes, &info, mouse); } MouseEventKind::Moved => {} } } - pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> { + pub(super) fn handle_mouse( + &mut self, + terminal_runtimes: &mut TerminalRuntimeRegistry, + mouse: MouseEvent, + ) -> Option<SettingsAction> { if self.mode == Mode::Onboarding { self.handle_onboarding_mouse(mouse); return None; @@ -187,7 +196,7 @@ impl AppState { 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); + apply_context_menu_action(self, terminal_runtimes, menu, idx); } else { leave_modal(self); } @@ -224,7 +233,7 @@ impl AppState { } if let Some((pane_id, target)) = - self.scrollbar_target_at(mouse.column, mouse.row) + self.scrollbar_target_at(terminal_runtimes, mouse.column, mouse.row) { self.focus_pane(pane_id); match target { @@ -237,7 +246,11 @@ impl AppState { }); } ScrollbarClickTarget::Track { offset_from_bottom } => { - self.set_pane_scroll_offset(pane_id, offset_from_bottom); + self.set_pane_scroll_offset( + terminal_runtimes, + pane_id, + offset_from_bottom, + ); } } if self.mode != Mode::Terminal { @@ -370,7 +383,7 @@ impl AppState { self.mode = Mode::Terminal; } - if self.forward_pane_mouse_button(&info, mouse) { + if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { self.selection = None; self.selection_autoscroll = None; return None; @@ -384,7 +397,7 @@ impl AppState { info.id, row, col, - self.pane_scroll_metrics(info.id), + self.pane_scroll_metrics(terminal_runtimes, info.id), )); } else if let Some(info) = self.view.pane_infos.iter().find(|p| { mouse.column >= p.rect.x @@ -402,13 +415,13 @@ impl AppState { MouseEventKind::Drag(MouseButton::Left) => { if self.selection.is_some() { - self.update_selection_drag(mouse.column, mouse.row); + self.update_selection_drag(terminal_runtimes, mouse.column, mouse.row); return None; } if self.drag.is_none() { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - if self.forward_pane_mouse_button(&info, mouse) { + if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { self.selection = None; self.selection_autoscroll = None; return None; @@ -504,11 +517,16 @@ impl AppState { grab_row_offset, } => { if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row( + terminal_runtimes, *pane_id, mouse.row, *grab_row_offset, ) { - self.set_pane_scroll_offset(*pane_id, offset_from_bottom); + self.set_pane_scroll_offset( + terminal_runtimes, + *pane_id, + offset_from_bottom, + ); } } DragTarget::SidebarDivider => { @@ -535,14 +553,14 @@ impl AppState { self.selection = None; self.selection_autoscroll = None; } else { - self.copy_selection(); + self.copy_selection(terminal_runtimes); } return None; } if self.drag.is_none() { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - if self.forward_pane_mouse_button(&info, mouse) { + if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { self.selection = None; self.selection_autoscroll = None; self.workspace_press = None; @@ -597,7 +615,7 @@ impl AppState { self.selection = None; self.selection_autoscroll = None; } else { - self.copy_selection(); + self.copy_selection(terminal_runtimes); } } } @@ -607,7 +625,7 @@ impl AppState { if !in_sidebar => { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - let _ = self.forward_pane_mouse_button(&info, mouse); + let _ = self.forward_pane_mouse_button(terminal_runtimes, &info, mouse); } } @@ -622,12 +640,12 @@ impl AppState { } MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - if !in_sidebar && self.scroll_selection_with_wheel(mouse) => {} + if !in_sidebar && self.scroll_selection_with_wheel(terminal_runtimes, mouse) => {} MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => { self.selection = None; self.selection_autoscroll = None; - self.handle_terminal_wheel(mouse); + self.handle_terminal_wheel(terminal_runtimes, mouse); } MouseEventKind::ScrollUp if in_sidebar => { @@ -1070,17 +1088,29 @@ impl AppState { self.mode = Mode::Terminal; } - pub(crate) fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) { + pub(crate) fn scroll_pane_up( + &self, + terminal_runtimes: &TerminalRuntimeRegistry, + pane_id: crate::layout::PaneId, + lines: usize, + ) { if let Some(ws_idx) = self.active { - if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { + if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) + { rt.scroll_up(lines); } } } - pub(crate) fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) { + pub(crate) fn scroll_pane_down( + &self, + terminal_runtimes: &TerminalRuntimeRegistry, + pane_id: crate::layout::PaneId, + lines: usize, + ) { if let Some(ws_idx) = self.active { - if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { + if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) + { rt.scroll_down(lines); } } @@ -1088,24 +1118,33 @@ impl AppState { pub(crate) fn pane_scroll_metrics( &self, + terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, ) -> Option<crate::pane::ScrollMetrics> { self.active - .and_then(|i| self.runtime_for_pane_in_workspace(i, pane_id)) + .and_then(|i| self.runtime_for_pane_in_workspace(terminal_runtimes, i, pane_id)) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) } - pub(super) fn handle_terminal_wheel(&mut self, mouse: MouseEvent) { + pub(super) fn handle_terminal_wheel( + &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, + 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) { + if self.forward_pane_wheel(terminal_runtimes, &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), + MouseEventKind::ScrollUp => { + self.scroll_pane_up(terminal_runtimes, info.id, LINES_PER_NOTCH) + } + MouseEventKind::ScrollDown => { + self.scroll_pane_down(terminal_runtimes, info.id, LINES_PER_NOTCH) + } _ => {} } return; @@ -1114,15 +1153,19 @@ impl AppState { 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), + MouseEventKind::ScrollUp => { + self.scroll_pane_up(terminal_runtimes, info.id, LINES_PER_NOTCH) + } + MouseEventKind::ScrollDown => { + self.scroll_pane_down(terminal_runtimes, info.id, LINES_PER_NOTCH) + } _ => {} } return; } if let Some(ws_idx) = self.active { - if let Some(rt) = self.focused_runtime_in_workspace(ws_idx) { + if let Some(rt) = self.focused_runtime_in_workspace(terminal_runtimes, ws_idx) { match mouse.kind { MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH), MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH), @@ -1132,11 +1175,17 @@ impl AppState { } } - pub(super) fn forward_pane_mouse_button(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { + pub(super) fn forward_pane_mouse_button( + &self, + terminal_runtimes: &TerminalRuntimeRegistry, + info: &PaneInfo, + mouse: MouseEvent, + ) -> bool { let Some(ws_idx) = self.active else { return false; }; - let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else { + let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) + else { return false; }; let column = mouse.column.saturating_sub(info.inner_rect.x); @@ -1151,11 +1200,17 @@ impl AppState { true } - fn forward_pane_reported_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { + fn forward_pane_reported_wheel( + &self, + terminal_runtimes: &TerminalRuntimeRegistry, + info: &PaneInfo, + mouse: MouseEvent, + ) -> bool { let Some(ws_idx) = self.active else { return false; }; - let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else { + let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) + else { return false; }; if !rt @@ -1177,11 +1232,17 @@ impl AppState { true } - pub(super) fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { + pub(super) fn forward_pane_wheel( + &self, + terminal_runtimes: &TerminalRuntimeRegistry, + info: &PaneInfo, + mouse: MouseEvent, + ) -> bool { let Some(ws_idx) = self.active else { return false; }; - let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else { + let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) + else { return false; }; match rt.wheel_routing() { @@ -1215,11 +1276,13 @@ impl AppState { pub(super) fn set_pane_scroll_offset( &self, + terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, offset_from_bottom: usize, ) { if let Some(ws_idx) = self.active { - if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { + if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) + { rt.set_scroll_offset_from_bottom(offset_from_bottom); } } @@ -1227,6 +1290,7 @@ impl AppState { pub(super) fn scrollbar_target_at( &self, + terminal_runtimes: &TerminalRuntimeRegistry, col: u16, row: u16, ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> { @@ -1239,7 +1303,7 @@ impl AppState { && row < track.y + track.height }) })?; - let rt = self.runtime_for_pane_in_workspace(ws_idx, info.id)?; + let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?; let metrics = rt.scroll_metrics()?; if metrics.max_offset_from_bottom == 0 { return None; @@ -1259,6 +1323,7 @@ impl AppState { pub(super) fn scrollbar_offset_for_pane_row( &self, + terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, row: u16, grab_row_offset: u16, @@ -1270,7 +1335,7 @@ impl AppState { .iter() .find(|info| info.id == pane_id)?; let track = crate::ui::pane_scrollbar_rect(info)?; - let rt = self.runtime_for_pane_in_workspace(ws_idx, pane_id)?; + let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?; let metrics = rt.scroll_metrics()?; if metrics.max_offset_from_bottom == 0 { return None; @@ -1435,6 +1500,7 @@ mod tests { app.state.mode = Mode::ContextMenu; handle_context_menu_key( &mut app.state, + &mut app.terminal_runtimes, KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), ); assert_eq!(app.state.mode, Mode::ConfirmClose); @@ -1458,6 +1524,56 @@ mod tests { assert_eq!(app.state.workspaces[0].display_name(), "a"); } + #[tokio::test] + async fn keyboard_context_menu_split_keeps_new_runtime() { + let mut app = app_for_mouse_test(); + app.state.default_shell = "/usr/bin/true".into(); + let (workspace, terminal, runtime) = Workspace::new( + std::env::current_dir().unwrap_or_else(|_| "/".into()), + 24, + 80, + app.state.pane_scrollback_limit_bytes, + app.state.host_terminal_theme, + &app.state.default_shell, + app.event_tx.clone(), + app.render_notify.clone(), + app.render_dirty.clone(), + ) + .expect("workspace should spawn"); + app.state.workspaces = vec![workspace]; + app.terminal_runtimes.insert(terminal.id.clone(), runtime); + app.state.terminals.insert(terminal.id.clone(), terminal); + app.state.active = Some(0); + app.state.selected = 0; + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let runtime_count = app.terminal_runtimes.len(); + app.state.context_menu = Some(ContextMenuState { + kind: ContextMenuKind::Pane { + pane_id, + has_manual_label: false, + }, + x: 2, + y: 2, + list: MenuListState::new(1), + }); + app.state.mode = Mode::ContextMenu; + + handle_context_menu_key( + &mut app.state, + &mut app.terminal_runtimes, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(app.state.mode, Mode::Terminal); + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); + assert_eq!(app.terminal_runtimes.len(), runtime_count + 1); + + let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); + for (_terminal_id, runtime) in runtimes { + runtime.shutdown(); + } + } + #[test] fn dragging_pane_split_updates_captured_layout_ratio() { let mut app = app_for_mouse_test(); diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 17180fed..9256bbbb 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -16,6 +16,7 @@ use crate::{ }, input::TerminalKey, layout::NavDirection, + terminal::TerminalRuntimeRegistry, }; pub(crate) fn terminal_direct_navigation_action( @@ -55,7 +56,12 @@ impl App { self.launch_focused_scrollback_editor(); finish_action_context(&mut self.state, ActionContext::Prefix, previous_mode); } else { - execute_navigate_action_in_context(&mut self.state, action, ActionContext::Prefix); + execute_navigate_action_in_context( + &mut self.state, + &mut self.terminal_runtimes, + action, + ActionContext::Prefix, + ); } self.selection_autoscroll_deadline = None; return; @@ -88,6 +94,7 @@ impl App { } else { execute_navigate_action_in_context( &mut self.state, + &mut self.terminal_runtimes, action, ActionContext::Navigate, ); @@ -105,7 +112,10 @@ impl App { let Some(ws_idx) = self.state.active else { return false; }; - let Some(rt) = self.state.focused_runtime_in_workspace(ws_idx) else { + let Some(rt) = self + .state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + else { return false; }; @@ -174,11 +184,7 @@ impl App { env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id)); } if let Some(pane_cwd) = workspace.active_tab().and_then(|tab| { - tab.cwd_for_pane( - pane_id, - &self.state.terminals, - &self.state.terminal_runtimes, - ) + tab.cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) }) { env.push(( "HERDR_ACTIVE_PANE_CWD".to_string(), @@ -245,7 +251,7 @@ impl App { .ok_or_else(|| std::io::Error::other("no focused pane"))?; let scrollback = self .state - .runtime_for_pane_in_workspace(ws_idx, pane_id) + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) .ok_or_else(|| std::io::Error::other("focused pane has no scrollback runtime"))? .recent_text(usize::MAX); @@ -298,7 +304,7 @@ impl App { tab.cwd_for_pane( previous_focus, &self.state.terminals, - &self.state.terminal_runtimes, + &self.terminal_runtimes, ) }); let new_pane = ws.split_focused_command( @@ -312,8 +318,7 @@ impl App { self.state.host_terminal_theme, )?; let new_pane_id = new_pane.pane_id; - self.state - .terminal_runtimes + self.terminal_runtimes .insert(new_pane.terminal.id.clone(), new_pane.runtime); self.state .terminals @@ -428,6 +433,7 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) #[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); state.update_dismissed = true; let terminal_key = TerminalKey::from(key); @@ -441,7 +447,12 @@ pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { } if let Some(action) = action_for_key(state, terminal_key, BindingDispatch::Prefix) { - execute_navigate_action_in_context(state, action, ActionContext::Navigate); + execute_navigate_action_in_context( + state, + &mut terminal_runtimes, + action, + ActionContext::Navigate, + ); } } @@ -587,11 +598,18 @@ fn action_for_key( #[cfg(test)] pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { - execute_navigate_action_in_context(state, action, ActionContext::Navigate); + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); + execute_navigate_action_in_context( + state, + &mut terminal_runtimes, + action, + ActionContext::Navigate, + ); } pub(super) fn execute_navigate_action_in_context( state: &mut AppState, + terminal_runtimes: &mut TerminalRuntimeRegistry, action: NavigateAction, context: ActionContext, ) { @@ -694,11 +712,11 @@ pub(super) fn execute_navigate_action_in_context( NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up), NavigateAction::FocusPaneRight => state.navigate_pane(NavDirection::Right), NavigateAction::SplitVertical => { - state.split_pane(Direction::Horizontal); + state.split_pane(terminal_runtimes, Direction::Horizontal); leave_navigate_mode(state); } NavigateAction::SplitHorizontal => { - state.split_pane(Direction::Vertical); + state.split_pane(terminal_runtimes, Direction::Vertical); leave_navigate_mode(state); } NavigateAction::ClosePane => { @@ -1192,10 +1210,12 @@ mod tests { #[test] fn empty_state_new_tab_is_no_op() { let mut state = crate::app::state::AppState::test_new(); + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); state.mode = Mode::Prefix; execute_navigate_action_in_context( &mut state, + &mut terminal_runtimes, NavigateAction::NewTab, ActionContext::Prefix, ); @@ -1277,9 +1297,7 @@ mod tests { ) .expect("workspace should spawn"); app.state.workspaces = vec![workspace]; - app.state - .terminal_runtimes - .insert(terminal.id.clone(), runtime); + app.terminal_runtimes.insert(terminal.id.clone(), runtime); app.state.terminals.insert(terminal.id.clone(), terminal); app.state.active = Some(0); app.state.selected = 0; @@ -1303,7 +1321,7 @@ mod tests { .await; assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); - assert_eq!(app.state.terminal_runtimes.len(), 2); + assert_eq!(app.terminal_runtimes.len(), 2); assert!(app.state.workspaces[0].tabs[0].zoomed); let _ = wait_for_file(&output_path); @@ -1321,6 +1339,11 @@ mod tests { assert!(!app.state.workspaces[0].tabs[0].zoomed); assert_eq!(app.state.mode, Mode::Terminal); let _ = std::fs::remove_file(output_path); + + let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); + for (_terminal_id, runtime) in runtimes { + runtime.shutdown(); + } } #[tokio::test] @@ -1337,7 +1360,12 @@ mod tests { let root_pane = workspace.tabs[0].root_pane; workspace.tabs[0].runtimes.insert( root_pane, - crate::pane::PaneRuntime::test_with_scrollback_bytes(20, 5, 4096, b"alpha\nbeta\n"), + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 4096, + b"alpha\nbeta\n", + ), ); app.state.workspaces = vec![workspace]; app.state.active = Some(0); diff --git a/src/app/input/selection.rs b/src/app/input/selection.rs index ad1dd33f..06de30b5 100644 --- a/src/app/input/selection.rs +++ b/src/app/input/selection.rs @@ -1,10 +1,14 @@ use crossterm::event::{MouseEvent, MouseEventKind}; -use crate::app::state::{AppState, SelectionAutoscroll, SelectionAutoscrollDirection}; +use crate::{ + app::state::{AppState, SelectionAutoscroll, SelectionAutoscrollDirection}, + terminal::TerminalRuntimeRegistry, +}; impl AppState { pub(crate) fn update_selection_cursor( &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, screen_col: u16, screen_row: u16, @@ -12,7 +16,7 @@ impl AppState { let Some(info) = self.pane_info_by_id(pane_id).cloned() else { return; }; - let metrics = self.pane_scroll_metrics(pane_id); + let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); if let Some(selection) = self.selection.as_mut() { selection.drag(screen_col, screen_row, info.inner_rect, metrics); } @@ -22,7 +26,12 @@ impl AppState { usize::from(distance).saturating_mul(3).clamp(3, 15) } - pub(super) fn update_selection_drag(&mut self, screen_col: u16, screen_row: u16) { + pub(super) fn update_selection_drag( + &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, + screen_col: u16, + screen_row: u16, + ) { let Some(pane_id) = self.selection.as_ref().map(|selection| selection.pane_id) else { return; }; @@ -44,14 +53,16 @@ impl AppState { // Anchor is stored in absolute row; for a simple screen // comparison, check whether the mouse is on a different // cell than the anchor's screen position. - let (ar, ac) = - s.anchor_screen_pos(info.inner_rect, self.pane_scroll_metrics(s.pane_id)); + let (ar, ac) = s.anchor_screen_pos( + info.inner_rect, + self.pane_scroll_metrics(terminal_runtimes, s.pane_id), + ); ar != screen_row || ac != screen_col }); let is_dragging = was_dragging || anchor_differs_from_mouse; // Advance the selection cursor. - self.update_selection_cursor(pane_id, screen_col, screen_row); + self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); // If the mouse is on a different cell than the anchor but drag() // didn't transition (cursor clamped to edge == anchor), force @@ -67,9 +78,13 @@ impl AppState { if screen_row < top { // Cursor above pane — immediate scroll + set autoscroll state if is_dragging { - self.scroll_pane_up(pane_id, Self::selection_edge_scroll_lines(top - screen_row)); + self.scroll_pane_up( + terminal_runtimes, + pane_id, + Self::selection_edge_scroll_lines(top - screen_row), + ); // Re-advance cursor after scroll so it reflects the new viewport position - self.update_selection_cursor(pane_id, screen_col, screen_row); + self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); self.selection_autoscroll = Some(SelectionAutoscroll { direction: SelectionAutoscrollDirection::Up, last_mouse_screen_col: screen_col, @@ -81,11 +96,12 @@ impl AppState { // Cursor below pane — immediate scroll + set autoscroll state if is_dragging { self.scroll_pane_down( + terminal_runtimes, pane_id, Self::selection_edge_scroll_lines(screen_row - bottom), ); // Re-advance cursor after scroll so it reflects the new viewport position - self.update_selection_cursor(pane_id, screen_col, screen_row); + self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); self.selection_autoscroll = Some(SelectionAutoscroll { direction: SelectionAutoscrollDirection::Down, last_mouse_screen_col: screen_col, @@ -123,7 +139,11 @@ impl AppState { } } - pub(super) fn scroll_selection_with_wheel(&mut self, mouse: MouseEvent) -> bool { + pub(super) fn scroll_selection_with_wheel( + &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, + mouse: MouseEvent, + ) -> bool { const LINES_PER_NOTCH: usize = 3; let Some(selection) = self.selection.as_ref() else { @@ -135,11 +155,15 @@ impl AppState { 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), + MouseEventKind::ScrollUp => { + self.scroll_pane_up(terminal_runtimes, pane_id, LINES_PER_NOTCH) + } + MouseEventKind::ScrollDown => { + self.scroll_pane_down(terminal_runtimes, pane_id, LINES_PER_NOTCH) + } _ => return false, } - self.update_selection_cursor(pane_id, mouse.column, mouse.row); + self.update_selection_cursor(terminal_runtimes, pane_id, mouse.column, mouse.row); true } } @@ -148,6 +172,7 @@ impl AppState { mod autoscroll_tests { use super::*; use crate::layout::PaneInfo; + use crate::terminal::TerminalRuntimeRegistry; use crate::workspace::Workspace; use ratatui::layout::Rect; @@ -188,7 +213,8 @@ mod autoscroll_tests { let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None); sel.drag(4, 5, Rect::new(0, 5, 80, 24), None); state.selection = Some(sel); - state.update_selection_drag(5, 4); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 5, 4); let autoscroll = state.selection_autoscroll.as_ref().unwrap(); assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up); } @@ -200,7 +226,8 @@ mod autoscroll_tests { let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None); sel.drag(0, 0, Rect::new(0, 0, 80, 24), None); state.selection = Some(sel); - state.update_selection_drag(0, 0); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 0, 0); let autoscroll = state.selection_autoscroll.as_ref().unwrap(); assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up); } @@ -211,7 +238,8 @@ mod autoscroll_tests { let (mut state, pane_id) = make_state_with_pane(); state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None)); // Same-cell drag on top edge row — still anchored - state.update_selection_drag(0, 0); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 0, 0); assert!(state.selection_autoscroll.is_none()); } @@ -222,7 +250,8 @@ mod autoscroll_tests { let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); sel.drag(23, 0, Rect::new(0, 0, 80, 24), None); state.selection = Some(sel); - state.update_selection_drag(0, 23); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 0, 23); let autoscroll = state.selection_autoscroll.as_ref().unwrap(); assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down); } @@ -234,7 +263,8 @@ mod autoscroll_tests { // Anchor at bottom edge row state.selection = Some(crate::selection::Selection::anchor(pane_id, 23, 0, None)); // Same-cell drag — still anchored - state.update_selection_drag(0, 23); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 0, 23); assert!(state.selection_autoscroll.is_none()); } @@ -246,7 +276,8 @@ mod autoscroll_tests { sel.drag(5, 5, Rect::new(0, 0, 80, 24), None); state.selection = Some(sel); // Drag cursor one row below the pane bottom - state.update_selection_drag(0, 24); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 0, 24); let autoscroll = state.selection_autoscroll.as_ref().unwrap(); assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down); } @@ -266,7 +297,8 @@ mod autoscroll_tests { inner_rect: Rect::new(0, 0, 80, 24), }); // Move cursor into safe zone (middle of pane, not on edge rows) - state.update_selection_drag(5, 12); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 5, 12); assert!(state.selection_autoscroll.is_none()); } } diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 41a62e82..eee5b863 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -47,6 +47,7 @@ impl App { } else { super::navigate::execute_navigate_action_in_context( &mut self.state, + &mut self.terminal_runtimes, action, super::navigate::ActionContext::Direct, ); @@ -88,7 +89,9 @@ impl App { let ws_idx = self.state.active?; let ws = self.state.workspaces.get(ws_idx)?; let pane_id = ws.focused_pane_id()?; - let rt = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)?; + let rt = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?; // Intercept plain PageUp/PageDown presses for pane scrollback when the // focused pane doesn't handle its own scrolling (e.g., a plain shell @@ -116,9 +119,11 @@ impl App { .unwrap_or(10) .max(1); if key_event.code == KeyCode::PageUp { - self.state.scroll_pane_up(pane_id, lines); + self.state + .scroll_pane_up(&self.terminal_runtimes, pane_id, lines); } else { - self.state.scroll_pane_down(pane_id, lines); + self.state + .scroll_pane_down(&self.terminal_runtimes, pane_id, lines); } debug!( code = ?key_event.code, @@ -223,7 +228,7 @@ mod tests { let start_metrics = app .state - .runtime_for_pane(pane_id) + .runtime_for_pane(&app.terminal_runtimes, pane_id) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("initial scroll metrics"); let start_row = info.inner_rect.y; @@ -242,7 +247,7 @@ mod tests { let end_metrics = app .state - .runtime_for_pane(pane_id) + .runtime_for_pane(&app.terminal_runtimes, pane_id) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after drag"); assert_eq!( @@ -329,7 +334,7 @@ mod tests { let start_metrics = app .state - .runtime_for_pane(pane_id) + .runtime_for_pane(&app.terminal_runtimes, pane_id) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("initial scroll metrics"); let top_row = info.inner_rect.y; @@ -340,7 +345,7 @@ mod tests { let end_metrics = app .state - .runtime_for_pane(pane_id) + .runtime_for_pane(&app.terminal_runtimes, pane_id) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after wheel"); assert_eq!( @@ -523,7 +528,12 @@ mod tests { let root_pane = workspace.tabs[0].root_pane; workspace.tabs[0].runtimes.insert( root_pane, - crate::pane::PaneRuntime::test_with_scrollback_bytes(20, 5, 4096, b"alpha\nbeta\n"), + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 4096, + b"alpha\nbeta\n", + ), ); app.state.workspaces = vec![workspace]; app.state.active = Some(0); @@ -602,6 +612,7 @@ mod tests { api_rx, crate::api::EventHub::default(), ); + app.state.default_shell = "/usr/bin/true".into(); let (workspace, terminal, runtime) = Workspace::new( std::env::current_dir().unwrap_or_else(|_| "/".into()), 24, @@ -615,9 +626,7 @@ mod tests { ) .expect("workspace should spawn"); app.state.workspaces = vec![workspace]; - app.state - .terminal_runtimes - .insert(terminal.id.clone(), runtime); + app.terminal_runtimes.insert(terminal.id.clone(), runtime); app.state.terminals.insert(terminal.id.clone(), terminal); app.state.active = Some(0); app.state.selected = 0; @@ -639,6 +648,11 @@ mod tests { assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); assert!(app.state.workspaces[0].tabs[0].zoomed); assert_eq!(app.state.mode, Mode::Terminal); + + let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); + for (_terminal_id, runtime) in runtimes { + runtime.shutdown(); + } } #[tokio::test] @@ -648,7 +662,7 @@ mod tests { let pane_id = ws.tabs[0].root_pane; let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 80, 24)); let info = pane_infos[0].clone(); - let (runtime, mut rx) = crate::pane::PaneRuntime::test_with_channel( + let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel( info.inner_rect.width, info.inner_rect.height, ); @@ -677,7 +691,7 @@ mod tests { let info = pane_infos[0].clone(); ws.tabs[0].runtimes.insert( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -693,8 +707,8 @@ mod tests { let start_metrics = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("initial scroll metrics"); assert_eq!(start_metrics.offset_from_bottom, 0); @@ -702,8 +716,8 @@ mod tests { let end_metrics = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageUp"); assert_eq!( end_metrics.offset_from_bottom, @@ -720,7 +734,7 @@ mod tests { let info = pane_infos[0].clone(); ws.tabs[0].runtimes.insert( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -737,8 +751,8 @@ mod tests { app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); let after_up = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageUp"); assert!(after_up.offset_from_bottom > 0); @@ -748,8 +762,8 @@ mod tests { )); let after_down = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageDown"); assert_eq!(after_down.offset_from_bottom, 0); } @@ -763,7 +777,7 @@ mod tests { let info = pane_infos[0].clone(); ws.tabs[0].runtimes.insert( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -780,8 +794,8 @@ mod tests { app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); let after_press = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageUp press"); assert_eq!( after_press.offset_from_bottom, @@ -795,8 +809,8 @@ mod tests { let after_release = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageUp release"); assert_eq!( after_release.offset_from_bottom, @@ -813,7 +827,7 @@ mod tests { let info = pane_infos[0].clone(); ws.tabs[0].runtimes.insert( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -831,8 +845,8 @@ mod tests { let metrics = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after modified PageUp"); assert_eq!(metrics.offset_from_bottom, 0); } @@ -848,7 +862,7 @@ mod tests { bytes.extend_from_slice(&numbered_lines_bytes(64)); ws.tabs[0].runtimes.insert( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -864,8 +878,8 @@ mod tests { let start_metrics = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("initial scroll metrics"); assert_eq!(start_metrics.offset_from_bottom, 0); @@ -873,8 +887,8 @@ mod tests { let end_metrics = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageUp"); // Forwarded to pane, so test runtime doesn't process it — scroll stays at bottom. assert_eq!(end_metrics.offset_from_bottom, 0); |
