Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 150 additions & 8 deletions cmux-tui/crates/cmux-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -15928,6 +15959,14 @@ impl App {
mouse: &MouseEvent,
missing_surface: Option<SurfaceId>,
) -> 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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Loading