use crate::{
modes::interactive::{
self,
app::{App, MsgLevel},
overlay,
panel::PanelKind,
},
utils::clipboard::{
read_clipboard, read_clipboard_file_paths, read_clipboard_image, write_clipboard,
},
};
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use std::{cell::RefCell, rc::Rc};
const ALT_WHEEL_MULTIPLIER: usize = 5;
const DRAG_EDGE_STEP: usize = 2;
const EDGE_DWELL_TICKS: u8 = 3;
pub(super) fn handle_mouse_event(shared: &Rc<RefCell<App>>, mouse: MouseEvent) {
let mut st = shared.borrow_mut();
if st.giff.is_some() {
if matches!(
mouse.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
) {
st.giff_wheel(&mouse);
}
return;
}
match mouse.kind {
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => st.handle_mouse_wheel(&mouse),
MouseEventKind::Down(MouseButton::Left) => st.handle_mouse_left_down(&mouse),
MouseEventKind::Drag(MouseButton::Left) => st.handle_mouse_left_drag(&mouse),
MouseEventKind::Down(MouseButton::Middle) => st.handle_mouse_middle_down(&mouse),
MouseEventKind::Up(MouseButton::Left) => st.handle_mouse_left_up(),
_ => {}
}
}
impl App {
pub(super) fn wheel_lines(&mut self, mouse: &MouseEvent) -> usize {
let direction = if mouse.kind == MouseEventKind::ScrollUp {
-1
} else {
1
};
let now_ms = self.accel_epoch.elapsed().as_millis() as u64;
let lines = self.wheel_accel.next(direction, now_ms);
if mouse.modifiers.contains(KeyModifiers::ALT) {
lines.saturating_mul(ALT_WHEEL_MULTIPLIER)
} else {
lines
}
}
fn over_log_area(&self, row: u16) -> bool {
self.mouse.log_area_h > 0
&& row >= self.mouse.log_area
&& row < self.mouse.log_area.saturating_add(self.mouse.log_area_h)
}
fn handle_mouse_wheel(&mut self, mouse: &MouseEvent) {
let step = self.wheel_lines(mouse);
if overlay::captures_mouse(self, mouse.column, mouse.row) {
let step = step as isize;
let delta = if mouse.kind == MouseEventKind::ScrollUp {
-step
} else {
step
};
overlay::scroll_by(self, delta);
return;
}
if let Some(area) = self.dock_area
&& mouse.row >= area.y
&& mouse.row < area.y + area.height
{
if mouse.kind == MouseEventKind::ScrollUp {
self.dock_offset = self.dock_offset.saturating_sub(step);
} else {
self.dock_offset = self.dock_offset.saturating_add(step);
}
self.dirty = true;
return;
}
if !self.over_log_area(mouse.row) {
return;
}
if mouse.kind == MouseEventKind::ScrollUp {
self.scroll = self.scroll.saturating_add(step);
} else {
self.scroll = self.scroll.saturating_sub(step);
}
self.dirty = true;
}
fn handle_mouse_left_up(&mut self) {
overlay::left_up(self);
self.mouse.dragging = false;
self.mouse.scroll_dragging = false;
self.mouse.scroll_press = None;
self.mouse.drag_row = None;
self.mouse.edge_dwell = 0;
self.dirty = true;
}
fn handle_mouse_left_down(&mut self, mouse: &MouseEvent) {
if overlay::captures_mouse(self, mouse.column, mouse.row) {
overlay::left_down(self, mouse.column, mouse.row);
return;
}
if self.panel.active {
self.mouse.sel = None;
self.mouse.dragging = false;
self.mouse.scroll_dragging = false;
self.mouse.scroll_press = None;
self.mouse.drag_row = None;
self.mouse.edge_dwell = 0;
if self.handle_panel_oauth_ctrl_click(mouse) {
return;
}
if self.handle_panel_extension_detail_ctrl_click(mouse) {
return;
}
self.dirty = true;
return;
}
if self.mouse.scroll_x == Some(mouse.column) {
self.mouse.sel = None;
self.mouse.dragging = false;
self.mouse.scroll_dragging = true;
self.mouse.drag_row = None;
self.mouse.edge_dwell = 0;
if self.scrollbar_press_on_thumb(mouse.row) {
let off = mouse
.row
.saturating_sub(self.mouse.log_area)
.saturating_sub(self.mouse.scroll_thumb.map(|(s, _)| s as u16).unwrap_or(0));
self.mouse.scroll_press = Some((mouse.row, off as usize));
} else {
self.scrollbar_click_jump(mouse.row);
}
self.dirty = true;
return;
}
let area_y = self.message_area_y();
let area_h = self.message_area_h();
if mouse.modifiers.contains(KeyModifiers::CONTROL)
&& self.open_message_link(mouse.column, mouse.row)
{
self.mouse.sel = None;
self.mouse.dragging = false;
self.mouse.drag_row = None;
return;
}
if mouse.modifiers.contains(KeyModifiers::ALT)
&& let Some(t) = self.mouse.click_target_at(mouse.row)
{
let cur = self
.expand_overrides
.get(&t.id)
.copied()
.unwrap_or(t.default_expanded);
self.expand_overrides.insert(t.id, !cur);
self.msg_cache.remove(&(t.id.ts, t.id.seq)); self.mouse.sel = None;
self.mouse.dragging = false;
self.mouse.drag_row = None;
self.dirty = true;
return;
}
if let Some((line, ch)) = self.mouse.hit(mouse.column, mouse.row, area_y, area_h) {
self.mouse.sel = Some((line, ch, line, ch));
self.mouse.dragging = true;
self.dirty = true;
} else {
self.mouse.sel = None;
self.mouse.dragging = false;
self.dirty = true;
}
}
fn handle_mouse_middle_down(&mut self, mouse: &MouseEvent) {
if overlay::captures_mouse(self, mouse.column, mouse.row) {
self.handle_overlay_middle_down();
return;
}
if let Some(text) = self.selected_text() {
if let Err(e) = write_clipboard(&text) {
self.push_msg(format!("copy failed: {e}"), MsgLevel::Warning);
}
self.mouse.sel = None;
self.mouse.dragging = false;
self.mouse.drag_row = None;
self.editor.paste_text(&text);
self.dirty = true;
return;
}
if let Some(text) = read_clipboard() {
self.editor.paste_text(&text);
self.dirty = true;
}
}
fn handle_overlay_middle_down(&mut self) {
if let Some(text) = overlay::selected_text(self) {
if let Err(e) = write_clipboard(&text) {
self.push_msg(format!("copy failed: {e}"), MsgLevel::Warning);
}
overlay::clear_selection(self);
if overlay::input_focused(self) {
overlay::paste_into_input(self, &text);
}
self.dirty = true;
return;
}
if overlay::input_focused(self)
&& let Some(text) = read_clipboard()
{
overlay::paste_into_input(self, &text);
self.dirty = true;
}
}
pub(super) fn copy_oauth_authorization_url(&mut self) {
let url = self
.oauth_login
.as_ref()
.map(|oauth| oauth.auth_url())
.unwrap_or_default();
if url.is_empty() {
self.push_msg("No sign-in URL to copy yet.".to_string(), MsgLevel::Info);
return;
}
if let Err(e) = write_clipboard(&url) {
self.push_msg(format!("copy failed: {e}"), MsgLevel::Warning);
} else {
self.set_status_msg("Copied sign-in URL to clipboard");
}
}
fn open_message_link(&mut self, col: u16, row: u16) -> bool {
let Some(link) = self.mouse.link_at(col, row) else {
return false;
};
let url = link.url.clone();
if webbrowser::open(&url).is_ok() {
self.push_msg(format!("Opening {url}"), MsgLevel::Info);
} else {
self.push_msg(
"Failed to open browser. Copy the URL and open it manually.".to_string(),
MsgLevel::Warning,
);
}
self.dirty = true;
true
}
fn handle_panel_oauth_ctrl_click(&mut self, mouse: &MouseEvent) -> bool {
if mouse.modifiers.contains(KeyModifiers::CONTROL)
&& self
.panel
.top()
.is_some_and(|l| l.kind == PanelKind::LoginOauth)
&& let Some(oauth) = self.oauth_login.as_ref()
&& let Some(area) = self.panel_area
{
if let Some((_, verification_uri)) = oauth.device_code_info() {
let n = interactive::render::oauth_url_lines(&verification_uri, area.width as usize)
as u16;
if mouse.row >= area.y + 6 && mouse.row < area.y + 6 + n {
if webbrowser::open(&verification_uri).is_ok() {
self.push_msg(
"Opening login page in your browser...".to_string(),
MsgLevel::Info,
);
} else {
self.push_msg(
"Failed to open browser. Copy the URL and open it manually."
.to_string(),
MsgLevel::Warning,
);
}
self.dirty = true;
return true;
}
}
let n =
interactive::render::oauth_url_lines(&oauth.auth_url(), area.width as usize) as u16;
if mouse.row >= area.y + 4 && mouse.row < area.y + 4 + n {
if webbrowser::open(&oauth.auth_url()).is_ok() {
self.push_msg(
"Opening login page in your browser...".to_string(),
MsgLevel::Info,
);
} else {
self.push_msg(
"Failed to open browser. Copy the URL and open it manually.".to_string(),
MsgLevel::Warning,
);
}
self.dirty = true;
return true;
}
}
false
}
fn handle_panel_extension_detail_ctrl_click(&mut self, mouse: &MouseEvent) -> bool {
if mouse.modifiers.contains(KeyModifiers::CONTROL)
&& self
.panel
.top()
.is_some_and(|l| l.kind == PanelKind::ExtensionDetail)
&& let Some(url) = self.extension_detail_url.as_deref()
&& !url.is_empty()
&& let Some(area) = self.panel_area
{
let mut fork_url_screen_row: Option<u16> = None;
let mut row_offset: u16 = 4; for l in &self.extension_detail {
if l.starts_with(" fork url: ") {
fork_url_screen_row = Some(row_offset);
break;
}
let wrapped = interactive::render::wrap_text(l, area.width as usize - 4);
row_offset += wrapped.len() as u16;
}
if let Some(fork_row) = fork_url_screen_row {
let screen_row = area.y + fork_row;
if mouse.row == screen_row {
if webbrowser::open(url).is_ok() {
self.push_msg(
"Opening fork project page in your browser...".to_string(),
MsgLevel::Info,
);
} else {
self.push_msg(
"Failed to open browser. Copy the URL and open it manually."
.to_string(),
MsgLevel::Warning,
);
}
self.dirty = true;
return true;
}
}
}
false
}
fn handle_mouse_left_drag(&mut self, mouse: &MouseEvent) {
if overlay::captures_mouse(self, mouse.column, mouse.row) || overlay::is_dragging(self) {
overlay::left_drag(self, mouse.column, mouse.row);
return;
}
if self.mouse.scroll_dragging {
if self.mouse.scroll_press.is_some() {
self.scrollbar_drag_to(mouse.row);
}
self.dirty = true;
return;
}
if !self.mouse.dragging {
return;
}
self.mouse.drag_row = Some(mouse.row);
let area_y = self.message_area_y();
let area_h = self.message_area_h();
let at_edge = mouse.row <= area_y || mouse.row.saturating_add(1) >= area_y + area_h;
if !at_edge {
self.mouse.edge_dwell = 0;
}
if let Some((line, ch)) = self.mouse.hit(mouse.column, mouse.row, area_y, area_h) {
self.mouse.set_drag_point(line, ch);
self.dirty = true;
return;
}
if (mouse.row < area_y && self.scroll_drag_edge(area_h as usize, true))
|| (mouse.row >= area_y + area_h && self.scroll_drag_edge(area_h as usize, false))
{
self.dirty = true;
}
}
fn scroll_drag_edge(&mut self, area_h: usize, up: bool) -> bool {
let max_scroll = self.mouse.log_total.saturating_sub(area_h);
if up {
if self.scroll >= max_scroll {
return false;
}
self.scroll = (self.scroll + DRAG_EDGE_STEP).min(max_scroll);
let start = self
.mouse
.log_total
.saturating_sub(area_h)
.saturating_sub(self.scroll);
self.mouse.set_drag_point(start, 0);
} else {
if self.scroll == 0 {
return false;
}
self.scroll = self.scroll.saturating_sub(DRAG_EDGE_STEP);
let start = self
.mouse
.log_total
.saturating_sub(area_h)
.saturating_sub(self.scroll);
let end_line = (start + area_h)
.saturating_sub(1)
.min(self.mouse.log_total.saturating_sub(1));
self.mouse.set_drag_point(end_line, usize::MAX);
}
true
}
pub(crate) fn auto_scroll_tick(&mut self) -> bool {
if !self.mouse.dragging {
self.mouse.edge_dwell = 0;
return false;
}
let Some(row) = self.mouse.drag_row else {
self.mouse.edge_dwell = 0;
return false;
};
let area_y = self.mouse.log_area;
let area_h = self.mouse.log_area_h.max(1) as usize;
let at_top = row <= area_y;
let at_bottom = row.saturating_add(1) >= area_y + area_h as u16;
if !at_top && !at_bottom {
self.mouse.edge_dwell = 0;
return false;
}
if self.mouse.edge_dwell < EDGE_DWELL_TICKS {
self.mouse.edge_dwell += 1;
return false;
}
self.scroll_drag_edge(area_h, at_top)
}
fn scrollbar_press_on_thumb(&self, row: u16) -> bool {
let Some((start, len)) = self.mouse.scroll_thumb else {
return false;
};
let rel = row
.saturating_sub(self.mouse.log_area)
.min(self.mouse.log_area_h.saturating_sub(1)) as usize;
rel >= start && rel < start + len
}
fn scrollbar_click_jump(&mut self, row: u16) {
let track = self.mouse.log_area_h.max(1) as usize;
let view = track;
let max_scroll = self.mouse.log_total.saturating_sub(view);
let rel = row
.saturating_sub(self.mouse.log_area)
.min(track.saturating_sub(1) as u16) as usize;
let ratio = if track <= 1 {
0.0
} else {
rel as f64 / (track - 1) as f64
};
self.scroll = ((max_scroll as f64 * (1.0 - ratio)).round() as usize).min(max_scroll);
self.mouse.scroll_press = Some((row, 0));
}
fn scrollbar_drag_to(&mut self, row: u16) {
let Some((_, grab_offset)) = self.mouse.scroll_press else {
return;
};
let track = self.mouse.log_area_h.max(1) as usize;
let max_scroll = self.mouse.log_total.saturating_sub(track);
let rel = row
.saturating_sub(self.mouse.log_area)
.saturating_sub(grab_offset as u16)
.min(track.saturating_sub(1) as u16) as usize;
let ratio = if track <= 1 {
0.0
} else {
rel as f64 / (track - 1) as f64
};
self.scroll = ((max_scroll as f64 * (1.0 - ratio)).round() as usize).min(max_scroll);
}
fn message_area_y(&self) -> u16 {
self.mouse.log_area
}
fn message_area_h(&self) -> u16 {
self.mouse.visible.len() as u16
}
pub(super) fn copy_selection(&mut self) {
let Some(text) = self.selected_text() else {
self.set_status_msg("No text selected to copy");
return;
};
if let Err(e) = write_clipboard(&text) {
self.push_msg(format!("copy failed: {e}"), MsgLevel::Warning);
} else {
self.set_status_msg("Copied selection to clipboard");
}
self.mouse.sel = None;
}
pub(super) fn copy_overlay_selection(&mut self) {
let Some(text) = overlay::selected_text(self) else {
self.set_status_msg("No text selected to copy");
return;
};
if let Err(e) = write_clipboard(&text) {
self.push_msg(format!("copy failed: {e}"), MsgLevel::Warning);
} else {
self.set_status_msg("Copied selection to clipboard");
}
overlay::clear_selection(self);
}
pub(super) fn copy_last_assistant_message(&mut self) {
let Some(msg) = self.messages.iter().rev().find(|m| m.role == "assistant") else {
self.push_msg("No agent messages to copy yet.".to_string(), MsgLevel::Info);
return;
};
let text = msg.text();
if let Err(e) = write_clipboard(&text) {
self.push_msg(format!("copy failed: {e}"), MsgLevel::Warning);
} else {
self.set_status_msg("Copied last agent message to clipboard");
}
}
pub(super) fn copy_editor_content(&mut self) {
let text = self.editor.expanded_text();
if text.is_empty() {
self.push_msg(
"Editor is empty. Nothing to copy.".to_string(),
MsgLevel::Info,
);
return;
}
if let Err(e) = write_clipboard(&text) {
self.push_msg(format!("copy failed: {e}"), MsgLevel::Warning);
} else {
self.set_status_msg("Copied editor content to clipboard");
}
}
pub(super) fn paste_clipboard(&mut self) {
let file_paths = read_clipboard_file_paths();
if !file_paths.is_empty() {
if file_paths.iter().any(|p| p.chars().any(char::is_control)) {
self.push_msg(
"paste failed: clipboard file path contains control characters".to_string(),
MsgLevel::Warning,
);
return;
}
let bash_mode = self.is_bash_mode();
self.editor.insert_text(&format_clipboard_paths(
&file_paths,
bash_mode,
self.editor.cursor_neighbours(),
));
self.dirty = true;
return;
}
let tmp_for_image = std::env::temp_dir();
if let Some(path) = read_clipboard_image(&tmp_for_image) {
self.editor.insert_text(&path);
self.dirty = true;
return;
}
if let Some(text) = read_clipboard() {
self.editor.paste_text(&text);
self.dirty = true;
} else {
self.push_msg(
"paste failed: no clipboard access".to_string(),
MsgLevel::Warning,
);
}
}
fn is_bash_mode(&self) -> bool {
self.editor
.lines
.first()
.map(|l| l.trim_start().starts_with('!'))
.unwrap_or(false)
}
}
fn format_clipboard_paths(
paths: &[String],
bash_mode: bool,
neighbours: (Option<char>, Option<char>),
) -> String {
let body = if bash_mode {
paths
.iter()
.map(|p| shell_quote_if_needed(p))
.collect::<Vec<_>>()
.join(" ")
} else {
paths.join("\n")
};
let (before, after) = neighbours;
let leading = before.filter(|c| !c.is_whitespace()).map_or("", |_| " ");
let trailing = after.filter(|c| !c.is_whitespace()).map_or("", |_| " ");
format!("{leading}{body}{trailing}")
}
fn shell_quote_if_needed(value: &str) -> String {
let safe = !value.is_empty()
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || "_-./~:@".contains(c));
if safe {
value.to_string()
} else {
format!("'{}'", value.replace('\'', "'\\''"))
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::arc_with_non_send_sync)]
use super::*;
use crate::modes::interactive::app::App;
const WHEEL_STEP: usize = 3;
fn test_app() -> App {
let mut app = App::new();
app.wheel_accel
.set_lines(crate::utils::wheel::WheelScrollLines::Lines(WHEEL_STEP));
app.mouse.log_area = 0;
app.mouse.log_area_h = 40;
app
}
fn alt_left_down(st: &mut App, row: u16) {
st.handle_mouse_left_down(&MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 2,
row,
modifiers: KeyModifiers::ALT,
});
}
#[test]
fn alt_left_click_toggles_collapsible_summary() {
use crate::core::provider::AgentMessage;
use ratatui::backend::TestBackend;
let mut st = test_app();
let mut summary = AgentMessage::user_text("SUMMARYBODY");
summary.role = "compactionSummary".to_string();
st.messages.push(summary);
let backend = TestBackend::new(60, 20);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::modes::interactive::render::render_frame(f, &mut st))
.unwrap();
let id = st.mouse.click_targets[0].id;
let row = st.mouse.click_targets[0].start;
assert!(st.expand_overrides.is_empty());
alt_left_down(&mut st, row);
assert!(st.mouse.sel.is_none(), "Alt+点击折叠块不启动文本选择");
assert!(!st.mouse.dragging);
assert_eq!(st.expand_overrides.get(&id), Some(&true), "首次点击展开");
terminal
.draw(|f| crate::modes::interactive::render::render_frame(f, &mut st))
.unwrap();
let row2 = st.mouse.click_targets[0].start;
alt_left_down(&mut st, row2);
assert_eq!(st.expand_overrides.get(&id), Some(&false), "再次点击折叠");
}
#[test]
fn plain_left_click_on_collapsible_keeps_selection_behaviour() {
use crate::core::provider::AgentMessage;
use ratatui::backend::TestBackend;
let mut st = test_app();
let mut summary = AgentMessage::user_text("SUMMARYBODY");
summary.role = "compactionSummary".to_string();
st.messages.push(summary);
let backend = TestBackend::new(60, 20);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::modes::interactive::render::render_frame(f, &mut st))
.unwrap();
let row = st.mouse.click_targets[0].start;
st.handle_mouse_left_down(&MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 2,
row,
modifiers: KeyModifiers::NONE,
});
assert!(st.expand_overrides.is_empty(), "裸左键不切换展开态");
assert!(st.mouse.sel.is_some(), "裸左键启动文本选择");
assert!(st.mouse.dragging);
}
#[test]
fn copy_editor_content_empty_editor_pushes_hint_only() {
let mut st = test_app();
assert_eq!(st.system_messages.len(), 0);
st.copy_editor_content();
assert_eq!(st.system_messages.len(), 1, "空输入框应只追加一条提示");
}
#[test]
fn middle_click_copies_selection_to_clipboard_and_clears_sel() {
use ratatui::backend::TestBackend;
let mut st = test_app();
for i in 0..5 {
st.messages
.push(crate::core::provider::AgentMessage::user_text(&format!(
"line {:02}",
i
)));
}
let backend = TestBackend::new(50, 10);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::modes::interactive::render::render_frame(f, &mut st))
.unwrap();
let start = st.mouse.view_start;
st.mouse.sel = Some((start, 0, start, usize::MAX));
let expected = st.selected_text().unwrap();
assert!(!expected.is_empty());
st.handle_mouse_middle_down(&MouseEvent {
kind: MouseEventKind::Down(MouseButton::Middle),
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
});
assert_eq!(st.mouse.sel, None, "中键复制后取消选中");
assert!(!st.mouse.dragging);
st.handle_mouse_middle_down(&MouseEvent {
kind: MouseEventKind::Down(MouseButton::Middle),
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
});
assert_eq!(st.mouse.sel, None, "无选中时中键保持无选择");
}
#[test]
fn wheel_over_overlay_scrolls_overlay_only() {
use ratatui::layout::Rect;
let st = Rc::new(RefCell::new(test_app()));
{
let mut s = st.borrow_mut();
let mut view = crate::core::extensions::OverlayView::inline(
"Fleet",
(0..30)
.map(|i| vec![crate::core::extensions::DockSpan::plain(format!("l{i}"))])
.collect(),
6,
);
view.footer.clear();
crate::modes::interactive::overlay::open(&mut s, 1, view);
s.overlay.as_mut().unwrap().content_rows = 5;
s.overlay.as_mut().unwrap().area = Some(Rect::new(0, 5, 40, 5));
s.scroll = 17;
}
let wheel = |kind| MouseEvent {
kind,
column: 10,
row: 6,
modifiers: KeyModifiers::NONE,
};
handle_mouse_event(&st, wheel(MouseEventKind::ScrollDown));
assert_eq!(st.borrow().overlay.as_ref().unwrap().scroll, WHEEL_STEP);
assert_eq!(st.borrow().scroll, 17, "消息区不动");
handle_mouse_event(&st, wheel(MouseEventKind::ScrollUp));
assert_eq!(st.borrow().overlay.as_ref().unwrap().scroll, 0);
handle_mouse_event(&st, wheel(MouseEventKind::ScrollUp));
assert_eq!(st.borrow().overlay.as_ref().unwrap().scroll, 0, "顶部夹紧");
assert_eq!(st.borrow().scroll, 17);
}
#[test]
fn alt_wheel_accelerates_overlay_scroll() {
use crate::core::extensions::{DockSpan, OverlaySize, OverlayView};
use ratatui::layout::Rect;
let long_view = |size| {
let mut view = OverlayView::inline(
"Fleet",
(0..40)
.map(|i| vec![DockSpan::plain(format!("l{i}"))])
.collect(),
6,
);
view.footer.clear();
view.size = size;
view
};
let st = Rc::new(RefCell::new(test_app()));
{
let mut s = st.borrow_mut();
crate::modes::interactive::overlay::open(
&mut s,
1,
long_view(OverlaySize::Inline { max_rows: 6 }),
);
s.overlay.as_mut().unwrap().content_rows = 5;
s.overlay.as_mut().unwrap().area = Some(Rect::new(0, 5, 40, 5));
}
handle_mouse_event(
&st,
MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 10,
row: 6,
modifiers: KeyModifiers::ALT,
},
);
assert_eq!(
st.borrow().overlay.as_ref().unwrap().scroll,
WHEEL_STEP * ALT_WHEEL_MULTIPLIER,
"inline 覆盖层 Alt 加速"
);
let st = Rc::new(RefCell::new(test_app()));
{
let mut s = st.borrow_mut();
crate::modes::interactive::overlay::open(
&mut s,
1,
long_view(OverlaySize::Panel { max_rows: 10 }),
);
s.overlay.as_mut().unwrap().content_rows = 5;
s.overlay.as_mut().unwrap().area = Some(Rect::new(0, 5, 40, 10));
}
handle_mouse_event(
&st,
MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 10,
row: 6,
modifiers: KeyModifiers::ALT,
},
);
assert_eq!(
st.borrow().overlay.as_ref().unwrap().scroll,
WHEEL_STEP * ALT_WHEEL_MULTIPLIER,
"Panel 覆盖层 Alt 加速"
);
let st = Rc::new(RefCell::new(test_app()));
{
let mut s = st.borrow_mut();
crate::modes::interactive::overlay::open(&mut s, 1, long_view(OverlaySize::Fullscreen));
s.overlay.as_mut().unwrap().content_rows = 5;
}
handle_mouse_event(
&st,
MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 1,
row: 1,
modifiers: KeyModifiers::ALT,
},
);
assert_eq!(
st.borrow().overlay.as_ref().unwrap().scroll,
WHEEL_STEP * ALT_WHEEL_MULTIPLIER,
"Fullscreen 覆盖层 Alt 加速"
);
}
#[test]
fn alt_wheel_over_rendered_panel_overlay_accelerates() {
use crate::core::extensions::{DockSpan, OverlaySize, OverlayView};
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
let mut app = test_app();
let mut view = OverlayView::inline(
"Fleet",
(0..40)
.map(|i| vec![DockSpan::plain(format!("l{i}"))])
.collect(),
6,
);
view.footer.clear();
view.size = OverlaySize::Panel { max_rows: 10 };
crate::modes::interactive::overlay::open(&mut app, 1, view);
let area = Rect::new(0, 20, 60, 10);
let backend = TestBackend::new(60, 40);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
crate::modes::interactive::render::overlay::render(
f,
area,
&mut app,
OverlaySize::Panel { max_rows: 10 },
);
})
.unwrap();
assert!(
app.overlay.as_ref().unwrap().area.is_some(),
"渲染应记录覆盖层区域(否则滚轮命中失败)"
);
app.scroll = 17;
let st = Rc::new(RefCell::new(app));
handle_mouse_event(
&st,
MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 5,
row: 21,
modifiers: KeyModifiers::ALT,
},
);
assert_eq!(
st.borrow().overlay.as_ref().unwrap().scroll,
WHEEL_STEP * ALT_WHEEL_MULTIPLIER,
"Panel 覆盖层(真实渲染路径)Alt 加速"
);
assert_eq!(st.borrow().scroll, 17, "消息区不动");
}
#[test]
fn wheel_over_dock_scrolls_dock_only() {
use ratatui::layout::Rect;
let st = Rc::new(RefCell::new(test_app()));
{
let mut s = st.borrow_mut();
s.dock_area = Some(Rect::new(0, 5, 40, 11));
s.dock_offset = 2;
s.scroll = 17;
}
let wheel = |kind| MouseEvent {
kind,
column: 10,
row: 6,
modifiers: KeyModifiers::NONE,
};
handle_mouse_event(&st, wheel(MouseEventKind::ScrollDown));
assert_eq!(st.borrow().dock_offset, 2 + WHEEL_STEP);
assert_eq!(st.borrow().scroll, 17, "面板内滚轮不得滚动消息区");
handle_mouse_event(&st, wheel(MouseEventKind::ScrollUp));
assert_eq!(st.borrow().dock_offset, 2);
handle_mouse_event(&st, wheel(MouseEventKind::ScrollUp));
handle_mouse_event(&st, wheel(MouseEventKind::ScrollUp));
handle_mouse_event(&st, wheel(MouseEventKind::ScrollUp));
assert_eq!(st.borrow().dock_offset, 0, "顶部夹紧且不接力消息区");
assert_eq!(st.borrow().scroll, 17);
}
#[test]
fn wheel_outside_dock_scrolls_messages() {
use ratatui::layout::Rect;
let st = Rc::new(RefCell::new(test_app()));
{
let mut s = st.borrow_mut();
s.dock_area = Some(Rect::new(0, 5, 40, 11));
s.dock_offset = 2;
s.scroll = 17;
}
handle_mouse_event(
&st,
MouseEvent {
kind: MouseEventKind::ScrollUp,
column: 10,
row: 30,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(
st.borrow().scroll,
17 + WHEEL_STEP,
"消息区按 WHEEL_STEP 滚动"
);
assert_eq!(st.borrow().dock_offset, 2, "面板偏移不变");
}
#[test]
fn wheel_outside_log_area_does_not_scroll_messages() {
use ratatui::layout::Rect;
let st = Rc::new(RefCell::new(test_app()));
{
let mut s = st.borrow_mut();
s.mouse.log_area = 0;
s.mouse.log_area_h = 20;
s.scroll = 17;
s.dock_area = Some(Rect::new(0, 41, 40, 5));
}
for row in [20, 25, 33, 39] {
for kind in [MouseEventKind::ScrollUp, MouseEventKind::ScrollDown] {
handle_mouse_event(
&st,
MouseEvent {
kind,
column: 10,
row,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(st.borrow().scroll, 17, "第 {row} 行的滚轮不该滚输出区");
}
}
st.borrow_mut().mouse.log_area_h = 0;
handle_mouse_event(
&st,
MouseEvent {
kind: MouseEventKind::ScrollUp,
column: 10,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(st.borrow().scroll, 17);
}
#[test]
fn alt_wheel_accelerates_scroll() {
use ratatui::layout::Rect;
let st = Rc::new(RefCell::new(test_app()));
let wheel = |kind, mods| MouseEvent {
kind,
column: 10,
row: 30,
modifiers: mods,
};
handle_mouse_event(&st, wheel(MouseEventKind::ScrollUp, KeyModifiers::NONE));
assert_eq!(st.borrow().scroll, WHEEL_STEP);
st.borrow_mut().scroll = 0;
handle_mouse_event(&st, wheel(MouseEventKind::ScrollUp, KeyModifiers::ALT));
assert_eq!(st.borrow().scroll, WHEEL_STEP * ALT_WHEEL_MULTIPLIER);
st.borrow_mut().scroll = 100;
handle_mouse_event(&st, wheel(MouseEventKind::ScrollDown, KeyModifiers::ALT));
assert_eq!(st.borrow().scroll, 100 - WHEEL_STEP * ALT_WHEEL_MULTIPLIER);
st.borrow_mut().scroll = 1;
handle_mouse_event(&st, wheel(MouseEventKind::ScrollDown, KeyModifiers::ALT));
assert_eq!(st.borrow().scroll, 0, "向下夹紧");
{
let mut s = st.borrow_mut();
s.scroll = 0;
s.dock_area = Some(Rect::new(0, 5, 40, 11));
s.dock_offset = 0;
}
handle_mouse_event(
&st,
MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 10,
row: 6,
modifiers: KeyModifiers::ALT,
},
);
assert_eq!(
st.borrow().dock_offset,
WHEEL_STEP * ALT_WHEEL_MULTIPLIER,
"停靠面板 Alt 加速"
);
assert_eq!(st.borrow().scroll, 0, "面板内不滚消息区");
}
#[test]
fn drag_to_track_top_reaches_content_start_from_bottom() {
let mut st = test_app();
st.mouse.log_area = 10;
st.mouse.log_area_h = 10;
st.mouse.log_total = 120;
let max_scroll = 120 - 10;
st.scroll = 5; st.mouse.scroll_thumb = Some((6, 2)); st.mouse.scroll_press = Some((16, 0)); st.scrollbar_drag_to(10); assert_eq!(
st.scroll, max_scroll,
"拖到 track 顶必须看到开头, scroll={}",
st.scroll
);
}
#[test]
fn drag_from_top_press_keeps_grab_offset() {
let mut st = test_app();
st.mouse.log_area = 10;
st.mouse.log_area_h = 10;
st.mouse.log_total = 120;
let max_scroll = 120 - 10;
st.scroll = 55;
st.mouse.scroll_thumb = Some((4, 3)); st.mouse.scroll_press = Some((16, 2)); st.scrollbar_drag_to(10); assert_eq!(st.scroll, max_scroll, "offset 跟手拖到顶也必须到开头");
st.scrollbar_drag_to(10 + (10 - 1) as u16); let rel = (10 - 1) as usize;
let _ratio = rel as f64 / 9.0;
let expected = ((max_scroll as f64 * (1.0 - 7.0 / 9.0)).round() as usize).min(max_scroll);
assert_eq!(st.scroll, expected, "跟手偏移下拖到底的滚动位置");
}
#[test]
fn drag_does_not_accumulate_thumb_offset_drift() {
let mut st = test_app();
st.mouse.log_area = 0;
st.mouse.log_area_h = 40;
st.mouse.log_total = 1000;
let _track = 40usize;
let max_scroll = 1000 - 40;
st.scroll = 714;
st.mouse.scroll_thumb = Some((10, 2));
st.mouse.scroll_press = Some((10, 0));
st.scrollbar_drag_to(8);
let expected = ((max_scroll as f64 * (1.0 - 8.0 / 39.0)).round() as usize).min(max_scroll);
assert_eq!(st.scroll, expected, "第一段拖动跟手: {}", st.scroll);
st.mouse.scroll_thumb = Some((8, 2));
st.scrollbar_drag_to(6);
let expected = ((max_scroll as f64 * (1.0 - 6.0 / 39.0)).round() as usize).min(max_scroll);
assert_eq!(
st.scroll, expected,
"拖动中 thumb_start 更新后仍跟手: {}",
st.scroll
);
}
#[test]
fn scrollbar_click_jump_and_grab_drag() {
let mut st = test_app();
st.mouse.log_area = 10; st.mouse.log_area_h = 10;
st.mouse.log_total = 120;
let track = 10usize;
let max_scroll = 120 - 10;
st.scroll = 0;
st.mouse.scroll_thumb = Some((5, 2));
st.scrollbar_click_jump(10);
assert_eq!(st.scroll, max_scroll, "track 顶端点击 = 内容顶部");
st.scrollbar_click_jump(19);
assert_eq!(st.scroll, 0, "track 底端点击 = 内容底部");
st.scroll = 60;
st.mouse.scroll_thumb = Some((5, 2)); st.mouse.scroll_press = Some((15, 0)); assert!(st.scrollbar_press_on_thumb(15), "落在 thumb 上进入抓取");
assert_eq!(st.scroll, 60, "thumb 上按下不跳转");
st.mouse.scroll_press = Some((15, 0));
st.scrollbar_drag_to((15 + (track - 1 - 5)) as u16); assert_eq!(
st.scroll, 0,
"thumb 顶拖到 track 底 = 内容底部, scroll={}",
st.scroll
);
st.scrollbar_drag_to(20);
assert_eq!(st.scroll, 0, "thumb 贴底(clamp)= 内容底部");
st.scrollbar_drag_to(10);
assert_eq!(st.scroll, max_scroll, "thumb 拖到 track 顶 = 内容顶部");
st.scrollbar_drag_to(3);
assert_eq!(st.scroll, max_scroll, "上方越界 clamp 到内容顶部");
st.scrollbar_drag_to(99);
assert_eq!(st.scroll, 0, "下方越界 clamp 到内容底部");
st.mouse.log_total = 5;
st.mouse.log_area_h = 10;
st.scrollbar_click_jump(0);
assert_eq!(st.scroll, 0, "内容不足一屏时保持 0");
st.mouse.log_total = 120;
st.mouse.log_area_h = 10;
st.scroll = 0;
let denom = (120 - 1 + track) as f64;
let pos = (120 - 1) as f64 * (1.0 - 0.0 / max_scroll as f64);
let tl = ((track as f64 * track as f64 / denom).round() as usize).clamp(1, track);
let ts = ((pos * track as f64 / denom).round() as usize).min(track.saturating_sub(tl));
assert_eq!(ts + tl, track, "scroll=0 时 thumb 贴 track 底");
}
#[test]
fn drag_edge_auto_scroll_up_after_dwell() {
let mut st = test_app();
st.mouse.log_area = 0;
st.mouse.log_area_h = 10;
st.mouse.log_total = 120;
st.scroll = 50;
st.mouse.dragging = true;
st.mouse.drag_row = Some(0); st.mouse.sel = Some((60, 2, 60, 2));
for _ in 0..EDGE_DWELL_TICKS {
assert!(!st.auto_scroll_tick(), "滞留不足不滚动");
}
assert_eq!(st.scroll, 50);
assert!(st.auto_scroll_tick());
assert_eq!(st.scroll, 50 + DRAG_EDGE_STEP, "上滚一步");
let start = st
.mouse
.log_total
.saturating_sub(st.mouse.log_area_h as usize)
.saturating_sub(st.scroll);
assert_eq!(st.mouse.sel, Some((60, 2, start, 0)), "终点钉视口顶行开头");
let mut guard = 0;
while st.auto_scroll_tick() && guard < 1000 {
guard += 1;
}
assert_eq!(
st.scroll,
st.mouse.log_total - st.mouse.log_area_h as usize,
"滚到内容顶部"
);
st.mouse.dragging = false;
assert!(!st.auto_scroll_tick());
assert_eq!(st.mouse.edge_dwell, 0);
}
#[test]
fn drag_edge_auto_scroll_down_pins_bottom_line() {
let mut st = test_app();
st.mouse.log_area = 0;
st.mouse.log_area_h = 10;
st.mouse.log_total = 120;
st.scroll = 60;
st.mouse.dragging = true;
st.mouse.drag_row = Some(10); st.mouse.sel = Some((45, 0, 45, 0));
for _ in 0..EDGE_DWELL_TICKS {
st.auto_scroll_tick();
}
assert!(st.auto_scroll_tick(), "满阈值后下滚");
assert_eq!(st.scroll, 60 - DRAG_EDGE_STEP);
let start = st
.mouse
.log_total
.saturating_sub(st.mouse.log_area_h as usize)
.saturating_sub(st.scroll);
let end_line = (start + 9).min(st.mouse.log_total - 1);
assert_eq!(
st.mouse.sel,
Some((45, 0, end_line, usize::MAX)),
"终点钉底行行尾"
);
st.scroll = 2;
st.mouse.edge_dwell = EDGE_DWELL_TICKS;
assert!(st.auto_scroll_tick());
assert_eq!(st.scroll, 0);
assert!(!st.auto_scroll_tick(), "贴底后不再滚");
}
#[test]
fn drag_edge_ignores_pointer_inside_area() {
let mut st = test_app();
st.mouse.log_area = 0;
st.mouse.log_area_h = 10;
st.mouse.log_total = 120;
st.scroll = 50;
st.mouse.dragging = true;
st.mouse.drag_row = Some(5); st.mouse.edge_dwell = EDGE_DWELL_TICKS;
assert!(!st.auto_scroll_tick());
assert_eq!(st.scroll, 50, "区域内不滚动");
assert_eq!(st.mouse.edge_dwell, 0, "离开边缘复位滞留计数");
}
#[test]
fn drag_edge_bottom_row_triggers_down_scroll() {
let mut st = test_app();
st.mouse.log_area = 0;
st.mouse.log_area_h = 6;
st.mouse.log_total = 120;
st.scroll = 60;
st.mouse.dragging = true;
st.mouse.drag_row = Some(5); st.mouse.sel = Some((45, 0, 45, 0));
for _ in 0..EDGE_DWELL_TICKS {
assert!(!st.auto_scroll_tick());
}
assert!(st.auto_scroll_tick(), "底行滞留满阈值后下滚");
assert_eq!(st.scroll, 60 - DRAG_EDGE_STEP, "底行停住触发下滚");
let start = st
.mouse
.log_total
.saturating_sub(st.mouse.log_area_h as usize)
.saturating_sub(st.scroll);
let end_line = (start + 5).min(st.mouse.log_total - 1);
assert_eq!(st.mouse.sel, Some((45, 0, end_line, usize::MAX)));
}
#[test]
fn drag_event_at_edge_keeps_dwell() {
let mut st = test_app();
st.mouse.visible = vec!["aaaa".to_string(); 6];
st.mouse.log_area = 0;
st.mouse.log_area_h = 6;
st.mouse.dragging = true;
st.mouse.edge_dwell = EDGE_DWELL_TICKS - 1; st.mouse.sel = Some((10, 0, 10, 0));
let ev = MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 3,
row: 5, modifiers: KeyModifiers::NONE,
};
st.handle_mouse_left_drag(&ev);
assert_eq!(
st.mouse.edge_dwell,
EDGE_DWELL_TICKS - 1,
"边缘内拖动保留滞留计数"
);
let ev = MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 3,
row: 3, modifiers: KeyModifiers::NONE,
};
st.handle_mouse_left_drag(&ev);
assert_eq!(st.mouse.edge_dwell, 0, "离开边缘清零");
}
#[test]
fn scroll_drag_edge_immediate_step() {
let mut st = test_app();
st.mouse.log_total = 120;
st.mouse.sel = Some((60, 2, 60, 2));
st.scroll = 60;
assert!(st.scroll_drag_edge(10, false), "下滚一步");
assert_eq!(st.scroll, 60 - DRAG_EDGE_STEP);
assert!(st.scroll_drag_edge(10, true), "上滚一步");
assert_eq!(st.scroll, 60);
st.scroll = 0;
assert!(!st.scroll_drag_edge(10, false), "贴底不滚");
st.scroll = 110; assert!(!st.scroll_drag_edge(10, true), "顶部不滚");
}
#[test]
fn fullscreen_overlay_owns_mouse_scroll_and_scrollbar() {
use crate::core::extensions::{DockSpan, OverlaySize, OverlayView};
use ratatui::layout::Rect;
let st = Rc::new(RefCell::new(test_app()));
{
let mut s = st.borrow_mut();
let mut view = OverlayView::inline(
"Viewer",
(0..50)
.map(|i| vec![DockSpan::plain(format!("l{i}"))])
.collect(),
6,
);
view.size = OverlaySize::Fullscreen;
view.footer.clear();
crate::modes::interactive::overlay::open(&mut s, 1, view);
let o = s.overlay.as_mut().unwrap();
o.content_rows = 5;
o.area = Some(Rect::new(0, 0, 40, 10));
crate::modes::interactive::overlay::record_content_geometry(o, 1, 5, Vec::new());
crate::modes::interactive::overlay::record_scrollbar(o, Some(39), Some((0, 2)));
s.scroll = 17;
}
handle_mouse_event(
&st,
MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 10,
row: 12,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(st.borrow().overlay.as_ref().unwrap().scroll, WHEEL_STEP);
assert_eq!(st.borrow().scroll, 17, "主页消息区不动");
handle_mouse_event(
&st,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 39,
row: 1,
modifiers: KeyModifiers::NONE,
},
);
assert!(st.borrow().overlay.as_ref().unwrap().sel.scroll_dragging);
handle_mouse_event(
&st,
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 39,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(st.borrow().overlay.as_ref().unwrap().scroll, 45);
assert_eq!(st.borrow().scroll, 17, "主页滚动位置不变");
}
#[test]
fn overlay_drag_selects_content() {
use crate::core::extensions::{DockSpan, OverlaySize, OverlayView};
use ratatui::layout::Rect;
let st = Rc::new(RefCell::new(test_app()));
{
let mut s = st.borrow_mut();
let mut view = OverlayView::inline(
"Viewer",
vec![
vec![DockSpan::plain("hello world")],
vec![DockSpan::plain("second line")],
],
6,
);
view.size = OverlaySize::Fullscreen;
view.footer.clear();
crate::modes::interactive::overlay::open(&mut s, 1, view);
let o = s.overlay.as_mut().unwrap();
o.content_rows = 5;
o.area = Some(Rect::new(0, 0, 40, 10));
crate::modes::interactive::overlay::record_content_geometry(
o,
1,
5,
vec!["hello world".to_string(), "second line".to_string()],
);
crate::modes::interactive::overlay::record_scrollbar(o, None, None);
}
handle_mouse_event(
&st,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 0,
row: 1,
modifiers: KeyModifiers::NONE,
},
);
handle_mouse_event(
&st,
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 6,
row: 2,
modifiers: KeyModifiers::NONE,
},
);
{
let s = st.borrow();
assert_eq!(s.overlay.as_ref().unwrap().sel.sel, Some((0, 0, 1, 6)));
let text = crate::modes::interactive::overlay::selected_text(&s).unwrap();
assert!(text.contains("hello world"), "{text:?}");
assert!(text.contains("second"), "{text:?}");
}
}
#[test]
fn clipboard_paths_join_one_per_line_outside_bash_mode() {
let paths = vec!["/tmp/photo.png".to_string(), "/tmp/b.png".to_string()];
assert_eq!(
format_clipboard_paths(&paths, false, (None, None)),
"/tmp/photo.png\n/tmp/b.png"
);
}
#[test]
fn clipboard_paths_are_shell_quoted_in_bash_mode() {
let paths = vec![
"/tmp/My Photos/photo.png".to_string(),
"/tmp/$(touch hacked).png".to_string(),
"/tmp/it's.png".to_string(),
"/tmp/plain.png".to_string(),
];
assert_eq!(
format_clipboard_paths(&paths, true, (None, None)),
"'/tmp/My Photos/photo.png' '/tmp/$(touch hacked).png' '/tmp/it'\\''s.png' /tmp/plain.png"
);
}
#[test]
fn clipboard_paths_keep_space_from_adjacent_text() {
let paths = vec!["/tmp/photo.png".to_string()];
assert_eq!(
format_clipboard_paths(&paths, false, (Some(':'), None)),
" /tmp/photo.png"
);
assert_eq!(
format_clipboard_paths(&paths, false, (None, Some('x'))),
"/tmp/photo.png "
);
assert_eq!(
format_clipboard_paths(&paths, false, (None, None)),
"/tmp/photo.png"
);
assert_eq!(
format_clipboard_paths(&paths, false, (Some(' '), Some(' '))),
"/tmp/photo.png"
);
}
#[test]
fn cursor_neighbours_reports_adjacent_chars() {
let mut app = test_app();
app.editor.replace_all("Review:");
app.editor.cursor_col = app.editor.lines[0].chars().count();
assert_eq!(app.editor.cursor_neighbours(), (Some(':'), None));
app.editor.cursor_col = 0;
assert_eq!(app.editor.cursor_neighbours(), (None, Some('R')));
}
}