diff --git a/architecture/notes/nebula_app/gpui_shell/terminal/view/2026-10-28-application-mouse-gesture-lifetime.md b/architecture/notes/nebula_app/gpui_shell/terminal/view/2026-10-28-application-mouse-gesture-lifetime.md new file mode 100644 index 000000000..ca6a5999d --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/terminal/view/2026-10-28-application-mouse-gesture-lifetime.md @@ -0,0 +1,94 @@ +# Application mouse gesture lifetime + +## Status + +Implemented for left-button application gestures in the GPUI terminal. + +## Context + +Fullscreen input editors own their draft, caret, selection, and editing commands. +The terminal owns event delivery. Previously each move/release independently chose +between application reporting and terminal selection using the current modifiers +and hitbox. A drag leaving its pane lost its release, entering another pane could +emit an unpaired drag/release, and pressing Shift mid-gesture changed ownership. + +## Evidence + +- The GPUI pointer regressions reproduce missing releases and foreign input using + actual element hit testing and the bytes sent to a terminal session. +- Applications can disable and re-enable mouse reporting in one PTY batch, so + sampling the final mode flags cannot distinguish a new input lifetime. +- Native Claude Code 2.1.310 reasserts `1000h`, `1002h`, `1003h`, `1006h` while + interacting. Treating each tracking-detail change as a new lifetime dropped + valid releases. The regression now covers this sequence explicitly. +- Native Codex 0.165.0 receives selection deletion through the ordinary PTY path; + no terminal-side text deletion or draft mirror is required. + +## Decision + +The view records an application gesture only after encoding its left press. It +retains a weak terminal identity, the terminal's mouse-input generation, and the +last cell/modifiers. Window capture routes motion/release to this owner outside +its hitbox, before another pane handles them. Listeners exist before the press so +events between paint frames are also captured. Native selection, links and +scrollbars retain their own existing state; a foreign left drag cannot start an +application gesture. + +The terminal core owns negotiation lifetime. Crossing the enabled/disabled +boundary, changing encoding, switching screens, reset, and grid resize invalidate +mouse coordinates. Reasserting tracking detail while reporting remains enabled +does not. This is a protocol/coordinate generation, not a process identity or an +application-name heuristic. + +Loss of focus/visibility and layout changes release a live gesture at its last +valid cell. Late input after protocol/session replacement is discarded. A move +with no held left button recovers a missed OS release. Core generation checks +also cover transitions that occurred entirely between GUI events. + +Keyboard, IME and paste keep their existing encoding/broadcast contracts. Mouse +reports target only the originating session. The terminal does not infer or copy +an application's selected text into a shell editing command. + +## Rejected alternatives + +- Re-evaluating Shift on every event loses the release owed to the press owner. +- Broadcasting coordinates or handing a drag to the hovered pane creates input + without a corresponding press in the receiving application. +- Tracking only final mode bits misses disable/re-enable in one parser batch. +- Invalidating on every mode setter breaks applications that reassert modes on + redraw; only input-lifetime/encoding/coordinate boundaries invalidate a press. +- Synthesizing Backspaces from terminal selection length cannot preserve the + application's Unicode boundaries, placeholders, undo state, or actual draft. + +## Consequences + +Each view holds at most one application left gesture and registers two small +capture listeners per paint. Idle listeners only check for an owner. No gesture +timer, editor buffer, worker, persistence, or dependency is introduced. + +The generation cannot identify a process replacement that does not change any +terminal protocol or screen state. Applications still must reset their input +modes on exit; the PTY does not expose the identity of a byte-stream consumer. +Right/middle-button behavior and ordinary-shell editing are outside this change. + +## Validation + +- `term/mouse.rs` tests negotiation in every parser chunk size, idempotent + reassertion, encoding transitions, screen switches, reset and resize. +- `pointer/tests/application_mouse.rs` tests pane boundaries in both directions, + Shift changes, focus/visibility, session replacement, late release, resize, + legacy encoding, and redraw reassertion with real GPUI event dispatch. +- `application_mouse/native_tests.rs` provides an opt-in native-window/PTY fixture + for isolated application drafts, using real keyboard and pointer dispatch. +- Linux native checks do not establish Windows ConPTY or macOS compatibility; + those hosts require their native execution paths to be run separately. + +## Supersedes + +None. + +## Revisit when + +GPUI exposes a persistent pointer-capture owner, the terminal gains another mouse +encoding, or application mouse lifetime is exposed by a stronger protocol. Reuse +the terminal generation rather than adding per-frontend protocol state machines. diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs index 6165672ed..ea657f090 100644 --- a/nebula_app/src/gpui_shell/terminal/element.rs +++ b/nebula_app/src/gpui_shell/terminal/element.rs @@ -49,15 +49,17 @@ impl TerminalElement { Self { view } } - fn capture_completion_scrollbar_drag(&self, window: &mut Window, cx: &App) { - if self.view.read(cx).completion_viewport.scrollbar_grab.is_none() { - return; - } + fn capture_pointer_drag(&self, window: &mut Window) { + // Register even before a press: input can arrive between paint frames. + // The owning view decides whether to consume the gesture at dispatch. let view = self.view.downgrade(); window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| { if phase == DispatchPhase::Capture && view - .update(cx, |view, cx| view.move_completion_popup_scrollbar(event, cx)) + .update(cx, |view, cx| { + view.move_application_mouse(event) + || view.move_completion_popup_scrollbar(event, cx) + }) .unwrap_or(false) { cx.stop_propagation(); @@ -69,7 +71,8 @@ impl TerminalElement { && event.button == MouseButton::Left && view .update(cx, |view, cx| { - view.finish_completion_popup_scrollbar(event.position, cx) + view.finish_application_mouse(Some(event)) + || view.finish_completion_popup_scrollbar(event.position, cx) }) .unwrap_or(false) { @@ -252,7 +255,7 @@ impl Element for TerminalElement { window: &mut Window, cx: &mut App, ) { - self.capture_completion_scrollbar_drag(window, cx); + self.capture_pointer_drag(window); let theme = self.view.read(cx).palette.clone(); // 元素不画底色:卡容器统一负责"卡底色(带窗口透明度)→ 壁纸" // 两层(旧壳 draw_window_backdrop 同模型),元素只画单元格背景、 diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index a54e4aa6a..8e0a60df5 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -3,6 +3,7 @@ #[cfg(all(test, feature = "gpui-test-support"))] mod activity_tests; mod agent_activity; +mod application_mouse; mod broadcast; mod completion; #[cfg(all(test, feature = "gpui-test-support"))] @@ -490,6 +491,7 @@ pub struct TerminalView { copy_on_select: bool, /// 鼠标模式下最后上报的单元格:move 事件按"进入新单元格"去重。 last_report_point: Option, + application_mouse: Option, /// GPUI 没有旧壳 scheduler 的 `BlinkCursor` 事件,视图自己只维护可见相位; /// 光标是否允许闪烁仍由共享 `Term::cursor_style()` 裁定。 cursor_visible: bool, @@ -612,6 +614,7 @@ impl TerminalView { cx: &mut Context, ) { if !visible { + self.cancel_pointer_gesture(); self.clear_output_hover(cx); } if self.output_visible != visible { @@ -865,6 +868,8 @@ impl TerminalView { /// `Exited` 只对宿主发一次;重复的退出信号(ChildExit 之后必然跟 Exit)只更新文案。 fn mark_exited(&mut self, message: String, cx: &mut Context) { + self.application_mouse = None; + self.cancel_pointer_gesture(); self.clear_output_hover(cx); self.reading.cancel_query(); self.reading.cancel_copy(); diff --git a/nebula_app/src/gpui_shell/terminal/view/application_mouse.rs b/nebula_app/src/gpui_shell/terminal/view/application_mouse.rs new file mode 100644 index 000000000..3aca364c8 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/view/application_mouse.rs @@ -0,0 +1,155 @@ +//! A left-button press owns its motion/release, even outside the pane's hitbox. +//! Text ranges and edits remain owned by the application reading the PTY. +use super::*; +use nebula_terminal::Term; +use nebula_terminal::sync::FairMutex; +use std::sync::Weak; + +#[cfg(all(test, feature = "gpui-test-support"))] +mod native_tests; + +pub(super) struct Gesture { + terminal: Weak>>, + generation: u64, + point: TermPoint, + modifiers: gpui::Modifiers, +} + +impl TerminalView { + pub(super) fn begin_application_mouse( + &mut self, + event: &MouseDownEvent, + cx: &mut Context, + ) { + let Some(session) = self.session.as_ref() else { return }; + let (point, _) = self.grid_point(event.position); + let mut term = session.term.lock(); + if !term.mode().intersects(TermMode::MOUSE_MODE) || self.exited.is_some() { + return; + } + let Some(bytes) = mouse_protocol::report( + term.mode(), + point, + mouse_protocol::BUTTON_LEFT, + true, + report_mods(event.modifiers), + ) else { + return; + }; + self.application_mouse = Some(Gesture { + terminal: Arc::downgrade(&session.term), + generation: term.mouse_input_generation(), + point, + modifiers: event.modifiers, + }); + term.selection = None; + drop(term); + self.selecting = false; + self.stop_selection_scroll(); + self.last_report_point = Some(point); + self.write_bytes(bytes); + cx.notify(); + } + + /// Capture listeners call this for every pane. Only the press owner consumes it. + pub(in crate::gpui_shell::terminal) fn move_application_mouse( + &mut self, + event: &MouseMoveEvent, + ) -> bool { + let Some(gesture) = self.application_mouse.as_ref() else { return false }; + if event.pressed_button != Some(MouseButton::Left) { + // Recover from a missed OS release without moving the selection to a + // new hover position. Never synthesize a new press on re-entry. + self.finish_application_mouse(None); + return false; + } + let (point, _) = self.grid_point(event.position); + if point != gesture.point || event.modifiers != gesture.modifiers { + self.report_application_mouse(point, event.modifiers, true); + } + true + } + + pub(in crate::gpui_shell::terminal) fn finish_application_mouse( + &mut self, + event: Option<&MouseUpEvent>, + ) -> bool { + let Some(gesture) = self.application_mouse.as_ref() else { return false }; + let (point, modifiers) = match event { + Some(event) => (self.grid_point(event.position).0, event.modifiers), + None => (gesture.point, gesture.modifiers), + }; + self.report_application_mouse(point, modifiers, false); + self.application_mouse = None; + self.last_report_point = None; + true + } + + fn report_application_mouse( + &mut self, + point: TermPoint, + modifiers: gpui::Modifiers, + drag: bool, + ) { + let Some(gesture) = self.application_mouse.as_ref() else { return }; + let Some(session) = self.session.as_ref() else { + self.application_mouse = None; + return; + }; + let term = session.term.lock(); + if !std::ptr::eq(gesture.terminal.as_ptr(), Arc::as_ptr(&session.term)) + || gesture.generation != term.mouse_input_generation() + || !term.mode().intersects(TermMode::MOUSE_MODE) + || self.exited.is_some() + { + self.application_mouse = None; + return; + } + if drag && !term.mode().intersects(TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION) { + return; + } + let encode = |point| { + mouse_protocol::report( + term.mode(), + point, + mouse_protocol::BUTTON_LEFT + if drag { mouse_protocol::DRAG_OFFSET } else { 0 }, + drag, + report_mods(modifiers), + ) + }; + let (point, bytes) = match encode(point) { + Some(bytes) => (point, bytes), + None if !drag => { + // Scrollback and legacy coordinate limits cannot encode this + // position. The app still needs a release for its valid press. + let Some(bytes) = encode(gesture.point) else { return }; + (gesture.point, bytes) + }, + None => return, + }; + drop(term); + if let Some(gesture) = self.application_mouse.as_mut() { + gesture.point = point; + gesture.modifiers = modifiers; + } + self.last_report_point = Some(point); + self.write_bytes(bytes); + } + + pub(super) fn cancel_pointer_gesture(&mut self) { + self.finish_application_mouse(None); + self.selecting = false; + self.stop_selection_scroll(); + self.scrollbar_drag = None; + self.completion_viewport.scrollbar_grab = None; + self.pending_link_open = false; + } +} + +fn report_mods(modifiers: gpui::Modifiers) -> mouse_protocol::ReportMods { + mouse_protocol::ReportMods { + shift: modifiers.shift, + alt: modifiers.alt, + control: modifiers.control, + } +} diff --git a/nebula_app/src/gpui_shell/terminal/view/application_mouse/native_tests.rs b/nebula_app/src/gpui_shell/terminal/view/application_mouse/native_tests.rs new file mode 100644 index 000000000..0da26309e --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/view/application_mouse/native_tests.rs @@ -0,0 +1,170 @@ +//! Opt-in real PTY fixture. Launch only isolated QA programs; never submit a model request. +use super::*; +use gpui::{ + Entity, EntityInputHandler as _, Modifiers, PlatformInput, WindowBounds, WindowOptions, size, +}; +use std::path::PathBuf; +use std::time::Duration; + +#[derive(serde::Deserialize)] +struct Launch { + program: String, + args: Vec, +} + +#[derive(serde::Deserialize)] +struct Action { + kind: String, + #[serde(default)] + column: f32, + #[serde(default)] + row: f32, + #[serde(default)] + shift: bool, + #[serde(default)] + text: String, +} + +#[test] +#[ignore = "requires native desktop, isolated launch.json and PEBREL_MOUSE_QA_DIR/config"] +fn native_application_mouse_editor() { + let output = PathBuf::from(std::env::var_os("PEBREL_MOUSE_QA_DIR").expect("QA directory")); + assert!(output.is_absolute()); + assert_eq!( + std::env::var_os("PEBREL_CONFIG_DIR").map(PathBuf::from), + Some(output.join("config")) + ); + assert!(!output.join("screen.json").exists(), "use a fresh QA directory"); + let launch: Launch = + serde_json::from_slice(&std::fs::read(output.join("launch.json")).unwrap()).unwrap(); + assert!(std::path::Path::new(&launch.program).is_absolute()); + gpui_platform::application().with_assets(crate::gpui_shell::assets::NebulaAssets).run(move |cx| { + crate::gpui_shell::register_bundled_fonts(cx); + gpui_component::init(cx); + crate::gpui_shell::terminal::init(cx); + crate::gpui_shell::scientific_render::init(cx); + cx.set_global(Settings::load(nebula_settings::ThemeName::Nord)); + crate::gpui_shell::theme::apply_chrome_theme(cx); + let mut terminal = None; + let handle = cx.open_window(WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::new( + point(px(40.0), px(40.0)), size(px(1000.0), px(700.0)), + ))), + focus: true, + ..Default::default() + }, |window, cx| { + let view = cx.new(|cx| TerminalView::new( + 9900, (100, 35), TerminalLaunch::Local { + cwd: Some(output.clone()), + shell: Some(nebula_terminal::tty::Shell::new(launch.program, launch.args)), + shell_name: None, + }, window, cx, + )); + window.focus(&view.read(cx).focus_handle.clone(), cx); + terminal = Some(view.clone()); + cx.new(|cx| gpui_component::Root::new(view, window, cx)) + }).unwrap(); + let view = terminal.unwrap(); + cx.spawn(async move |cx| { + let mut next = 0; + let mut completed = false; + for _ in 0..1200 { + cx.background_executor().timer(Duration::from_millis(500)).await; + let path = output.join(format!("action-{next}.json")); + let action = cx.background_executor().spawn(async move { + std::fs::read(path).ok().map(|data| serde_json::from_slice::(&data).unwrap()) + }).await; + if let Some(action) = action { + if action.kind == "quit" { + completed = true; + break; + } + cx.update_window(handle.into(), |_, window, cx| { + dispatch(&view, action, window, cx); + window.refresh(); + }).unwrap(); + next += 1; + } + let snapshot = cx.update_window(handle.into(), |_, window, cx| { + let _ = window.draw(cx); + let view = view.read(cx); + let session = view.session.as_ref().expect("QA PTY must start"); + let term = session.term.lock(); + let rows: Vec = (0..term.screen_lines()).map(|row| { + let mut text = String::new(); + for column in 0..term.columns() { + let cell = &term.grid()[Line(row as i32)][Column(column)]; + if cell.flags.contains(nebula_terminal::term::cell::Flags::WIDE_CHAR_SPACER) { + continue; + } + text.push(cell.c); + if let Some(zero_width) = cell.zerowidth() { + text.extend(zero_width.iter()); + } + } + text.trim_end().to_owned() + }).collect(); + serde_json::json!({ + "next_action": next, "rows": rows, + "origin": [view.origin.x.as_f32(), view.origin.y.as_f32()], + "cell": [view.cell_width.as_f32(), view.line_height.as_f32()], + "grid": [view.cols, view.rows], "mode": term.mode().bits(), + "mouse_generation": term.mouse_input_generation(), + "cursor": [term.grid().cursor.point.column.0, term.grid().cursor.point.line.0], + "gesture_active": view.application_mouse.is_some(), + }) + }).unwrap(); + let path = output.join("screen.json"); + cx.background_executor().spawn(async move { + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, serde_json::to_vec_pretty(&snapshot).unwrap()).unwrap(); + std::fs::rename(tmp, path).unwrap(); + }).await; + } + cx.update_window(handle.into(), |_, _, cx| view.read(cx).shutdown()).unwrap(); + cx.background_executor().timer(Duration::from_millis(250)).await; + assert!(completed, "native QA controller timed out"); + cx.update(|cx| cx.quit()); + }).detach(); + }); +} + +fn dispatch(view: &Entity, action: Action, window: &mut Window, cx: &mut App) { + let terminal = view.read(cx); + let position = point( + terminal.origin.x + terminal.cell_width * (action.column + 0.5), + terminal.origin.y + terminal.line_height * (action.row + 0.5), + ); + let modifiers = Modifiers { shift: action.shift, ..Modifiers::default() }; + let event = match action.kind.as_str() { + "down" => PlatformInput::MouseDown(MouseDownEvent { + position, + modifiers, + button: MouseButton::Left, + click_count: 1, + first_mouse: false, + }), + "move" => PlatformInput::MouseMove(MouseMoveEvent { + position, + modifiers, + pressed_button: Some(MouseButton::Left), + }), + "up" => PlatformInput::MouseUp(MouseUpEvent { + position, + modifiers, + button: MouseButton::Left, + click_count: 1, + }), + "key" => PlatformInput::KeyDown(KeyDownEvent { + keystroke: gpui::Keystroke::parse(&action.text).unwrap(), + is_held: false, + prefer_character_input: false, + }), + "text" => { + view.update(cx, |view, cx| view.replace_text_in_range(None, &action.text, window, cx)); + return; + }, + _ => panic!("unknown QA action"), + }; + window.dispatch_event(event, cx); +} diff --git a/nebula_app/src/gpui_shell/terminal/view/layout.rs b/nebula_app/src/gpui_shell/terminal/view/layout.rs index 01028fc4d..9b657be4c 100644 --- a/nebula_app/src/gpui_shell/terminal/view/layout.rs +++ b/nebula_app/src/gpui_shell/terminal/view/layout.rs @@ -21,6 +21,10 @@ impl TerminalView { scale: f32, cx: &mut Context, ) { + if self.origin != origin || self.cell_width != cell_width || self.line_height != line_height + { + self.finish_application_mouse(None); + } self.origin = origin; self.cell_width = cell_width; self.line_height = line_height; @@ -31,6 +35,10 @@ impl TerminalView { }; let change = self.viewports.observe(content.width.as_f32(), content.height.as_f32(), &metrics); + if change.is_some() { + // Release using the last valid cell before a resize changes its meaning. + self.finish_application_mouse(None); + } // 启动稳定闸:开窗 resize 异步落地,首帧可能还是开窗前的旧尺寸。 // 命中 spawn 网格前不向 Term/ConPTY 下发(PTY 出生即目标几何, diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer.rs b/nebula_app/src/gpui_shell/terminal/view/pointer.rs index ce4bbebeb..9a4275e66 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer.rs @@ -486,6 +486,7 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) { + self.cancel_pointer_gesture(); window.focus(&self.focus_handle, cx); self.pending_link_open = false; cx.emit(TerminalViewEvent::FocusRequested); @@ -546,12 +547,7 @@ impl TerminalView { } } if self.mouse_mode_active(&event.modifiers) { - self.send_mouse_report( - event.position, - mouse_protocol::BUTTON_LEFT, - true, - &event.modifiers, - ); + self.begin_application_mouse(event, cx); return; } let (point, side) = self.grid_point(event.position); @@ -588,6 +584,9 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) { + if self.move_application_mouse(event) { + return; + } self.update_output_hover(event, cx); self.update_scrollbar_hover(event.position, cx); // Retain the pressed link until release; dragging must not retarget it @@ -662,6 +661,11 @@ impl TerminalView { } } if self.mouse_mode_active(&event.modifiers) { + // A press from another pane, toolbar or a consumed popup click + // cannot start an application's drag just by entering its hitbox. + if event.pressed_button == Some(MouseButton::Left) { + return; + } self.clear_link_hover(cx); // 鼠标模式的移动上报:拖动 = 按钮码+32(需 DRAG 或 MOTION 任一), // 无按键纯移动 = 35(仅 MOTION);同一单元格内的移动不重报。 @@ -702,6 +706,10 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) { + if self.finish_application_mouse(Some(event)) { + cx.stop_propagation(); + return; + } if self.finish_completion_popup_scrollbar(event.position, cx) { cx.stop_propagation(); return; @@ -713,13 +721,7 @@ impl TerminalView { return; } let pending_link_open = std::mem::take(&mut self.pending_link_open); - if !pending_link_open && !self.selecting && self.mouse_mode_active(&event.modifiers) { - self.send_mouse_report( - event.position, - mouse_protocol::BUTTON_LEFT, - false, - &event.modifiers, - ); + if !pending_link_open && !self.selecting { return; } self.selecting = false; @@ -745,6 +747,10 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) { + if self.finish_application_mouse(Some(event)) { + cx.stop_propagation(); + return; + } if self.finish_completion_popup_scrollbar(event.position, cx) { cx.stop_propagation(); return; diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs b/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs index b9c75b633..e8af1ef90 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs @@ -8,6 +8,8 @@ use gpui_component::{ use std::cell::Cell; use std::rc::Rc; +mod application_mouse; + struct Probe { terminal: Entity, input: Entity, diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer/tests/application_mouse.rs b/nebula_app/src/gpui_shell/terminal/view/pointer/tests/application_mouse.rs new file mode 100644 index 000000000..b87b80df4 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/view/pointer/tests/application_mouse.rs @@ -0,0 +1,340 @@ +//! Exercise real GPUI hit testing and the bytes delivered to the session. +use super::*; + +const FULLSCREEN: &[u8] = b"\x1b[?1049h\x1b[?1002h\x1b[?1006hhello world"; + +fn input(receiver: &std::sync::mpsc::Receiver) -> Vec> { + receiver + .try_iter() + .filter_map(|message| match message { + Msg::Input(bytes) => Some(bytes.into_owned()), + _ => None, + }) + .collect() +} + +#[gpui::test] +fn application_drag_releases_outside_the_pane(cx: &mut TestAppContext) { + let (view, mut window, receiver) = link_fixture(cx, FULLSCREEN); + let start = cell(&view, &window, 1); + window.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + draw(&mut window); + // The input control above the terminal owns this hitbox. + let outside = point(start.x, px(10.0)); + window.simulate_mouse_move(outside, Some(MouseButton::Left), Modifiers::default()); + window.simulate_mouse_up(outside, MouseButton::Left, Modifiers::default()); + let reports = input(&receiver); + assert_eq!(reports.first().unwrap(), b"\x1b[<0;2;1M"); + assert_eq!(reports.last().unwrap(), b"\x1b[<0;2;1m"); + assert_eq!(reports.iter().filter(|r| r.ends_with(b"m")).count(), 1); +} + +#[gpui::test] +fn application_drag_keeps_ownership_when_shift_changes(cx: &mut TestAppContext) { + let (view, mut window, receiver) = link_fixture(cx, FULLSCREEN); + let start = cell(&view, &window, 1); + let end = cell(&view, &window, 6); + let shift = Modifiers { shift: true, ..Modifiers::default() }; + window.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + window.simulate_mouse_move(end, Some(MouseButton::Left), shift); + window.simulate_mouse_up(end, MouseButton::Left, shift); + assert_eq!( + input(&receiver), + vec![b"\x1b[<0;2;1M".to_vec(), b"\x1b[<36;7;1M".to_vec(), b"\x1b[<4;7;1m".to_vec(),] + ); + view.read_with(&window, |view, _| assert!(view.selection_is_empty())); +} + +#[gpui::test] +fn foreign_drag_and_release_do_not_reach_application(cx: &mut TestAppContext) { + let (view, mut window, receiver) = link_fixture(cx, FULLSCREEN); + let end = cell(&view, &window, 6); + window.simulate_mouse_down(point(end.x, px(10.0)), MouseButton::Left, Modifiers::default()); + window.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default()); + window.simulate_mouse_up(end, MouseButton::Left, Modifiers::default()); + assert!(input(&receiver).is_empty(), "an application must first receive a press"); +} + +#[gpui::test] +fn focus_loss_releases_application_once(cx: &mut TestAppContext) { + let (view, mut window, receiver) = link_fixture(cx, FULLSCREEN); + let start = cell(&view, &window, 1); + window.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + window.deactivate_window(); + window.run_until_parked(); + assert_eq!(input(&receiver), vec![b"\x1b[<0;2;1M".to_vec(), b"\x1b[<0;2;1m".to_vec()]); + window.simulate_mouse_up(start, MouseButton::Left, Modifiers::default()); + assert!(input(&receiver).is_empty(), "a late release must not be sent twice"); +} + +#[gpui::test] +fn native_shift_selection_does_not_turn_into_application_drag(cx: &mut TestAppContext) { + let (view, mut window, receiver) = link_fixture(cx, FULLSCREEN); + let start = cell(&view, &window, 0); + let end = cell(&view, &window, 5); + window.simulate_mouse_down( + start, + MouseButton::Left, + Modifiers { shift: true, ..Modifiers::default() }, + ); + window.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default()); + window.simulate_mouse_up(end, MouseButton::Left, Modifiers::default()); + assert!(input(&receiver).is_empty()); + view.read_with(&window, |view, _| assert!(!view.selection_is_empty())); + window.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + window.simulate_mouse_up(start, MouseButton::Left, Modifiers::default()); + view.read_with(&window, |view, _| { + assert!(view.selection_is_empty(), "the TUI owns the new selection") + }); + assert_eq!(input(&receiver).len(), 2); +} + +#[gpui::test] +fn application_mode_round_trip_expires_the_old_gesture(cx: &mut TestAppContext) { + let (view, mut window, receiver) = link_fixture(cx, FULLSCREEN); + let start = cell(&view, &window, 1); + for transition in [ + b"\x1b[?1002l\x1b[?1002h".as_slice(), + b"\x1b[?1006l\x1b[?1006h", + b"\x1b[?1049l\x1b[?1049h", + b"\x1bc\x1b[?1002h\x1b[?1006h", + ] { + window.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + assert_eq!(input(&receiver).len(), 1); + view.update(&mut window, |view, _| { + super::super::super::startup_tests::feed(view, transition) + }); + window.simulate_mouse_move( + cell(&view, &window, 4), + Some(MouseButton::Left), + Modifiers::default(), + ); + window.simulate_mouse_up(start, MouseButton::Left, Modifiers::default()); + assert!(input(&receiver).is_empty(), "old gesture survived {transition:?}"); + } +} + +#[gpui::test] +fn changed_session_does_not_receive_old_release(cx: &mut TestAppContext) { + let (view, mut window, receiver) = link_fixture(cx, FULLSCREEN); + let start = cell(&view, &window, 1); + window.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + assert_eq!(input(&receiver).len(), 1); + let replacement = view.update(&mut window, |view, _| { + let receiver = view.install_completion_test_session(); + super::super::super::startup_tests::feed(view, FULLSCREEN); + receiver + }); + window.simulate_mouse_up(start, MouseButton::Left, Modifiers::default()); + assert!(input(&replacement).is_empty()); +} + +#[gpui::test] +fn resize_and_hidden_pane_release_before_changing_coordinates(cx: &mut TestAppContext) { + let (view, mut window, receiver) = link_fixture(cx, FULLSCREEN); + let start = cell(&view, &window, 1); + window.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + window.simulate_resize(gpui::size(px(500.0), px(400.0))); + draw(&mut window); + assert_eq!(input(&receiver), vec![b"\x1b[<0;2;1M".to_vec(), b"\x1b[<0;2;1m".to_vec()]); + window.simulate_mouse_up(start, MouseButton::Left, Modifiers::default()); + assert!(input(&receiver).is_empty()); + window.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + view.update(&mut window, |view, cx| view.set_output_visible(false, cx)); + assert_eq!(input(&receiver).len(), 2); + window.simulate_mouse_up(start, MouseButton::Left, Modifiers::default()); + assert!(input(&receiver).is_empty()); +} + +#[gpui::test] +fn legacy_mouse_encoding_and_click_only_tracking_are_preserved(cx: &mut TestAppContext) { + let (view, mut window, receiver) = link_fixture(cx, b"\x1b[?1049h\x1b[?1000hhello"); + let start = cell(&view, &window, 1); + let end = cell(&view, &window, 6); + for encoding in [b"\x1b[?1005l".as_slice(), b"\x1b[?1005h"] { + view.update(&mut window, |view, _| { + super::super::super::startup_tests::feed(view, encoding) + }); + window.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + window.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default()); + window.simulate_mouse_up(end, MouseButton::Left, Modifiers::default()); + assert_eq!( + input(&receiver), + vec![b"\x1b[M\x20\x22\x21".to_vec(), b"\x1b[M\x23\x27\x21".to_vec()] + ); + } +} + +struct Pair(Vec>); + +#[gpui::test] +fn application_redraw_can_reassert_tracking_without_losing_release(cx: &mut TestAppContext) { + let (view, mut window, receiver) = link_fixture(cx, FULLSCREEN); + let start = cell(&view, &window, 1); + let end = cell(&view, &window, 6); + window.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + view.update(&mut window, |view, _| { + super::super::super::startup_tests::feed( + view, + b"\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h", + ) + }); + window.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default()); + window.simulate_mouse_up(end, MouseButton::Left, Modifiers::default()); + assert_eq!( + input(&receiver), + vec![b"\x1b[<0;2;1M".to_vec(), b"\x1b[<32;7;1M".to_vec(), b"\x1b[<0;7;1m".to_vec(),] + ); +} + +impl Render for Pair { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().flex().children( + self.0.iter().map(|view| div().flex_1().min_w_0().h_full().child(view.clone())), + ) + } +} + +#[gpui::test] +fn application_drag_stays_with_the_original_pane(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + crate::gpui_shell::scientific_render::init(cx); + cx.set_global(Settings::load(nebula_settings::ThemeName::Nord)); + }); + let mut panes = Vec::new(); + let mut receivers = Vec::new(); + let (_, window) = cx.add_window_view(|window, cx| { + for id in 1..=2 { + let view = cx.new(|cx| { + TerminalView::new( + id, + (40, 24), + super::super::super::TerminalLaunch::Local { + cwd: None, + shell: Some(nebula_terminal::tty::Shell::new( + "pebrel-test-missing-shell-executable".into(), + vec![], + )), + shell_name: None, + }, + window, + cx, + ) + }); + receivers.push(view.update(cx, |view, _| { + let receiver = view.install_completion_test_session(); + super::super::super::startup_tests::feed(view, FULLSCREEN); + receiver + })); + panes.push(view); + } + let pair = cx.new(|_| Pair(panes.clone())); + Root::new(pair, window, cx) + }); + let mut window = window.clone(); + window.simulate_resize(gpui::size(px(800.0), px(400.0))); + window.update(|window, _| window.activate_window()); + draw(&mut window); + for (source, target) in [(0, 1), (1, 0)] { + let start = cell(&panes[source], &window, 2); + let end = cell(&panes[target], &window, 5); + let last_column = + panes[source].read_with(&window, |view, _| if source == 0 { view.cols } else { 1 }); + window.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + draw(&mut window); + window.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default()); + window.simulate_mouse_up(end, MouseButton::Left, Modifiers::default()); + assert!( + input(&receivers[target]).is_empty(), + "the neighbouring app received a foreign gesture" + ); + assert_eq!( + input(&receivers[source]), + vec![ + b"\x1b[<0;3;1M".to_vec(), + format!("\x1b[<32;{last_column};1M").into_bytes(), + format!("\x1b[<0;{last_column};1m").into_bytes(), + ] + ); + } +} + +#[gpui::test] +fn missing_os_release_and_keyboard_focus_change_end_drag_once(cx: &mut TestAppContext) { + let (view, mut window, receiver) = link_fixture(cx, FULLSCREEN); + let start = cell(&view, &window, 1); + window.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + window.simulate_mouse_move(cell(&view, &window, 5), None, Modifiers::default()); + assert_eq!(input(&receiver), vec![b"\x1b[<0;2;1M".to_vec(), b"\x1b[<0;2;1m".to_vec()]); + window.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + window.update(|window, cx| { + // Opening another focus target while still holding the mouse ends the + // terminal gesture without waiting for a later release in that control. + let focus = cx.focus_handle(); + window.focus(&focus, cx); + }); + window.run_until_parked(); + assert_eq!(input(&receiver).len(), 2); + window.simulate_mouse_up(start, MouseButton::Left, Modifiers::default()); + assert!(input(&receiver).is_empty()); +} + +#[gpui::test] +fn legacy_drag_releases_at_last_encodable_cell(cx: &mut TestAppContext) { + let (view, mut window, receiver) = link_fixture(cx, b"\x1b[?1049h\x1b[?1002hhello"); + view.update(&mut window, |view, _| view.mark_structural_resize()); + window.simulate_resize(gpui::size(px(2600.0), px(400.0))); + draw(&mut window); + view.read_with(&window, |view, _| assert!(view.cols > 240)); + // The standalone test session has no PTY event loop to apply resize messages. + view.update(&mut window, |view, _| { + view.session + .as_ref() + .unwrap() + .term + .lock() + .resize(session::GridSize { columns: view.cols, screen_lines: view.rows }); + }); + let start = cell(&view, &window, 1); + let last_valid = cell(&view, &window, 222); + let outside_encoding = cell(&view, &window, 240); + window.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + window.simulate_mouse_move(last_valid, Some(MouseButton::Left), Modifiers::default()); + window.simulate_mouse_move(outside_encoding, Some(MouseButton::Left), Modifiers::default()); + window.simulate_mouse_up(outside_encoding, MouseButton::Left, Modifiers::default()); + assert_eq!( + input(&receiver), + vec![ + b"\x1b[M\x20\x22\x21".to_vec(), + b"\x1b[M\x40\xff\x21".to_vec(), + b"\x1b[M\x23\xff\x21".to_vec(), + ], + "the application must receive a release even beyond the legacy coordinate limit" + ); + window.simulate_mouse_move(start, None, Modifiers::default()); + assert!(input(&receiver).is_empty()); +} + +#[gpui::test] +fn scrollback_during_application_drag_keeps_a_valid_release(cx: &mut TestAppContext) { + let text = format!("{}\x1b[?1002h\x1b[?1006h", "line\r\n".repeat(80)); + let (view, mut window, receiver) = link_fixture(cx, text.as_bytes()); + let start = cell(&view, &window, 1); + window.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + view.update(&mut window, |view, _| { + view.session.as_ref().unwrap().term.lock().scroll_display(Scroll::Delta(5)); + }); + draw(&mut window); + window.simulate_mouse_move( + cell(&view, &window, 5), + Some(MouseButton::Left), + Modifiers::default(), + ); + window.simulate_mouse_up(start, MouseButton::Left, Modifiers::default()); + assert_eq!( + input(&receiver), + vec![b"\x1b[<0;2;1M".to_vec(), b"\x1b[<0;2;1m".to_vec()], + "scrollback has no application coordinates, but the original press still needs its release" + ); +} diff --git a/nebula_app/src/gpui_shell/terminal/view/startup.rs b/nebula_app/src/gpui_shell/terminal/view/startup.rs index 7c795ff73..b746c7d3a 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup.rs @@ -266,6 +266,9 @@ impl TerminalView { let cursor_blink_subscriptions = [ cx.observe_window_activation(window, |view, window, cx| { let active = window.is_window_active(); + if !active { + view.cancel_pointer_gesture(); + } if view.cursor_window_active != active { view.cursor_window_active = active; view.restart_cursor_blink(cx); @@ -278,6 +281,7 @@ impl TerminalView { } }), cx.on_focus_out(&focus_handle, window, |view, _event, _window, cx| { + view.cancel_pointer_gesture(); view.path_drop.invalidate(); if view.cursor_pane_focused { view.cursor_pane_focused = false; @@ -395,6 +399,7 @@ impl TerminalView { pending_link_open: false, copy_on_select, last_report_point: None, + application_mouse: None, cursor_visible: true, cursor_animation: Default::default(), cursor_blink_epoch: 0, diff --git a/nebula_terminal/src/term/mod.rs b/nebula_terminal/src/term/mod.rs index c7cb80118..499a4ab7a 100644 --- a/nebula_terminal/src/term/mod.rs +++ b/nebula_terminal/src/term/mod.rs @@ -36,6 +36,7 @@ mod input; mod keyboard; #[cfg(test)] mod keyboard_contract_tests; +mod mouse; mod program_status; mod prompt; pub mod reading; @@ -198,6 +199,7 @@ pub struct Term { /// Mode flags. mode: TermMode, + mouse_input_generation: u64, /// Scroll region. /// @@ -430,6 +432,7 @@ impl Term { selection: Default::default(), title: Default::default(), mode: Default::default(), + mouse_input_generation: 0, } } @@ -730,6 +733,7 @@ impl Term { self.reset_input_cluster(); debug!("New num_cols is {num_cols} and num_lines is {num_lines}"); + self.invalidate_mouse_input(); // Move vi mode cursor with the content. let history_size = self.history_size(); @@ -796,6 +800,7 @@ impl Term { /// Swap primary and alternate screen buffer. pub fn swap_alt(&mut self) { + self.invalidate_mouse_input(); self.reset_input_cluster(); self.reading.invalidate(); self.cancel_redraw_anchor(); @@ -1860,6 +1865,7 @@ impl Handler for Term { /// Reset all important fields in the term struct. #[inline] fn reset_state(&mut self) { + self.invalidate_mouse_input(); self.reset_program_status(); self.reset_input_cluster(); self.cancel_redraw_anchor(); @@ -1997,32 +2003,13 @@ impl Handler for Term { NamedPrivateMode::ShowCursor => self.mode.insert(TermMode::SHOW_CURSOR), NamedPrivateMode::CursorKeys => self.mode.insert(TermMode::APP_CURSOR), // Mouse protocols are mutually exclusive. - NamedPrivateMode::ReportMouseClicks => { - self.mode.remove(TermMode::MOUSE_MODE); - self.mode.insert(TermMode::MOUSE_REPORT_CLICK); - self.event_proxy.send_event(Event::MouseCursorDirty); - }, - NamedPrivateMode::ReportCellMouseMotion => { - self.mode.remove(TermMode::MOUSE_MODE); - self.mode.insert(TermMode::MOUSE_DRAG); - self.event_proxy.send_event(Event::MouseCursorDirty); - }, - NamedPrivateMode::ReportAllMouseMotion => { - self.mode.remove(TermMode::MOUSE_MODE); - self.mode.insert(TermMode::MOUSE_MOTION); - self.event_proxy.send_event(Event::MouseCursorDirty); - }, + NamedPrivateMode::ReportMouseClicks + | NamedPrivateMode::ReportCellMouseMotion + | NamedPrivateMode::ReportAllMouseMotion + | NamedPrivateMode::SgrMouse + | NamedPrivateMode::Utf8Mouse => self.set_mouse_mode(mode, true), NamedPrivateMode::ReportFocusInOut => self.mode.insert(TermMode::FOCUS_IN_OUT), NamedPrivateMode::BracketedPaste => self.mode.insert(TermMode::BRACKETED_PASTE), - // Mouse encodings are mutually exclusive. - NamedPrivateMode::SgrMouse => { - self.mode.remove(TermMode::UTF8_MOUSE); - self.mode.insert(TermMode::SGR_MOUSE); - }, - NamedPrivateMode::Utf8Mouse => { - self.mode.remove(TermMode::SGR_MOUSE); - self.mode.insert(TermMode::UTF8_MOUSE); - }, NamedPrivateMode::AlternateScroll => self.mode.insert(TermMode::ALTERNATE_SCROLL), NamedPrivateMode::LineWrap => self.mode.insert(TermMode::LINE_WRAP), NamedPrivateMode::Origin => { @@ -2066,22 +2053,13 @@ impl Handler for Term { }, NamedPrivateMode::ShowCursor => self.mode.remove(TermMode::SHOW_CURSOR), NamedPrivateMode::CursorKeys => self.mode.remove(TermMode::APP_CURSOR), - NamedPrivateMode::ReportMouseClicks => { - self.mode.remove(TermMode::MOUSE_REPORT_CLICK); - self.event_proxy.send_event(Event::MouseCursorDirty); - }, - NamedPrivateMode::ReportCellMouseMotion => { - self.mode.remove(TermMode::MOUSE_DRAG); - self.event_proxy.send_event(Event::MouseCursorDirty); - }, - NamedPrivateMode::ReportAllMouseMotion => { - self.mode.remove(TermMode::MOUSE_MOTION); - self.event_proxy.send_event(Event::MouseCursorDirty); - }, + NamedPrivateMode::ReportMouseClicks + | NamedPrivateMode::ReportCellMouseMotion + | NamedPrivateMode::ReportAllMouseMotion + | NamedPrivateMode::SgrMouse + | NamedPrivateMode::Utf8Mouse => self.set_mouse_mode(mode, false), NamedPrivateMode::ReportFocusInOut => self.mode.remove(TermMode::FOCUS_IN_OUT), NamedPrivateMode::BracketedPaste => self.mode.remove(TermMode::BRACKETED_PASTE), - NamedPrivateMode::SgrMouse => self.mode.remove(TermMode::SGR_MOUSE), - NamedPrivateMode::Utf8Mouse => self.mode.remove(TermMode::UTF8_MOUSE), NamedPrivateMode::AlternateScroll => self.mode.remove(TermMode::ALTERNATE_SCROLL), NamedPrivateMode::LineWrap => self.mode.remove(TermMode::LINE_WRAP), NamedPrivateMode::Origin => self.mode.remove(TermMode::ORIGIN), diff --git a/nebula_terminal/src/term/mouse.rs b/nebula_terminal/src/term/mouse.rs new file mode 100644 index 000000000..755216197 --- /dev/null +++ b/nebula_terminal/src/term/mouse.rs @@ -0,0 +1,97 @@ +//! Mouse negotiation and coordinate lifetime, shared by all frontends. +use super::{Event, EventListener, NamedPrivateMode, Term, TermMode}; + +impl Term { + /// A held gesture is valid only in the generation in which its press was sent. + /// Sampling mode bits alone misses disable/re-enable within one parser batch. + pub fn mouse_input_generation(&self) -> u64 { + self.mouse_input_generation + } + + pub(super) fn invalidate_mouse_input(&mut self) { + self.mouse_input_generation = self.mouse_input_generation.wrapping_add(1); + } +} + +impl Term { + pub(super) fn set_mouse_mode(&mut self, mode: NamedPrivateMode, enabled: bool) { + let (flag, exclusive) = match mode { + NamedPrivateMode::ReportMouseClicks => { + (TermMode::MOUSE_REPORT_CLICK, TermMode::MOUSE_MODE) + }, + NamedPrivateMode::ReportCellMouseMotion => (TermMode::MOUSE_DRAG, TermMode::MOUSE_MODE), + NamedPrivateMode::ReportAllMouseMotion => { + (TermMode::MOUSE_MOTION, TermMode::MOUSE_MODE) + }, + NamedPrivateMode::SgrMouse => (TermMode::SGR_MOUSE, TermMode::UTF8_MOUSE), + NamedPrivateMode::Utf8Mouse => (TermMode::UTF8_MOUSE, TermMode::SGR_MOUSE), + _ => unreachable!("only mouse modes are delegated here"), + }; + let previous = self.mode; + if enabled { + self.mode.remove(exclusive); + } + self.mode.set(flag, enabled); + // Reasserting 1000h, 1002h, 1003h on redraw keeps an active press. + // Leaving/re-entering mouse reporting or changing encoding expires it. + if previous.intersects(TermMode::MOUSE_MODE) != self.mode.intersects(TermMode::MOUSE_MODE) + || (previous ^ self.mode).intersects(TermMode::SGR_MOUSE | TermMode::UTF8_MOUSE) + { + self.invalidate_mouse_input(); + } + if flag.intersects(TermMode::MOUSE_MODE) { + self.event_proxy.send_event(Event::MouseCursorDirty); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::VoidListener; + use crate::term::{Config, test::TermSize}; + use crate::vte::ansi::Processor; + + #[test] + fn mouse_generation_observes_negotiation_across_parser_chunks() { + let wire = b"\x1b[?1002h\x1b[?1006h\x1b[?1002l\x1b[?1002h"; + for chunk_size in 1..=wire.len() { + let mut term = Term::new(Config::default(), &TermSize::new(80, 24), VoidListener); + let mut parser: Processor = Processor::new(); + for chunk in wire.chunks(chunk_size) { + parser.advance(&mut term, chunk); + } + assert_eq!(term.mouse_input_generation(), 4); + assert!(term.mode().contains(TermMode::MOUSE_DRAG | TermMode::SGR_MOUSE)); + let generation = term.mouse_input_generation(); + parser.advance(&mut term, b"\x1b[?1002h\x1b[?1006h\x1b[?1000l"); + assert_eq!(term.mouse_input_generation(), generation, "idempotent modes are stable"); + parser.advance(&mut term, b"\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h"); + assert_eq!( + term.mouse_input_generation(), + generation, + "active tracking may be reasserted" + ); + parser.advance(&mut term, b"\x1b[?1005h"); + assert!(term.mode().contains(TermMode::UTF8_MOUSE)); + assert!(!term.mode().contains(TermMode::SGR_MOUSE)); + assert_ne!(term.mouse_input_generation(), generation); + } + } + + #[test] + fn screen_reset_and_resize_expire_mouse_coordinates() { + let mut term = Term::new(Config::default(), &TermSize::new(80, 24), VoidListener); + let mut parser: Processor = Processor::new(); + for wire in [b"\x1b[?1049h".as_slice(), b"\x1b[?1049l", b"\x1bc"] { + let generation = term.mouse_input_generation(); + parser.advance(&mut term, wire); + assert_ne!(term.mouse_input_generation(), generation); + } + let generation = term.mouse_input_generation(); + term.resize(TermSize::new(80, 24)); + assert_eq!(term.mouse_input_generation(), generation); + term.resize(TermSize::new(40, 12)); + assert_ne!(term.mouse_input_generation(), generation); + } +}