diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 4f5933aa6876..a0c7e61b875a 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -5761,6 +5761,26 @@ impl PointerRouteIdentity { _ => None, } } + + /// A press that opens the cmux-owned context menu never enters the pane's + /// application, so the surface's content generation and encoder semantics + /// are not part of that press's route identity. Only the geometry (which + /// pane, which region) decides where the menu opens. + fn normalized_for_cmux_menu(mut self) -> Self { + if let Self::Pane { region, .. } = &mut self { + match region { + PanePointerRegion::TerminalCell { semantics, content_generation, .. } => { + *semantics = None; + *content_generation = None; + } + PanePointerRegion::BrowserCell { content_generation, .. } => { + *content_generation = None; + } + PanePointerRegion::ContentPadding | PanePointerRegion::Chrome => {} + } + } + self + } } #[derive(Clone, Default)] @@ -12355,6 +12375,12 @@ impl App { if self.pointer_route_is_globally_stale() { return true; } + if Self::mouse_opens_cmux_context_menu(mouse) { + // The menu is owned by cmux rather than the browser bitmap or a + // graphics scene. Keep global and layout barriers above, but do + // not wait for content admission this press never enters. + return false; + } if self.pending_graphics_changes_cell(mouse.column, mouse.row) { return true; } @@ -12364,12 +12390,6 @@ impl App { ) { return false; } - if Self::mouse_opens_cmux_context_menu(mouse) { - // The menu is owned by cmux rather than the browser bitmap. Keep - // geometry barriers above, but do not wait for browser content - // admission that this press never enters. - return false; - } let route = self.rendered_pointer_frame.route_for_mouse(mouse); let Some((surface, rendered_generation)) = route.browser_content_generation() else { return false; @@ -14512,7 +14532,7 @@ impl App { if let TerminalInput::Mouse(mouse) = input && !pointer_has_capture { - let rendered_route = self.rendered_pointer_frame.route_for_mouse(mouse); + let rendered_route = self.rendered_pointer_route_for_mouse(mouse); let replayed_route_changed = replay_context .as_ref() .and_then(|context| context.pointer.as_ref()) @@ -15704,7 +15724,7 @@ impl App { focus_generation: self.pointer_focus_generation, pointer_map_generation: self.rendered_pointer_frame.pointer_map_generation, route: Self::mouse_requires_rendered_route(mouse.kind) - .then(|| self.rendered_pointer_frame.route_for_mouse(mouse)), + .then(|| self.rendered_pointer_route_for_mouse(mouse)), }) } else { None @@ -15884,6 +15904,17 @@ impl App { && mouse.modifiers.contains(KeyModifiers::SHIFT) } + fn rendered_pointer_route_for_mouse(&self, mouse: &MouseEvent) -> PointerRouteIdentity { + let route = self.rendered_pointer_frame.route_for_mouse(mouse); + if Self::mouse_opens_cmux_context_menu(mouse) { + // The menu press never enters the pane's application; async output + // must not change its recorded route. + route.normalized_for_cmux_menu() + } else { + route + } + } + fn pointer_has_capture(&self, kind: MouseEventKind) -> bool { match kind { MouseEventKind::Drag(button) | MouseEventKind::Up(button) => { @@ -15928,6 +15959,14 @@ impl App { mouse: &MouseEvent, missing_surface: Option, ) -> TerminalPointerAdmissionResult { + if Self::mouse_opens_cmux_context_menu(mouse) { + // The menu is owned by cmux rather than the terminal + // application: the press never forwards bytes, so encoder + // semantics and content-generation admission cannot gate it. + // Geometry barriers (pending paints, pointer-map mutations) + // still defer it through the route staleness checks. + return TerminalPointerAdmissionResult::NotTerminal; + } let Some((surface, input_rect, expected_snapshot)) = rendered_route.terminal_pointer_snapshot() else { @@ -30364,6 +30403,100 @@ mod tests { mux.close_surface(surface.id).unwrap(); } + #[test] + fn immediate_menu_press_survives_content_changed_before_surface_output() { + let (mux, surface) = test_mux("immediate-menu-content-test", None); + surface.with_terminal(|terminal| { + for index in 0..100 { + terminal.vt_write(format!("line {index}\r\n").as_bytes()); + } + }); + let (mut app, events) = test_app_with_events(Session::Local(mux.clone())); + app.replace_tree(app.session.tree()); + app.sidebar_visible = false; + app.sync_layout((40, 15)); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + let mut terminal = Terminal::new(TestBackend::new(40, 15)).unwrap(); + app.render_action(&mut terminal, RenderAction::Draw).unwrap(); + let content = app.pane_areas[0].content; + assert_eq!(app.pointer_route_phase, PointerRoutePhase::Fresh); + + // Terminal content moves after the frame committed, exactly like PTY + // output landing between a paint and the user's press. + surface.scroll_delta(-3).unwrap(); + assert_eq!( + app.pointer_route_phase, + PointerRoutePhase::Fresh, + "the queued output event has not marked the rendered route stale yet" + ); + + app.handle(AppEvent::Input(Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: content.x + 4, + row: content.y + 2, + modifiers: KeyModifiers::SHIFT, + }))) + .unwrap(); + + assert!( + app.menu.is_some(), + "a cmux-owned context menu press must not be swallowed by terminal content \ + admission: it never forwards bytes to the terminal application" + ); + assert!(app.deferred_input.is_empty()); + mux.close_surface(surface.id).unwrap(); + } + + #[test] + fn deferred_menu_press_survives_content_repaint_before_replay() { + let (mux, surface) = test_mux("deferred-menu-content-test", None); + surface.with_terminal(|terminal| { + for index in 0..100 { + terminal.vt_write(format!("line {index}\r\n").as_bytes()); + } + }); + let (mut app, events) = test_app_with_events(Session::Local(mux.clone())); + app.replace_tree(app.session.tree()); + app.sidebar_visible = false; + app.sync_layout((40, 15)); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + let mut terminal = Terminal::new(TestBackend::new(40, 15)).unwrap(); + app.render_action(&mut terminal, RenderAction::Draw).unwrap(); + let content = app.pane_areas[0].content; + + app.pointer_route_phase = PointerRoutePhase::DrawPending; + app.handle(AppEvent::Input(Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: content.x + 4, + row: content.y + 2, + modifiers: KeyModifiers::SHIFT, + }))) + .unwrap(); + assert_eq!(app.deferred_input.len(), 1); + assert!(app.menu.is_none()); + + // Content changes while the press waits for its paint, so the + // repainted frame carries a newer terminal content generation than + // the one recorded when the press was deferred. + surface.scroll_delta(-3).unwrap(); + let repaint = app.handle(AppEvent::Mux(MuxEvent::SurfaceOutput(surface.id))).unwrap(); + app.render_action(&mut terminal, repaint).unwrap(); + assert_eq!(app.pointer_route_phase, PointerRoutePhase::Fresh); + app.replay_deferred_input().unwrap(); + + assert!(app.deferred_input.is_empty()); + assert!( + app.menu.is_some(), + "a replayed cmux-owned context menu press must not be dropped because terminal \ + content changed under it while it waited for the paint" + ); + mux.close_surface(surface.id).unwrap(); + } + #[test] fn immediate_untracked_wheel_fails_closed_before_surface_output_marks_route_stale() { let (mux, surface) = test_mux("immediate-wheel-semantics-test", None); @@ -30773,6 +30906,15 @@ mod tests { app.pointer_route_is_stale_for_mouse(&covered), "the old browser image still owns this cell until deletion is processed" ); + let menu = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + modifiers: KeyModifiers::SHIFT, + ..covered + }; + assert!( + !app.pointer_route_is_stale_for_mouse(&menu), + "a cmux-owned menu press must not wait for a graphics image it never enters" + ); app.pending_graphics_snapshot = Some(vec![GraphicIdentity { session_generation: app.session_generation,