From b79c05776b1dc677e9b3388d8a1c45b51d46448e Mon Sep 17 00:00:00 2001 From: kuddev Date: Thu, 13 Aug 2026 12:42:26 +0800 Subject: [PATCH] feat(gpui): terminal mouse semantics parity - app reporting, right-click, copy-on-select Mouse-mode apps (vim/htop) now own the pointer: press/release/drag/motion/wheel are encoded per protocol (SGR, normal, UTF-8 extension) and reported to the app, with Shift as the local-selection escape hatch - same as the legacy shell. The encoder is a pure module ported verbatim from input/mouse.rs with unit tests locking the byte sequences. Right-click follows the legacy Windows convention (copy+clear when a selection exists, paste otherwise), copy_on_select fires on release, Shift+click extends an existing selection, and Shift+PageUp/PageDown/Home/End page the scrollback on the primary screen. --- nebula_gpui/src/terminal/mod.rs | 1 + nebula_gpui/src/terminal/mouse_protocol.rs | 190 ++++++++++++++ nebula_gpui/src/terminal/view.rs | 277 ++++++++++++++++++--- 3 files changed, 438 insertions(+), 30 deletions(-) create mode 100644 nebula_gpui/src/terminal/mouse_protocol.rs diff --git a/nebula_gpui/src/terminal/mod.rs b/nebula_gpui/src/terminal/mod.rs index 349e903..db0dfb4 100644 --- a/nebula_gpui/src/terminal/mod.rs +++ b/nebula_gpui/src/terminal/mod.rs @@ -6,5 +6,6 @@ pub mod colors; pub mod element; pub mod keymap; +pub mod mouse_protocol; pub mod session; pub mod view; diff --git a/nebula_gpui/src/terminal/mouse_protocol.rs b/nebula_gpui/src/terminal/mouse_protocol.rs new file mode 100644 index 0000000..8451bb8 --- /dev/null +++ b/nebula_gpui/src/terminal/mouse_protocol.rs @@ -0,0 +1,190 @@ +//! 鼠标上报协议编码:X10/normal、SGR、UTF-8 扩展坐标。 +//! +//! 从旧壳 `nebula_app/src/input/mouse.rs` 的 `mouse_report` / +//! `normal_mouse_report` / `sgr_mouse_report` 逐字对照移植成纯函数—— +//! 字节序列以旧壳行为为权威,这里只做"无 UI 状态"化,方便单测锁定。 +//! +//! 坐标约定:`point` 是 buffer 坐标(`viewport_to_point` 之后),负行 +//! 意味着鼠标落在回滚区,任何协议都不上报。 + +use nebula_terminal::index::Point as TermPoint; +use nebula_terminal::term::TermMode; + +/// 按钮码(xterm):左 0、中 1、右 2;滚轮上/下 64/65。 +/// 拖动在按钮码上 +32;无按键的纯移动固定 35。 +pub const BUTTON_LEFT: u8 = 0; +pub const BUTTON_MIDDLE: u8 = 1; +pub const BUTTON_RIGHT: u8 = 2; +pub const WHEEL_UP: u8 = 64; +pub const WHEEL_DOWN: u8 = 65; +pub const DRAG_OFFSET: u8 = 32; +pub const MOTION_ONLY: u8 = 35; + +#[derive(Clone, Copy, Default)] +pub struct ReportMods { + pub shift: bool, + pub alt: bool, + pub control: bool, +} + +impl ReportMods { + /// xterm 修饰位:shift +4、alt +8、ctrl +16。 + fn value(self) -> u8 { + let mut mods = 0; + if self.shift { + mods += 4; + } + if self.alt { + mods += 8; + } + if self.control { + mods += 16; + } + mods + } +} + +/// 编码一次鼠标上报。`None` 表示这次事件不应上报(回滚区、normal 模式 +/// 坐标超界)。`pressed=false` 只对按键有意义;滚轮永远按 pressed 上报。 +pub fn report( + mode: &TermMode, + point: TermPoint, + button: u8, + pressed: bool, + mods: ReportMods, +) -> Option> { + // 回滚区不属于应用坐标空间。 + if point.line.0 < 0 { + return None; + } + + let mods = mods.value(); + if mode.contains(TermMode::SGR_MOUSE) { + let c = if pressed { 'M' } else { 'm' }; + Some( + format!("\x1b[<{};{};{}{}", button + mods, point.column.0 + 1, point.line.0 + 1, c) + .into_bytes(), + ) + } else if pressed { + normal_report(mode, point, button + mods) + } else { + // normal 协议无法区分哪个键抬起:释放一律报 3。 + normal_report(mode, point, 3 + mods) + } +} + +fn normal_report(mode: &TermMode, point: TermPoint, button: u8) -> Option> { + let utf8 = mode.contains(TermMode::UTF8_MOUSE); + let max_point: i32 = if utf8 { 2015 } else { 223 }; + + if point.line.0 >= max_point || point.column.0 >= max_point as usize { + return None; + } + + let mut msg = vec![b'\x1b', b'[', b'M', 32 + button]; + + let mouse_pos_encode = |pos: usize| -> [u8; 2] { + let pos = 32 + 1 + pos; + [(0xC0 + pos / 64) as u8, (0x80 + (pos & 63)) as u8] + }; + + if utf8 && point.column.0 >= 95 { + msg.extend_from_slice(&mouse_pos_encode(point.column.0)); + } else { + msg.push(32 + 1 + point.column.0 as u8); + } + + if utf8 && point.line.0 >= 95 { + msg.extend_from_slice(&mouse_pos_encode(point.line.0 as usize)); + } else { + msg.push(32 + 1 + point.line.0 as u8); + } + + Some(msg) +} + +#[cfg(test)] +mod tests { + use super::*; + use nebula_terminal::index::{Column, Line}; + + fn at(line: i32, col: usize) -> TermPoint { + TermPoint::new(Line(line), Column(col)) + } + + const NO_MODS: ReportMods = ReportMods { shift: false, alt: false, control: false }; + + #[test] + fn sgr_press_and_release() { + let mode = TermMode::SGR_MOUSE; + assert_eq!( + report(&mode, at(0, 0), BUTTON_LEFT, true, NO_MODS), + Some(b"\x1b[<0;1;1M".to_vec()) + ); + assert_eq!( + report(&mode, at(9, 4), BUTTON_LEFT, false, NO_MODS), + Some(b"\x1b[<0;5;10m".to_vec()) + ); + } + + #[test] + fn sgr_modifiers_add_to_button() { + let mode = TermMode::SGR_MOUSE; + let mods = ReportMods { shift: false, alt: false, control: true }; + assert_eq!( + report(&mode, at(0, 0), BUTTON_RIGHT, true, mods), + Some(b"\x1b[<18;1;1M".to_vec()) + ); + } + + #[test] + fn normal_press_release_and_bounds() { + let mode = TermMode::empty(); + // 按下左键 (0,0):CSI M, 32+0, 33, 33。 + assert_eq!( + report(&mode, at(0, 0), BUTTON_LEFT, true, NO_MODS), + Some(vec![0x1b, b'[', b'M', 32, 33, 33]) + ); + // 释放一律报按钮 3。 + assert_eq!( + report(&mode, at(0, 0), BUTTON_LEFT, false, NO_MODS), + Some(vec![0x1b, b'[', b'M', 35, 33, 33]) + ); + // normal 协议坐标上限 223。 + assert_eq!(report(&mode, at(0, 223), BUTTON_LEFT, true, NO_MODS), None); + assert_eq!(report(&mode, at(223, 0), BUTTON_LEFT, true, NO_MODS), None); + } + + #[test] + fn utf8_mouse_encodes_wide_coordinates() { + let mode = TermMode::UTF8_MOUSE; + // col=100 → pos=133 → 0xC2 0x85(两字节),行仍单字节。 + assert_eq!( + report(&mode, at(0, 100), BUTTON_LEFT, true, NO_MODS), + Some(vec![0x1b, b'[', b'M', 32, 0xC2, 0x85, 33]) + ); + // UTF-8 扩展上限 2015。 + assert_eq!(report(&mode, at(0, 2015), BUTTON_LEFT, true, NO_MODS), None); + assert!(report(&mode, at(0, 2014), BUTTON_LEFT, true, NO_MODS).is_some()); + } + + #[test] + fn scrollback_is_never_reported() { + for mode in [TermMode::SGR_MOUSE, TermMode::empty()] { + assert_eq!(report(&mode, at(-1, 0), BUTTON_LEFT, true, NO_MODS), None); + } + } + + #[test] + fn wheel_uses_press_encoding() { + let mode = TermMode::SGR_MOUSE; + assert_eq!( + report(&mode, at(2, 2), WHEEL_UP, true, NO_MODS), + Some(b"\x1b[<64;3;3M".to_vec()) + ); + assert_eq!( + report(&mode, at(2, 2), WHEEL_DOWN, true, NO_MODS), + Some(b"\x1b[<65;3;3M".to_vec()) + ); + } +} diff --git a/nebula_gpui/src/terminal/view.rs b/nebula_gpui/src/terminal/view.rs index a291f50..4245c06 100644 --- a/nebula_gpui/src/terminal/view.rs +++ b/nebula_gpui/src/terminal/view.rs @@ -21,6 +21,7 @@ use std::sync::Arc; use super::colors::Palette; use super::element::TerminalElement; use super::keymap; +use super::mouse_protocol; use super::session::{self, GridSize, TerminalSession}; use crate::config::Settings; @@ -58,31 +59,39 @@ pub struct TerminalView { viewports: ViewportTracker, scroll_px: f32, selecting: bool, + /// 选中即复制(旧壳 `copy_on_select`);关闭时复制交给右键路径。 + copy_on_select: bool, + /// 鼠标模式下最后上报的单元格:move 事件按"进入新单元格"去重。 + last_report_point: Option, } impl TerminalView { pub fn new(cx: &mut Context) -> Self { // 字体、调色板与终端启动配置来自用户配置(nebula.toml + // nebula_settings.txt,bootstrap 时装载为全局 Settings)。 - let (families, font_size, palette, term_config) = match cx.try_global::() { - Some(settings) => ( - [ - settings.font_family.clone(), - settings.font_bold_family.clone(), - settings.font_italic_family.clone(), - settings.font_bold_italic_family.clone(), - ], - px(settings.font_size_px), - Arc::new(settings.palette.clone()), - settings.term_config(), - ), - None => ( - std::array::from_fn(|_| String::from("Cascadia Mono")), - px(15.0), - Arc::new(Palette::default()), - nebula_terminal::term::Config::default(), - ), - }; + let (families, font_size, palette, term_config, copy_on_select) = + match cx.try_global::() { + Some(settings) => ( + [ + settings.font_family.clone(), + settings.font_bold_family.clone(), + settings.font_italic_family.clone(), + settings.font_bold_italic_family.clone(), + ], + px(settings.font_size_px), + Arc::new(settings.palette.clone()), + settings.term_config(), + settings.copy_on_select, + ), + None => ( + std::array::from_fn(|_| String::from("Cascadia Mono")), + px(15.0), + Arc::new(Palette::default()), + nebula_terminal::term::Config::default(), + // 旧壳的出厂默认即开。 + true, + ), + }; // to_string:SharedString 的 &str 转换要求 'static,字体名是运行时值。 let mono = |family: &str, weight: FontWeight, style: FontStyle| Font { weight, @@ -147,6 +156,8 @@ impl TerminalView { viewports: ViewportTracker::default(), scroll_px: 0.0, selecting: false, + copy_on_select, + last_report_point: None, } } @@ -339,6 +350,28 @@ impl TerminalView { } let mode = self.term_mode(); + + // 回滚快捷键(对齐旧壳默认绑定,仅主屏):Shift+PageUp/PageDown + // 翻页、Shift+Home/End 到顶/到底;备用屏下不拦截,交给编码器 + // 把带修饰的键序发给应用(less 自己处理 Shift+PageUp)。 + if mods.shift && !mods.control && !mods.alt && !mode.contains(TermMode::ALT_SCREEN) { + let scroll = match ks.key.as_str() { + "pageup" => Some(Scroll::PageUp), + "pagedown" => Some(Scroll::PageDown), + "home" => Some(Scroll::Top), + "end" => Some(Scroll::Bottom), + _ => None, + }; + if let Some(scroll) = scroll { + if let Some(session) = &self.session { + session.term.lock().scroll_display(scroll); + } + cx.notify(); + cx.stop_propagation(); + return; + } + } + if let Some(bytes) = keymap::encode(ks, &mode) { self.write_input(bytes, cx); cx.stop_propagation(); @@ -357,6 +390,30 @@ impl TerminalView { (viewport_to_point(display_offset, TermPoint::new(row, Column(col))), side) } + /// 应用是否接管了鼠标(vim/htop 等)。Shift 按住时强制旁路——这是 + /// 终端的通用逃生门:应用吃鼠标时用户仍能选择/复制。 + fn mouse_mode_active(&self, mods: &gpui::Modifiers) -> bool { + !mods.shift && self.term_mode().intersects(TermMode::MOUSE_MODE) + } + + /// 把一次鼠标事件按当前协议(SGR/normal/UTF-8)编码上报给应用。 + fn send_mouse_report( + &mut self, + position: Point, + button: u8, + pressed: bool, + mods: &gpui::Modifiers, + ) { + let (point, _) = self.grid_point(position); + self.last_report_point = Some(point); + let mode = self.term_mode(); + let mods = + mouse_protocol::ReportMods { shift: mods.shift, alt: mods.alt, control: mods.control }; + if let Some(bytes) = mouse_protocol::report(&mode, point, button, pressed, mods) { + self.write_bytes(bytes); + } + } + fn on_mouse_down( &mut self, event: &MouseDownEvent, @@ -364,14 +421,36 @@ impl TerminalView { cx: &mut Context, ) { window.focus(&self.focus_handle); - let Some(session) = &self.session else { return }; + if self.session.is_none() { + return; + } + if self.mouse_mode_active(&event.modifiers) { + self.send_mouse_report( + event.position, + mouse_protocol::BUTTON_LEFT, + true, + &event.modifiers, + ); + return; + } let (point, side) = self.grid_point(event.position); let ty = match event.click_count { 1 => SelectionType::Simple, 2 => SelectionType::Semantic, _ => SelectionType::Lines, }; - session.term.lock().selection = Some(Selection::new(ty, point, side)); + if let Some(session) = &self.session { + let mut term = session.term.lock(); + // Shift+单击扩展既有选区到点击处(原生文本框行为,对齐旧壳); + // 其余情况都开新选区。 + let extend = event.modifiers.shift + && event.click_count == 1 + && term.selection.as_ref().is_some_and(|s| !s.is_empty()); + match term.selection.as_mut() { + Some(selection) if extend => selection.update(point, side), + _ => term.selection = Some(Selection::new(ty, point, side)), + } + } self.selecting = true; cx.notify(); } @@ -382,20 +461,144 @@ impl TerminalView { _window: &mut Window, cx: &mut Context, ) { - if !self.selecting || event.pressed_button != Some(MouseButton::Left) { + if self.selecting { + if event.pressed_button != Some(MouseButton::Left) { + return; + } + let (point, side) = self.grid_point(event.position); + if let Some(session) = &self.session { + if let Some(selection) = session.term.lock().selection.as_mut() { + selection.update(point, side); + } + } + cx.notify(); return; } - let (point, side) = self.grid_point(event.position); - if let Some(session) = &self.session { - if let Some(selection) = session.term.lock().selection.as_mut() { - selection.update(point, side); - } + if !self.mouse_mode_active(&event.modifiers) { + return; + } + // 鼠标模式的移动上报:拖动 = 按钮码+32(需 DRAG 或 MOTION 任一), + // 无按键纯移动 = 35(仅 MOTION);同一单元格内的移动不重报。 + let mode = self.term_mode(); + if !mode.intersects(TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION) { + return; + } + let button = match event.pressed_button { + Some(MouseButton::Left) => mouse_protocol::BUTTON_LEFT + mouse_protocol::DRAG_OFFSET, + Some(MouseButton::Middle) => { + mouse_protocol::BUTTON_MIDDLE + mouse_protocol::DRAG_OFFSET + }, + Some(MouseButton::Right) => mouse_protocol::BUTTON_RIGHT + mouse_protocol::DRAG_OFFSET, + _ => { + if !mode.contains(TermMode::MOUSE_MOTION) { + return; + } + mouse_protocol::MOTION_ONLY + }, + }; + let (point, _) = self.grid_point(event.position); + if self.last_report_point == Some(point) { + return; + } + self.send_mouse_report(event.position, button, true, &event.modifiers); + } + + fn on_mouse_up(&mut self, event: &MouseUpEvent, _window: &mut Window, cx: &mut Context) { + if !self.selecting && self.mouse_mode_active(&event.modifiers) { + self.send_mouse_report( + event.position, + mouse_protocol::BUTTON_LEFT, + false, + &event.modifiers, + ); + return; + } + self.selecting = false; + // 选中即复制:在抬手时一次性入剪贴板(旧壳同样在 release 复制, + // 避免拖动过程刷爆剪贴板)。空选区在 copy_selection 内自然短路。 + if self.copy_on_select { + self.copy_selection(cx); } cx.notify(); } - fn on_mouse_up(&mut self, _event: &MouseUpEvent, _window: &mut Window, cx: &mut Context) { - self.selecting = false; + /// 右键(旧壳 Windows 惯例):有选区 → 复制并清除;无选区 → 粘贴。 + /// 应用接管鼠标时上报给应用。 + fn on_right_down( + &mut self, + event: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + window.focus(&self.focus_handle); + if self.session.is_none() { + return; + } + if self.mouse_mode_active(&event.modifiers) { + self.send_mouse_report( + event.position, + mouse_protocol::BUTTON_RIGHT, + true, + &event.modifiers, + ); + return; + } + let has_selection = self + .session + .as_ref() + .is_some_and(|s| s.term.lock().selection.as_ref().is_some_and(|sel| !sel.is_empty())); + if has_selection { + self.copy_selection(cx); + if let Some(session) = &self.session { + session.term.lock().selection = None; + } + cx.notify(); + } else { + self.paste(cx); + } + } + + fn on_right_up(&mut self, event: &MouseUpEvent, _window: &mut Window, cx: &mut Context) { + if self.mouse_mode_active(&event.modifiers) { + self.send_mouse_report( + event.position, + mouse_protocol::BUTTON_RIGHT, + false, + &event.modifiers, + ); + } + cx.notify(); + } + + /// 中键只在鼠标模式下有意义(上报给应用);旧壳在 Windows 上没有 + /// 中键粘贴路径,这里保持一致。 + fn on_middle_down( + &mut self, + event: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + window.focus(&self.focus_handle); + if self.mouse_mode_active(&event.modifiers) { + self.send_mouse_report( + event.position, + mouse_protocol::BUTTON_MIDDLE, + true, + &event.modifiers, + ); + } + cx.notify(); + } + + fn on_middle_up(&mut self, event: &MouseUpEvent, _window: &mut Window, cx: &mut Context) { + if self.mouse_mode_active(&event.modifiers) { + self.send_mouse_report( + event.position, + mouse_protocol::BUTTON_MIDDLE, + false, + &event.modifiers, + ); + } cx.notify(); } @@ -412,8 +615,18 @@ impl TerminalView { return; } self.scroll_px -= lines as f32 * self.line_height.as_f32(); + let mode = self.term_mode(); + // 应用接管鼠标时滚轮也归应用(htop 列表滚动);Shift 旁路回本地回滚。 + if !event.modifiers.shift && mode.intersects(TermMode::MOUSE_MODE) { + let code = + if lines > 0 { mouse_protocol::WHEEL_UP } else { mouse_protocol::WHEEL_DOWN }; + for _ in 0..lines.unsigned_abs() { + self.send_mouse_report(event.position, code, true, &event.modifiers); + } + cx.notify(); + return; + } let Some(session) = &self.session else { return }; - let mode = *session.term.lock().mode(); if mode.contains(TermMode::ALT_SCREEN) && mode.contains(TermMode::ALTERNATE_SCROLL) { // 备用屏(less/vim):滚轮翻译成方向键。 let seq: &[u8] = if mode.contains(TermMode::APP_CURSOR) { @@ -546,8 +759,12 @@ impl Render for TerminalView { .track_focus(&self.focus_handle) .on_key_down(cx.listener(Self::on_key_down)) .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) + .on_mouse_down(MouseButton::Right, cx.listener(Self::on_right_down)) + .on_mouse_down(MouseButton::Middle, cx.listener(Self::on_middle_down)) .on_mouse_move(cx.listener(Self::on_mouse_move)) .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) + .on_mouse_up(MouseButton::Right, cx.listener(Self::on_right_up)) + .on_mouse_up(MouseButton::Middle, cx.listener(Self::on_middle_up)) .on_scroll_wheel(cx.listener(Self::on_scroll)); if let Some(error) = &self.error {