1838 lines
66 KiB
Rust
1838 lines
66 KiB
Rust
use bytes::Bytes;
|
|
use crossterm::event::KeyCode;
|
|
use tracing::{debug, warn};
|
|
|
|
use crate::{
|
|
app::{App, InputSourceId, Mode, TerminalInputTarget},
|
|
input::TerminalKey,
|
|
};
|
|
|
|
struct PreparedPaneInput {
|
|
ws_idx: usize,
|
|
pane_id: crate::layout::PaneId,
|
|
target: TerminalInputTarget,
|
|
bytes: Bytes,
|
|
}
|
|
|
|
enum PreparedPopupInput {
|
|
NotOpen,
|
|
Consumed,
|
|
Bytes {
|
|
target: TerminalInputTarget,
|
|
bytes: Bytes,
|
|
},
|
|
}
|
|
|
|
fn is_modifier_only_key(code: &KeyCode) -> bool {
|
|
matches!(code, KeyCode::Modifier(_))
|
|
}
|
|
|
|
impl App {
|
|
#[cfg(test)]
|
|
pub(crate) fn handle_terminal_key_headless(
|
|
&mut self,
|
|
key: TerminalKey,
|
|
) -> Option<TerminalInputTarget> {
|
|
self.handle_terminal_key_headless_from(crate::app::LOCAL_INPUT_SOURCE, key)
|
|
}
|
|
|
|
pub(crate) fn handle_terminal_key_headless_from(
|
|
&mut self,
|
|
source_id: InputSourceId,
|
|
key: TerminalKey,
|
|
) -> Option<TerminalInputTarget> {
|
|
match self.prepare_popup_key_forward(key) {
|
|
PreparedPopupInput::NotOpen => {}
|
|
PreparedPopupInput::Consumed => return None,
|
|
PreparedPopupInput::Bytes { target, bytes } => {
|
|
let Some(runtime) = self.popup_runtime() else {
|
|
self.close_popup_pane();
|
|
return None;
|
|
};
|
|
return runtime.try_send_bytes(bytes).is_ok().then_some(target);
|
|
}
|
|
}
|
|
|
|
let input = self.prepare_terminal_key_forward(source_id, key)?;
|
|
let sent = self
|
|
.lookup_runtime_sender(input.ws_idx, input.pane_id)
|
|
.is_some_and(|runtime| runtime.try_send_bytes(input.bytes).is_ok());
|
|
sent.then_some(input.target)
|
|
}
|
|
|
|
fn prepare_terminal_key_forward(
|
|
&mut self,
|
|
source_id: InputSourceId,
|
|
key: TerminalKey,
|
|
) -> Option<PreparedPaneInput> {
|
|
let key_event = key.as_key_event();
|
|
if self.try_copy_retained_selection(source_id, key) {
|
|
return None;
|
|
}
|
|
|
|
self.state.clear_selection();
|
|
self.selection_autoscroll_deadline = None;
|
|
self.state.update_dismissed = true;
|
|
|
|
if let Some(action) = super::terminal_direct_non_indexed_navigation_action(&self.state, key)
|
|
{
|
|
debug!(
|
|
code = ?key_event.code,
|
|
modifiers = ?key_event.modifiers,
|
|
kind = ?key_event.kind,
|
|
action = ?action,
|
|
"intercepted terminal direct keybinding before forwarding to pane"
|
|
);
|
|
if action == super::navigate::NavigateAction::EditScrollback {
|
|
self.launch_focused_scrollback_editor();
|
|
} else {
|
|
self.execute_tui_navigate_action(action, super::navigate::ActionContext::Direct);
|
|
}
|
|
return None;
|
|
}
|
|
|
|
if let Some(binding) = super::navigate::command_for_key(
|
|
&self.state,
|
|
key,
|
|
super::navigate::BindingDispatch::Direct,
|
|
) {
|
|
debug!(
|
|
code = ?key_event.code,
|
|
modifiers = ?key_event.modifiers,
|
|
kind = ?key_event.kind,
|
|
command = %binding.label,
|
|
"intercepted terminal direct custom command before forwarding to pane"
|
|
);
|
|
self.launch_custom_command(binding, super::navigate::ActionContext::Direct);
|
|
return None;
|
|
}
|
|
|
|
if let Some(action) = super::terminal_direct_indexed_navigation_action(&self.state, key) {
|
|
debug!(
|
|
code = ?key_event.code,
|
|
modifiers = ?key_event.modifiers,
|
|
kind = ?key_event.kind,
|
|
action = ?action,
|
|
"intercepted terminal direct indexed keybinding before forwarding to pane"
|
|
);
|
|
self.execute_tui_navigate_action(action, super::navigate::ActionContext::Direct);
|
|
return None;
|
|
}
|
|
|
|
if self.state.is_prefix_key(key) {
|
|
self.state.mode = Mode::Prefix;
|
|
return None;
|
|
}
|
|
|
|
if is_modifier_only_key(&key_event.code) {
|
|
debug!(
|
|
code = ?key_event.code,
|
|
modifiers = ?key_event.modifiers,
|
|
kind = ?key_event.kind,
|
|
"dropping modifier-only terminal key event instead of forwarding it to pane"
|
|
);
|
|
return None;
|
|
}
|
|
|
|
let ws_idx = self.state.active?;
|
|
let ws = self.state.workspaces.get(ws_idx)?;
|
|
let pane_id = ws.focused_pane_id()?;
|
|
let terminal_id = ws.terminal_id(pane_id)?.clone();
|
|
let rt =
|
|
self.state
|
|
.runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?;
|
|
|
|
// Intercept plain PageUp/PageDown presses for pane scrollback only
|
|
// when the focused pane looks like a shell transcript. Normal-screen
|
|
// pagers such as `less -X` keep the primary screen but enter
|
|
// application cursor mode while they own special keys.
|
|
// Modified page keys are pane shortcuts, and release events should not
|
|
// produce a second host-scroll action.
|
|
// Only intercept when we know the pane state; if input_state is unknown,
|
|
// fail-open and forward the key to the pane.
|
|
if matches!(key_event.code, KeyCode::PageUp | KeyCode::PageDown)
|
|
&& key_event.modifiers.is_empty()
|
|
{
|
|
if let Some(input_state) = rt.input_state() {
|
|
if input_state.plain_page_keys_use_host_scrollback() {
|
|
if key_event.kind == crossterm::event::KeyEventKind::Release {
|
|
return None;
|
|
}
|
|
if matches!(
|
|
key_event.kind,
|
|
crossterm::event::KeyEventKind::Press
|
|
| crossterm::event::KeyEventKind::Repeat
|
|
) {
|
|
let lines = self
|
|
.state
|
|
.pane_info_by_id(pane_id)
|
|
.map(|info| info.inner_rect.height as usize)
|
|
.unwrap_or(10)
|
|
.max(1);
|
|
if key_event.code == KeyCode::PageUp {
|
|
self.state
|
|
.scroll_pane_up(&self.terminal_runtimes, pane_id, lines);
|
|
} else {
|
|
self.state
|
|
.scroll_pane_down(&self.terminal_runtimes, pane_id, lines);
|
|
}
|
|
debug!(
|
|
code = ?key_event.code,
|
|
lines,
|
|
"intercepted page key for pane scrollback"
|
|
);
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
rt.scroll_reset();
|
|
let protocol = rt.keyboard_protocol();
|
|
let bytes = rt.encode_terminal_key(key);
|
|
|
|
if matches!(key_event.code, KeyCode::Esc)
|
|
|| key_event
|
|
.modifiers
|
|
.contains(crossterm::event::KeyModifiers::ALT)
|
|
{
|
|
debug!(
|
|
code = ?key_event.code,
|
|
modifiers = ?key_event.modifiers,
|
|
kind = ?key_event.kind,
|
|
protocol = ?protocol,
|
|
encoded = ?bytes,
|
|
"forwarding potentially-ambiguous terminal key to pane"
|
|
);
|
|
}
|
|
|
|
if bytes.is_empty() {
|
|
if key.kind != crossterm::event::KeyEventKind::Release
|
|
&& !matches!(
|
|
key.code,
|
|
KeyCode::CapsLock
|
|
| KeyCode::ScrollLock
|
|
| KeyCode::NumLock
|
|
| KeyCode::PrintScreen
|
|
| KeyCode::Pause
|
|
| KeyCode::Menu
|
|
| KeyCode::KeypadBegin
|
|
| KeyCode::Media(_)
|
|
| KeyCode::Modifier(_)
|
|
)
|
|
{
|
|
warn!(code = ?key_event.code, mods = ?key_event.modifiers, state = ?key_event.state, "key produced empty encoding");
|
|
}
|
|
return None;
|
|
}
|
|
|
|
Some(PreparedPaneInput {
|
|
ws_idx,
|
|
pane_id,
|
|
target: TerminalInputTarget { terminal_id },
|
|
bytes: Bytes::from(bytes),
|
|
})
|
|
}
|
|
|
|
fn prepare_popup_key_forward(&mut self, key: TerminalKey) -> PreparedPopupInput {
|
|
if self.state.popup_pane.is_none() {
|
|
return PreparedPopupInput::NotOpen;
|
|
}
|
|
let Some(terminal_id) = self
|
|
.state
|
|
.popup_pane
|
|
.as_ref()
|
|
.map(|popup| popup.terminal_id.clone())
|
|
else {
|
|
return PreparedPopupInput::NotOpen;
|
|
};
|
|
let Some(rt) = self.terminal_runtimes.get(&terminal_id) else {
|
|
self.close_popup_pane();
|
|
return PreparedPopupInput::Consumed;
|
|
};
|
|
rt.scroll_reset();
|
|
let bytes = rt.encode_terminal_key(key);
|
|
self.state.mode = Mode::Terminal;
|
|
if bytes.is_empty() {
|
|
PreparedPopupInput::Consumed
|
|
} else {
|
|
PreparedPopupInput::Bytes {
|
|
target: TerminalInputTarget { terminal_id },
|
|
bytes: Bytes::from(bytes),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn host_keyboard_report_all_requested(&self) -> bool {
|
|
if self.state.popup_pane.is_none()
|
|
&& matches!(self.state.mode, Mode::Prefix | Mode::Navigate)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
let runtime = if self.state.popup_pane.is_some() {
|
|
self.popup_runtime()
|
|
} else if self.state.mode == Mode::Terminal {
|
|
self.state.active.and_then(|ws_idx| {
|
|
self.state
|
|
.focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
runtime.is_some_and(|runtime| runtime.keyboard_protocol().reports_all_keys())
|
|
}
|
|
|
|
fn terminal_input_runtime(
|
|
&self,
|
|
target: &TerminalInputTarget,
|
|
) -> Option<&crate::terminal::TerminalRuntime> {
|
|
if let Some(runtime) = self.terminal_runtimes.get(&target.terminal_id) {
|
|
return Some(runtime);
|
|
}
|
|
#[cfg(test)]
|
|
for (ws_idx, workspace) in self.state.workspaces.iter().enumerate() {
|
|
for tab in &workspace.tabs {
|
|
for (&pane_id, pane) in &tab.panes {
|
|
if pane.attached_terminal_id == target.terminal_id {
|
|
return self.state.runtime_for_pane_in_workspace(
|
|
&self.terminal_runtimes,
|
|
ws_idx,
|
|
pane_id,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub(crate) fn forward_terminal_key_to_target_headless(
|
|
&self,
|
|
target: &TerminalInputTarget,
|
|
key: TerminalKey,
|
|
) -> bool {
|
|
let Some(runtime) = self.terminal_input_runtime(target) else {
|
|
return false;
|
|
};
|
|
let bytes = runtime.encode_terminal_key(key);
|
|
bytes.is_empty() || runtime.try_send_bytes(Bytes::from(bytes)).is_ok()
|
|
}
|
|
|
|
pub(crate) async fn forward_terminal_key_to_target(
|
|
&self,
|
|
target: &TerminalInputTarget,
|
|
key: TerminalKey,
|
|
) -> bool {
|
|
let Some(runtime) = self.terminal_input_runtime(target) else {
|
|
return false;
|
|
};
|
|
let bytes = runtime.encode_terminal_key(key);
|
|
bytes.is_empty() || runtime.send_bytes(Bytes::from(bytes)).await.is_ok()
|
|
}
|
|
|
|
fn take_pressed_keys_for_source(
|
|
&mut self,
|
|
source_id: crate::app::InputSourceId,
|
|
) -> Vec<crate::app::PressedTerminalKey> {
|
|
let pressed = self
|
|
.pressed_terminal_keys
|
|
.iter()
|
|
.filter(|((id, _), _)| *id == source_id)
|
|
.map(|(_, pressed)| pressed.clone())
|
|
.collect();
|
|
self.pressed_terminal_keys
|
|
.retain(|(id, _), _| *id != source_id);
|
|
self.suppressed_repeat_keys
|
|
.retain(|(id, _)| *id != source_id);
|
|
pressed
|
|
}
|
|
|
|
pub(crate) fn release_input_source_headless(&mut self, source_id: crate::app::InputSourceId) {
|
|
self.pending_url_click_sources.remove(&source_id);
|
|
for pressed in self.take_pressed_keys_for_source(source_id) {
|
|
let release = pressed
|
|
.key
|
|
.with_kind(crossterm::event::KeyEventKind::Release);
|
|
let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release);
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn release_input_source(&mut self, source_id: crate::app::InputSourceId) {
|
|
self.pending_url_click_sources.remove(&source_id);
|
|
for pressed in self.take_pressed_keys_for_source(source_id) {
|
|
let release = pressed
|
|
.key
|
|
.with_kind(crossterm::event::KeyEventKind::Release);
|
|
let _ = self
|
|
.forward_terminal_key_to_target(&pressed.target, release)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
pub(super) async fn handle_terminal_key(
|
|
&mut self,
|
|
key: TerminalKey,
|
|
) -> Option<TerminalInputTarget> {
|
|
match self.prepare_popup_key_forward(key) {
|
|
PreparedPopupInput::NotOpen => {}
|
|
PreparedPopupInput::Consumed => return None,
|
|
PreparedPopupInput::Bytes { target, bytes } => {
|
|
let Some(runtime) = self.popup_runtime() else {
|
|
self.close_popup_pane();
|
|
return None;
|
|
};
|
|
return runtime.send_bytes(bytes).await.is_ok().then_some(target);
|
|
}
|
|
}
|
|
|
|
let input = self.prepare_terminal_key_forward(crate::app::LOCAL_INPUT_SOURCE, key)?;
|
|
let sent = if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) {
|
|
runtime.send_bytes(input.bytes).await.is_ok()
|
|
} else {
|
|
false
|
|
};
|
|
sent.then_some(input.target)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
|
|
use ratatui::layout::Rect;
|
|
|
|
use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes};
|
|
#[cfg(unix)]
|
|
use super::super::{unique_temp_path, wait_for_file};
|
|
use super::*;
|
|
use crate::{config::Config, events::AppEvent, workspace::Workspace};
|
|
|
|
#[cfg(unix)]
|
|
fn app_with_spawned_workspace() -> App {
|
|
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = App::new(
|
|
&Config::default(),
|
|
true,
|
|
None,
|
|
api_rx,
|
|
crate::api::EventHub::default(),
|
|
);
|
|
app.state.default_shell = "/bin/sh".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,
|
|
crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode),
|
|
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;
|
|
app.state.mode = Mode::Terminal;
|
|
app
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn shutdown_test_runtimes(app: &mut App) {
|
|
for (_, runtime) in app.terminal_runtimes.drain() {
|
|
runtime.shutdown();
|
|
}
|
|
}
|
|
|
|
fn app_with_screen_bytes(bytes: &[u8]) -> (App, crate::layout::PaneInfo) {
|
|
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.insert_test_runtime(
|
|
pane_id,
|
|
crate::terminal::TerminalRuntime::test_with_screen_bytes(
|
|
info.inner_rect.width,
|
|
info.inner_rect.height,
|
|
bytes,
|
|
),
|
|
);
|
|
|
|
app.state.workspaces = vec![ws];
|
|
app.state.active = Some(0);
|
|
app.state.selected = 0;
|
|
app.state.mode = Mode::Terminal;
|
|
app.state.view.pane_infos = pane_infos;
|
|
(app, info)
|
|
}
|
|
|
|
fn double_click(app: &mut App, col: u16, row: u16) {
|
|
app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row));
|
|
app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row));
|
|
app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row));
|
|
}
|
|
|
|
fn modified_mouse(
|
|
kind: MouseEventKind,
|
|
col: u16,
|
|
row: u16,
|
|
modifiers: KeyModifiers,
|
|
) -> crossterm::event::MouseEvent {
|
|
crossterm::event::MouseEvent {
|
|
kind,
|
|
column: col,
|
|
row,
|
|
modifiers,
|
|
}
|
|
}
|
|
|
|
fn clipboard_write_content(app: &mut App) -> Vec<u8> {
|
|
match app.event_rx.try_recv().expect("clipboard write event") {
|
|
AppEvent::ClipboardWrite { content } => content,
|
|
event => panic!("unexpected event: {event:?}"),
|
|
}
|
|
}
|
|
|
|
fn assert_visible_selection(app: &App) {
|
|
assert!(app
|
|
.state
|
|
.selection
|
|
.as_ref()
|
|
.is_some_and(crate::selection::Selection::is_visible));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn install_test_link_handler(app: &mut App) {
|
|
let plugin_root = std::env::temp_dir();
|
|
app.state.installed_plugins = std::collections::HashMap::from([(
|
|
"example.links".to_string(),
|
|
crate::api::schema::InstalledPluginInfo {
|
|
plugin_id: "example.links".into(),
|
|
name: "Links".into(),
|
|
version: "0.1.0".into(),
|
|
min_herdr_version: "0.6.10".into(),
|
|
description: None,
|
|
manifest_path: plugin_root.join("herdr-plugin.toml").display().to_string(),
|
|
plugin_root: plugin_root.display().to_string(),
|
|
enabled: true,
|
|
platforms: None,
|
|
build: Vec::new(),
|
|
startup: Vec::new(),
|
|
actions: vec![crate::api::schema::PluginManifestAction {
|
|
id: "open".into(),
|
|
title: "Open link".into(),
|
|
description: None,
|
|
contexts: Vec::new(),
|
|
platforms: None,
|
|
command: vec!["sh".into(), "-c".into(), ":".into()],
|
|
}],
|
|
events: Vec::new(),
|
|
panes: Vec::new(),
|
|
link_handlers: vec![crate::api::schema::PluginManifestLinkHandler {
|
|
id: "github-issue".into(),
|
|
title: "Open GitHub issue".into(),
|
|
pattern: "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$".into(),
|
|
action: "open".into(),
|
|
platforms: None,
|
|
}],
|
|
source: crate::api::schema::PluginSourceInfo::default(),
|
|
warnings: Vec::new(),
|
|
},
|
|
)]);
|
|
}
|
|
|
|
#[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.insert_test_runtime(
|
|
pane_id,
|
|
crate::terminal::TerminalRuntime::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
|
|
.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;
|
|
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
|
|
.runtime_for_pane(&app.terminal_runtimes, pane_id)
|
|
.and_then(crate::terminal::TerminalRuntime::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.insert_test_runtime(
|
|
pane_id,
|
|
crate::terminal::TerminalRuntime::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 drag_copy_then_click_does_not_reuse_double_click_candidate() {
|
|
let (mut app, info) = app_with_screen_bytes(b"alpha beta");
|
|
let row = info.inner_rect.y;
|
|
let start_col = info.inner_rect.x;
|
|
let end_col = info.inner_rect.x + 4;
|
|
|
|
app.handle_mouse(mouse(
|
|
MouseEventKind::Down(MouseButton::Left),
|
|
start_col,
|
|
row,
|
|
));
|
|
assert!(app.last_pane_click.is_some());
|
|
|
|
app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
|
|
assert!(app.last_pane_click.is_none());
|
|
|
|
app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));
|
|
assert!(app.last_pane_click.is_none());
|
|
assert_eq!(clipboard_write_content(&mut app), b"alpha");
|
|
|
|
app.handle_mouse(mouse(
|
|
MouseEventKind::Down(MouseButton::Left),
|
|
start_col,
|
|
row,
|
|
));
|
|
|
|
assert!(app.last_pane_click.is_some());
|
|
assert!(app.event_rx.try_recv().is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn double_click_selects_and_copies_word() {
|
|
let (mut app, info) = app_with_screen_bytes(b"alpha beta-gamma_delta@omega");
|
|
let col = info.inner_rect.x + 13;
|
|
let row = info.inner_rect.y;
|
|
double_click(&mut app, col, row);
|
|
|
|
assert_eq!(clipboard_write_content(&mut app), b"beta-gamma_delta@omega");
|
|
assert_visible_selection(&app);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn copy_on_select_disabled_keeps_drag_selection_without_copying() {
|
|
let (mut app, info) = app_with_screen_bytes(b"alpha beta");
|
|
app.state.copy_on_select = false;
|
|
let row = info.inner_rect.y;
|
|
let start_col = info.inner_rect.x;
|
|
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_visible_selection(&app);
|
|
assert!(!app
|
|
.state
|
|
.selection
|
|
.as_ref()
|
|
.is_some_and(crate::selection::Selection::is_finalized));
|
|
|
|
app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));
|
|
|
|
assert_visible_selection(&app);
|
|
assert_eq!(
|
|
app.state
|
|
.selection
|
|
.as_ref()
|
|
.map(crate::selection::Selection::ordered_cells),
|
|
Some(((0, 0), (0, 4)))
|
|
);
|
|
assert!(app
|
|
.state
|
|
.selection
|
|
.as_ref()
|
|
.is_some_and(crate::selection::Selection::is_finalized));
|
|
assert!(app.state.selection_autoscroll.is_none());
|
|
assert!(app.selection_autoscroll_deadline.is_none());
|
|
assert!(app.selection_highlight_clear_deadline.is_none());
|
|
assert!(app.event_rx.try_recv().is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn copy_on_select_disabled_retains_double_clicked_word_until_shortcut() {
|
|
let (mut app, info) = app_with_screen_bytes(b"alpha beta");
|
|
app.state.copy_on_select = false;
|
|
let col = info.inner_rect.x + 2;
|
|
let row = info.inner_rect.y;
|
|
|
|
double_click(&mut app, col, row);
|
|
|
|
assert_visible_selection(&app);
|
|
assert!(app.selection_highlight_clear_deadline.is_none());
|
|
assert!(app.event_rx.try_recv().is_err());
|
|
|
|
app.handle_terminal_key_headless(TerminalKey::new(
|
|
KeyCode::Char('c'),
|
|
KeyModifiers::CONTROL,
|
|
));
|
|
|
|
assert_eq!(clipboard_write_content(&mut app), b"alpha");
|
|
assert!(app.state.selection.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn new_drag_cancels_stale_double_click_highlight_deadline() {
|
|
let (mut app, info) = app_with_screen_bytes(b"alpha beta");
|
|
let row = info.inner_rect.y;
|
|
let word_col = info.inner_rect.x + 2;
|
|
|
|
double_click(&mut app, word_col, row);
|
|
assert_eq!(clipboard_write_content(&mut app), b"alpha");
|
|
let stale_deadline = app
|
|
.selection_highlight_clear_deadline
|
|
.expect("double-click highlight deadline");
|
|
app.state.copy_on_select = false;
|
|
|
|
let start_col = info.inner_rect.x + 6;
|
|
let end_col = info.inner_rect.x + 9;
|
|
app.handle_mouse(mouse(
|
|
MouseEventKind::Down(MouseButton::Left),
|
|
start_col,
|
|
row,
|
|
));
|
|
assert!(app.selection_highlight_clear_deadline.is_none());
|
|
app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
|
|
app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));
|
|
|
|
assert_visible_selection(&app);
|
|
assert!(!app
|
|
.clear_due_selection_highlight(stale_deadline + std::time::Duration::from_millis(1)));
|
|
assert_visible_selection(&app);
|
|
assert!(app.event_rx.try_recv().is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ignored_left_down_keeps_double_click_highlight_deadline() {
|
|
let (mut app, info) = app_with_screen_bytes(b"alpha beta");
|
|
let col = info.inner_rect.x + 2;
|
|
let row = info.inner_rect.y;
|
|
|
|
double_click(&mut app, col, row);
|
|
assert_eq!(clipboard_write_content(&mut app), b"alpha");
|
|
let deadline = app
|
|
.selection_highlight_clear_deadline
|
|
.expect("double-click highlight deadline");
|
|
app.state.toast = Some(crate::app::state::ToastNotification {
|
|
kind: crate::app::state::ToastKind::Finished,
|
|
title: "finished".into(),
|
|
context: "missing".into(),
|
|
position: None,
|
|
target: Some(crate::app::state::ToastTarget {
|
|
workspace_id: "missing".into(),
|
|
pane_id: info.id,
|
|
}),
|
|
});
|
|
app.state.view.toast_hit_area = Rect::new(0, 0, 1, 1);
|
|
|
|
app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0, 0));
|
|
|
|
assert_visible_selection(&app);
|
|
assert_eq!(app.selection_highlight_clear_deadline, Some(deadline));
|
|
assert!(app.clear_due_selection_highlight(deadline + std::time::Duration::from_millis(1)));
|
|
assert!(app.state.selection.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn double_click_uses_display_columns_for_wide_chars() {
|
|
let (mut app, info) = app_with_screen_bytes("echo 你好-world done".as_bytes());
|
|
let col = info.inner_rect.x + 8;
|
|
let row = info.inner_rect.y;
|
|
double_click(&mut app, col, row);
|
|
|
|
assert_eq!(clipboard_write_content(&mut app), "你好-world".as_bytes());
|
|
assert_visible_selection(&app);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn double_click_copies_quoted_path_without_quotes() {
|
|
let line = r#"cat "/tmp/build output/log.txt""#;
|
|
let (mut app, info) = app_with_screen_bytes(line.as_bytes());
|
|
let col = info.inner_rect.x + line.find("output").expect("path segment") as u16;
|
|
let row = info.inner_rect.y;
|
|
double_click(&mut app, col, row);
|
|
|
|
assert_eq!(
|
|
clipboard_write_content(&mut app),
|
|
b"/tmp/build output/log.txt"
|
|
);
|
|
assert_visible_selection(&app);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn double_click_excludes_trailing_punctuation() {
|
|
let (mut app, info) = app_with_screen_bytes(b"done.");
|
|
let col = info.inner_rect.x + 2;
|
|
let row = info.inner_rect.y;
|
|
double_click(&mut app, col, row);
|
|
|
|
assert_eq!(clipboard_write_content(&mut app), b"done");
|
|
assert_visible_selection(&app);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn modified_pane_click_does_not_seed_double_click_copy() {
|
|
let (mut app, info) = app_with_screen_bytes(b"alpha beta");
|
|
let col = info.inner_rect.x + 7;
|
|
let row = info.inner_rect.y;
|
|
|
|
app.handle_mouse(modified_mouse(
|
|
MouseEventKind::Down(MouseButton::Left),
|
|
col,
|
|
row,
|
|
KeyModifiers::CONTROL,
|
|
));
|
|
app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row));
|
|
app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row));
|
|
|
|
assert!(app.event_rx.try_recv().is_err());
|
|
assert!(app.selection_highlight_clear_deadline.is_none());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn ctrl_click_url_does_not_forward_release_to_mouse_reporting_pane() {
|
|
let line = "see https://github.com/ogulcancelik/herdr/issues/1761";
|
|
let col = line.find("github").expect("url host") as u16;
|
|
let (mut app, info) = app_with_screen_bytes(b"");
|
|
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
|
|
let screen = format!("\x1b[?1049h\x1b[?1000h\x1b[?1006h{line}");
|
|
let (runtime, mut input_rx) =
|
|
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
|
info.inner_rect.width,
|
|
info.inner_rect.height,
|
|
0,
|
|
screen.as_bytes(),
|
|
4,
|
|
);
|
|
app.state.insert_test_runtime(pane_id, runtime);
|
|
install_test_link_handler(&mut app);
|
|
let mut send_mouse = |source_id, kind, column, modifiers| {
|
|
app.handle_mouse_from_input_source(
|
|
source_id,
|
|
modified_mouse(kind, column, info.inner_rect.y, modifiers),
|
|
);
|
|
};
|
|
let down = MouseEventKind::Down(MouseButton::Left);
|
|
let up = MouseEventKind::Up(MouseButton::Left);
|
|
let url_x = info.inner_rect.x + col;
|
|
|
|
send_mouse(41, down, url_x, KeyModifiers::CONTROL);
|
|
send_mouse(42, down, info.inner_rect.x, KeyModifiers::empty());
|
|
send_mouse(42, up, info.inner_rect.x, KeyModifiers::empty());
|
|
send_mouse(41, up, url_x, KeyModifiers::empty());
|
|
|
|
assert_eq!(app.state.plugin_command_logs.len(), 1);
|
|
assert_eq!(
|
|
input_rx.try_recv().expect("other source mouse down"),
|
|
Bytes::from_static(b"\x1b[<0;1;1M")
|
|
);
|
|
assert_eq!(
|
|
input_rx.try_recv().expect("other source mouse up"),
|
|
Bytes::from_static(b"\x1b[<0;1;1m")
|
|
);
|
|
assert!(
|
|
input_rx.try_recv().is_err(),
|
|
"handled URL click must not leave an unmatched release for the pane"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn ctrl_click_url_invokes_plugin_link_handler_but_super_click_does_not() {
|
|
let line = "see https://github.com/ogulcancelik/herdr/issues/398";
|
|
let col = line.find("github").expect("url host") as u16;
|
|
|
|
let (mut ctrl_app, ctrl_info) = app_with_screen_bytes(line.as_bytes());
|
|
install_test_link_handler(&mut ctrl_app);
|
|
ctrl_app.handle_mouse(modified_mouse(
|
|
MouseEventKind::Down(MouseButton::Left),
|
|
ctrl_info.inner_rect.x + col,
|
|
ctrl_info.inner_rect.y,
|
|
KeyModifiers::CONTROL,
|
|
));
|
|
|
|
let ctrl_log = ctrl_app
|
|
.state
|
|
.plugin_command_logs
|
|
.last()
|
|
.expect("ctrl-click should start plugin link handler");
|
|
assert_eq!(ctrl_log.plugin_id, "example.links");
|
|
assert_eq!(ctrl_log.action_id.as_deref(), Some("open"));
|
|
|
|
let (mut super_app, super_info) = app_with_screen_bytes(line.as_bytes());
|
|
install_test_link_handler(&mut super_app);
|
|
super_app.handle_mouse(modified_mouse(
|
|
MouseEventKind::Down(MouseButton::Left),
|
|
super_info.inner_rect.x + col,
|
|
super_info.inner_rect.y,
|
|
KeyModifiers::SUPER,
|
|
));
|
|
|
|
assert!(super_app.state.plugin_command_logs.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pane_cell_url_resolver_finds_visible_url() {
|
|
let line = "see https://example.com/pr/307.";
|
|
let (app, info) = app_with_screen_bytes(line.as_bytes());
|
|
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
|
|
let col = line.find("example").expect("url host") as u16;
|
|
|
|
assert_eq!(
|
|
app.state
|
|
.url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, col)
|
|
.as_deref(),
|
|
Some("https://example.com/pr/307")
|
|
);
|
|
assert_eq!(
|
|
app.state.url_at_pane_cell(
|
|
&app.terminal_runtimes,
|
|
pane_id,
|
|
0,
|
|
info.inner_rect.width - 1
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pane_cell_url_resolver_finds_soft_wrapped_url() {
|
|
let (_app, info) = app_with_screen_bytes(b"");
|
|
let prefix = "https://example.com/";
|
|
let padding = "a".repeat(info.inner_rect.width as usize - prefix.len());
|
|
let url = format!("{prefix}{padding}tail");
|
|
let (app, _info) = app_with_screen_bytes(url.as_bytes());
|
|
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
|
|
|
|
assert_eq!(
|
|
app.state
|
|
.url_at_pane_cell(&app.terminal_runtimes, pane_id, 1, 1)
|
|
.as_deref(),
|
|
Some(url.as_str())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pane_cell_url_resolver_does_not_shift_after_zero_width_mark() {
|
|
let url = "https://example.com/mark";
|
|
let screen = format!("e\u{301} {url}");
|
|
let (app, _info) = app_with_screen_bytes(screen.as_bytes());
|
|
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
|
|
|
|
assert_eq!(
|
|
app.state
|
|
.url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, 2)
|
|
.as_deref(),
|
|
Some(url)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pane_cell_url_resolver_handles_hard_newline_after_full_row() {
|
|
let (_app, info) = app_with_screen_bytes(b"");
|
|
let full_row = "x".repeat(info.inner_rect.width as usize);
|
|
let url = "https://example.com/next";
|
|
let screen = format!("{full_row}\n{url}");
|
|
let (app, _info) = app_with_screen_bytes(screen.as_bytes());
|
|
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
|
|
|
|
assert_eq!(
|
|
app.state
|
|
.url_at_pane_cell(&app.terminal_runtimes, pane_id, 1, 1)
|
|
.as_deref(),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
app.state
|
|
.url_at_pane_cell(&app.terminal_runtimes, pane_id, 2, 1)
|
|
.as_deref(),
|
|
Some(url)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn render_stream_does_not_synthesize_soft_wrapped_url_hyperlinks() {
|
|
let (_app, info) = app_with_screen_bytes(b"");
|
|
let prefix = "https://example.com/";
|
|
let padding = "b".repeat(info.inner_rect.width as usize - prefix.len());
|
|
let url = format!("{prefix}{padding}tail");
|
|
let (app, _info) = app_with_screen_bytes(url.as_bytes());
|
|
|
|
let links =
|
|
crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes);
|
|
|
|
assert!(links.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn render_stream_does_not_synthesize_url_hyperlinks_after_zero_width_mark() {
|
|
let url = "https://example.com/mark";
|
|
let screen = format!("e\u{301} {url}");
|
|
let (app, _info) = app_with_screen_bytes(screen.as_bytes());
|
|
|
|
let links =
|
|
crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes);
|
|
|
|
assert!(links.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn render_stream_does_not_synthesize_hard_newline_plain_url_hyperlinks() {
|
|
let (_app, info) = app_with_screen_bytes(b"");
|
|
let full_row = "x".repeat(info.inner_rect.width as usize);
|
|
let url = "https://example.com/next";
|
|
let screen = format!("{full_row}\n{url}");
|
|
let (app, _info) = app_with_screen_bytes(screen.as_bytes());
|
|
let links =
|
|
crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes);
|
|
|
|
assert!(links.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn render_stream_exports_osc8_hyperlink_metadata() {
|
|
let uri = "https://example.com/target";
|
|
let (mut app, _info) =
|
|
app_with_screen_bytes(format!("\x1b]8;;{uri}\x1b\\label\x1b]8;;\x1b\\").as_bytes());
|
|
let (buffer, cursor) = crate::server::render_stream::render_virtual_with_runtime_registry(
|
|
&mut app.state,
|
|
&app.terminal_runtimes,
|
|
ratatui::layout::Rect::new(0, 0, 106, 20),
|
|
false,
|
|
crate::kitty_graphics::HostCellSize::default(),
|
|
);
|
|
let links =
|
|
crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes);
|
|
let frame = crate::protocol::FrameData::from_ratatui_buffer_with_hyperlinks(
|
|
&buffer, cursor, &links,
|
|
);
|
|
let ((x, y), symbol, _) = links
|
|
.iter()
|
|
.find(|(_, symbol, link_uri)| symbol == "l" && link_uri == uri)
|
|
.expect("OSC 8 link cell");
|
|
let linked_cell_index = usize::from(*y) * usize::from(frame.width) + usize::from(*x);
|
|
|
|
assert_eq!(frame.hyperlinks, vec![uri.to_owned()]);
|
|
assert_eq!(symbol, "l");
|
|
assert_eq!(frame.cells[linked_cell_index].hyperlink, Some(0));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pane_cell_url_resolver_prefers_osc8_hyperlink() {
|
|
let (app, _info) = app_with_screen_bytes(
|
|
b"\x1b]8;;https://example.com/hidden-target\x1b\\label\x1b]8;;\x1b\\",
|
|
);
|
|
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
|
|
|
|
assert_eq!(
|
|
app.state
|
|
.url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, 1)
|
|
.as_deref(),
|
|
Some("https://example.com/hidden-target")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn double_click_highlight_clears_after_short_delay() {
|
|
let (mut app, info) = app_with_screen_bytes(b"copied");
|
|
let col = info.inner_rect.x + 2;
|
|
let row = info.inner_rect.y;
|
|
double_click(&mut app, col, row);
|
|
assert_eq!(clipboard_write_content(&mut app), b"copied");
|
|
|
|
app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row));
|
|
|
|
assert!(app.event_rx.try_recv().is_err());
|
|
assert!(app.state.selection.is_some());
|
|
let deadline = app
|
|
.selection_highlight_clear_deadline
|
|
.expect("highlight clear deadline");
|
|
assert!(app.handle_scheduled_tasks(deadline + std::time::Duration::from_millis(1), false));
|
|
assert!(app.state.selection.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn copy_on_select_disabled_still_forwards_mouse_reporting_gestures() {
|
|
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();
|
|
let (runtime, mut input_rx) =
|
|
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
|
info.inner_rect.width,
|
|
info.inner_rect.height,
|
|
0,
|
|
b"\x1b[?1002h\x1b[?1006h",
|
|
4,
|
|
);
|
|
ws.insert_test_runtime(pane_id, runtime);
|
|
app.state.workspaces = vec![ws];
|
|
app.state.active = Some(0);
|
|
app.state.selected = 0;
|
|
app.state.mode = Mode::Terminal;
|
|
app.state.view.pane_infos = pane_infos;
|
|
app.state.copy_on_select = false;
|
|
|
|
let col = info.inner_rect.x + 2;
|
|
let row = info.inner_rect.y + 3;
|
|
app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row));
|
|
app.handle_mouse(mouse(
|
|
MouseEventKind::Drag(MouseButton::Left),
|
|
col + 1,
|
|
row + 1,
|
|
));
|
|
app.handle_mouse(mouse(
|
|
MouseEventKind::Up(MouseButton::Left),
|
|
col + 1,
|
|
row + 1,
|
|
));
|
|
|
|
assert!(app.event_rx.try_recv().is_err());
|
|
assert!(app.state.selection.is_none());
|
|
assert!(app.selection_highlight_clear_deadline.is_none());
|
|
assert_eq!(
|
|
input_rx.try_recv().expect("forwarded left mouse down"),
|
|
Bytes::from_static(b"\x1b[<0;3;4M")
|
|
);
|
|
assert_eq!(
|
|
input_rx.try_recv().expect("forwarded left mouse drag"),
|
|
Bytes::from_static(b"\x1b[<32;4;5M")
|
|
);
|
|
assert_eq!(
|
|
input_rx.try_recv().expect("forwarded left mouse up"),
|
|
Bytes::from_static(b"\x1b[<0;4;5m")
|
|
);
|
|
assert!(input_rx.try_recv().is_err());
|
|
}
|
|
|
|
#[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.insert_test_runtime(
|
|
pane_id,
|
|
crate::terminal::TerminalRuntime::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
|
|
.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;
|
|
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
|
|
.runtime_for_pane(&app.terminal_runtimes, pane_id)
|
|
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
|
|
.expect("scroll metrics after wheel");
|
|
assert_eq!(
|
|
end_metrics.offset_from_bottom,
|
|
start_metrics.offset_from_bottom + 3
|
|
);
|
|
|
|
let selection = app.state.selection.as_ref().expect("selection after wheel");
|
|
assert!(selection.is_visible());
|
|
assert_eq!(
|
|
selection.ordered_cells(),
|
|
(
|
|
(
|
|
(start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32,
|
|
2,
|
|
),
|
|
(start_metrics.max_offset_from_bottom as u32, 2),
|
|
)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn 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,
|
|
api_rx,
|
|
crate::api::EventHub::default(),
|
|
);
|
|
app.state.workspaces = vec![Workspace::test_new("test")];
|
|
app.state.active = Some(0);
|
|
app.state.selected = 0;
|
|
app.state.mode = Mode::Terminal;
|
|
app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
|
|
app.state.view.pane_infos = app.state.workspaces[0]
|
|
.active_tab()
|
|
.unwrap()
|
|
.layout
|
|
.panes(Rect::new(0, 0, 80, 24));
|
|
let focused_before = app.state.workspaces[0].layout.focused();
|
|
app.state.keybinds.focus_pane_left = crate::config::ActionKeybinds::direct("alt+h");
|
|
|
|
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);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn terminal_direct_edit_scrollback_opens_editor_pane() {
|
|
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = App::new(
|
|
&Config::default(),
|
|
true,
|
|
None,
|
|
api_rx,
|
|
crate::api::EventHub::default(),
|
|
);
|
|
let mut workspace = Workspace::test_new("test");
|
|
let root_pane = workspace.tabs[0].root_pane;
|
|
workspace.tabs[0].runtimes.insert(
|
|
root_pane,
|
|
crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
|
|
20,
|
|
5,
|
|
4096,
|
|
b"alpha\nbeta\n",
|
|
),
|
|
);
|
|
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("direct-edit-scrollback");
|
|
let previous_editor = std::env::var_os("EDITOR");
|
|
std::env::set_var(
|
|
"EDITOR",
|
|
format!("sh -c 'cp \"$1\" {}' sh", output_path.display()),
|
|
);
|
|
app.state.keybinds.edit_scrollback = crate::config::ActionKeybinds::direct("ctrl+alt+e");
|
|
|
|
app.handle_terminal_key(TerminalKey::new(
|
|
KeyCode::Char('e'),
|
|
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
|
))
|
|
.await;
|
|
|
|
match previous_editor {
|
|
Some(value) => std::env::set_var("EDITOR", value),
|
|
None => std::env::remove_var("EDITOR"),
|
|
}
|
|
|
|
let content = wait_for_file(&output_path);
|
|
assert!(content.contains("alpha"));
|
|
assert!(content.contains("beta"));
|
|
assert_eq!(app.state.mode, Mode::Terminal);
|
|
|
|
let _ = std::fs::remove_file(output_path);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn direct_custom_command_runs_before_forwarding_to_pane() {
|
|
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = App::new(
|
|
&Config::default(),
|
|
true,
|
|
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("direct-custom-command");
|
|
let command = format!("printf direct > '{}'", output_path.display());
|
|
app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
|
|
bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
|
|
label: "ctrl+alt+g".into(),
|
|
command,
|
|
action: crate::config::CustomCommandAction::Shell,
|
|
description: None,
|
|
width: None,
|
|
height: None,
|
|
}];
|
|
|
|
app.handle_terminal_key(TerminalKey::new(
|
|
KeyCode::Char('g'),
|
|
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
|
))
|
|
.await;
|
|
|
|
assert_eq!(wait_for_file(&output_path), "direct");
|
|
assert_eq!(app.state.mode, Mode::Terminal);
|
|
let _ = std::fs::remove_file(output_path);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn direct_custom_pane_command_opens_overlay_pane() {
|
|
let mut app = app_with_spawned_workspace();
|
|
|
|
app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
|
|
bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
|
|
label: "ctrl+alt+g".into(),
|
|
command: "printf direct-pane".into(),
|
|
action: crate::config::CustomCommandAction::Pane,
|
|
description: None,
|
|
width: None,
|
|
height: None,
|
|
}];
|
|
|
|
app.handle_terminal_key(TerminalKey::new(
|
|
KeyCode::Char('g'),
|
|
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
|
))
|
|
.await;
|
|
|
|
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);
|
|
|
|
shutdown_test_runtimes(&mut app);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn direct_custom_popup_command_opens_layout_neutral_popup() {
|
|
let mut app = app_with_spawned_workspace();
|
|
|
|
app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
|
|
bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
|
|
label: "ctrl+alt+g".into(),
|
|
command: "sleep 1".into(),
|
|
action: crate::config::CustomCommandAction::Popup,
|
|
description: None,
|
|
width: Some(crate::popup_size::PopupSize::Cells(60)),
|
|
height: Some(crate::popup_size::PopupSize::Cells(12)),
|
|
}];
|
|
|
|
app.handle_terminal_key(TerminalKey::new(
|
|
KeyCode::Char('g'),
|
|
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
|
))
|
|
.await;
|
|
|
|
assert!(app.state.popup_pane.is_some());
|
|
assert!(!app
|
|
.popup_runtime()
|
|
.unwrap()
|
|
.agent_detection_enabled_for_test());
|
|
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 snapshot = crate::persist::capture(
|
|
&app.state.workspaces,
|
|
&app.state.terminals,
|
|
&app.terminal_runtimes,
|
|
app.state.active,
|
|
app.state.selected,
|
|
app.state.sidebar_width,
|
|
app.state.sidebar_section_split,
|
|
app.state.collapsed_space_keys.clone(),
|
|
);
|
|
assert_eq!(snapshot.workspaces[0].tabs[0].panes.len(), 1);
|
|
assert!(matches!(
|
|
snapshot.workspaces[0].tabs[0].layout,
|
|
crate::persist::LayoutSnapshot::Pane(_)
|
|
));
|
|
|
|
shutdown_test_runtimes(&mut app);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn direct_custom_popup_command_closes_after_exit() {
|
|
let mut app = app_with_spawned_workspace();
|
|
let focused_pane = app.state.workspaces[0].focused_pane_id().unwrap();
|
|
let focused_pane_id = app.public_pane_id(0, focused_pane).unwrap();
|
|
|
|
let output_path = unique_temp_path("custom-popup-command");
|
|
let command = format!(
|
|
"printf '%s|%s' \"${{HERDR_PANE_ID-unset}}\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'",
|
|
output_path.display()
|
|
);
|
|
app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
|
|
bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
|
|
label: "ctrl+alt+g".into(),
|
|
command,
|
|
action: crate::config::CustomCommandAction::Popup,
|
|
description: None,
|
|
width: None,
|
|
height: None,
|
|
}];
|
|
|
|
app.handle_terminal_key(TerminalKey::new(
|
|
KeyCode::Char('g'),
|
|
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
|
))
|
|
.await;
|
|
|
|
assert_eq!(
|
|
wait_for_file(&output_path),
|
|
format!("unset|{focused_pane_id}")
|
|
);
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
|
while std::time::Instant::now() < deadline {
|
|
app.drain_internal_events();
|
|
if app.state.popup_pane.is_none() {
|
|
break;
|
|
}
|
|
}
|
|
|
|
assert!(app.state.popup_pane.is_none());
|
|
assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1);
|
|
|
|
shutdown_test_runtimes(&mut app);
|
|
let _ = std::fs::remove_file(output_path);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn popup_forwards_escape_instead_of_closing() {
|
|
let mut app = app_for_mouse_test();
|
|
let (runtime, mut rx) =
|
|
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
|
40,
|
|
2,
|
|
1024,
|
|
b"one\r\ntwo\r\nthree\r\n",
|
|
4,
|
|
);
|
|
runtime.scroll_up(1);
|
|
assert!(runtime
|
|
.scroll_metrics()
|
|
.is_some_and(|metrics| metrics.offset_from_bottom > 0));
|
|
app.install_test_popup_runtime(runtime);
|
|
app.state.mode = Mode::Settings;
|
|
|
|
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()));
|
|
|
|
assert_eq!(rx.try_recv().unwrap().as_ref(), b"\x1b");
|
|
assert!(app.state.popup_pane.is_some());
|
|
assert_eq!(
|
|
app.popup_runtime()
|
|
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
|
|
.map(|metrics| metrics.offset_from_bottom),
|
|
Some(0)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn local_popup_input_waits_for_channel_capacity() {
|
|
let mut app = app_for_mouse_test();
|
|
let (runtime, mut rx) =
|
|
crate::terminal::TerminalRuntime::test_with_channel_capacity(40, 2, 1);
|
|
runtime
|
|
.try_send_bytes(Bytes::from_static(b"queued"))
|
|
.unwrap();
|
|
app.install_test_popup_runtime(runtime);
|
|
app.state.mode = Mode::Settings;
|
|
|
|
let mut send = Box::pin(
|
|
app.handle_terminal_key(TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty())),
|
|
);
|
|
assert!(
|
|
tokio::time::timeout(std::time::Duration::from_millis(20), &mut send)
|
|
.await
|
|
.is_err()
|
|
);
|
|
|
|
assert_eq!(rx.recv().await.unwrap().as_ref(), b"queued");
|
|
send.await;
|
|
assert_eq!(rx.recv().await.unwrap().as_ref(), b"x");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn alt_backspace_is_forwarded_to_focused_pane() {
|
|
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(0, 0, 80, 24));
|
|
let info = pane_infos[0].clone();
|
|
let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(
|
|
info.inner_rect.width,
|
|
info.inner_rect.height,
|
|
);
|
|
ws.tabs[0].runtimes.insert(pane_id, runtime);
|
|
|
|
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 key = crate::input::parse_terminal_key_sequence("\x1b\x7f").unwrap();
|
|
app.handle_terminal_key_headless(key);
|
|
|
|
let bytes = rx.try_recv().unwrap();
|
|
assert_eq!(bytes.as_ref(), b"\x1b\x7f");
|
|
assert!(rx.try_recv().is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn page_up_scrolls_plain_shell_pane() {
|
|
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::terminal::TerminalRuntime::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
|
|
.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);
|
|
|
|
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
|
|
|
|
let end_metrics = app
|
|
.state
|
|
.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,
|
|
info.inner_rect.height as usize
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn page_down_returns_to_bottom_after_page_up() {
|
|
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::terminal::TerminalRuntime::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;
|
|
|
|
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
|
|
let after_up = app
|
|
.state
|
|
.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);
|
|
|
|
app.handle_terminal_key_headless(TerminalKey::new(
|
|
KeyCode::PageDown,
|
|
KeyModifiers::empty(),
|
|
));
|
|
let after_down = app
|
|
.state
|
|
.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);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn page_up_release_does_not_scroll_plain_shell_pane_again() {
|
|
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::terminal::TerminalRuntime::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;
|
|
|
|
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
|
|
let after_press = app
|
|
.state
|
|
.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,
|
|
info.inner_rect.height as usize
|
|
);
|
|
|
|
app.handle_terminal_key_headless(
|
|
TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())
|
|
.with_kind(KeyEventKind::Release),
|
|
);
|
|
|
|
let after_release = app
|
|
.state
|
|
.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,
|
|
after_press.offset_from_bottom
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn modified_page_up_does_not_host_scroll_plain_shell_pane() {
|
|
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::terminal::TerminalRuntime::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;
|
|
|
|
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::CONTROL));
|
|
|
|
let metrics = app
|
|
.state
|
|
.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);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn page_up_forwarded_to_mouse_reporting_pane() {
|
|
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();
|
|
let mut bytes = b"\x1b[?1002h".to_vec();
|
|
bytes.extend_from_slice(&numbered_lines_bytes(64));
|
|
ws.tabs[0].runtimes.insert(
|
|
pane_id,
|
|
crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
|
|
info.inner_rect.width,
|
|
info.inner_rect.height,
|
|
16 * 1024,
|
|
&bytes,
|
|
),
|
|
);
|
|
|
|
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
|
|
.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);
|
|
|
|
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
|
|
|
|
let end_metrics = app
|
|
.state
|
|
.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);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn page_up_forwarded_to_primary_screen_application_cursor_pane() {
|
|
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();
|
|
let mut bytes = b"\x1b[?1h".to_vec();
|
|
bytes.extend_from_slice(&numbered_lines_bytes(64));
|
|
let (runtime, mut input_rx) =
|
|
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
|
info.inner_rect.width,
|
|
info.inner_rect.height,
|
|
16 * 1024,
|
|
&bytes,
|
|
4,
|
|
);
|
|
ws.tabs[0].runtimes.insert(pane_id, runtime);
|
|
|
|
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
|
|
.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);
|
|
|
|
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
|
|
|
|
let forwarded = input_rx.try_recv().expect("forwarded PageUp");
|
|
assert_eq!(forwarded.as_ref(), b"\x1b[5~");
|
|
let end_metrics = app
|
|
.state
|
|
.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, 0);
|
|
}
|
|
}
|