diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index e69bb2c1..a682d7ac 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -1547,10 +1547,7 @@ impl AppState { else { return false; }; - if !rt - .input_state() - .is_some_and(crate::pane::InputState::mouse_reporting_enabled) - { + if rt.wheel_routing() != Some(crate::pane::WheelRouting::MouseReport) { return false; } rt.scroll_reset(); diff --git a/src/pane.rs b/src/pane.rs index b7a1f6c8..34775f00 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -2120,14 +2120,7 @@ impl PaneRuntime { } pub fn wheel_routing(&self) -> Option { - let input_state = self.input_state()?; - Some(if input_state.mouse_reporting_enabled() { - WheelRouting::MouseReport - } else if input_state.alternate_screen && input_state.mouse_alternate_scroll { - WheelRouting::AlternateScroll - } else { - WheelRouting::HostScroll - }) + self.terminal.wheel_routing() } pub fn encode_mouse_button( diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index fa281553..d697f76d 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -183,6 +183,10 @@ impl PaneTerminal { self.ghostty.input_state() } + pub fn wheel_routing(&self) -> Option { + self.ghostty.wheel_routing() + } + pub fn cursor_state(&self) -> Option { self.ghostty.cursor_state() } @@ -870,6 +874,29 @@ impl GhosttyPaneTerminal { }) } + pub fn wheel_routing(&self) -> Option { + let Ok(core) = self.core.lock() else { + return None; + }; + let alternate_screen = + core.terminal.active_screen().ok()? == crate::ghostty::ActiveScreen::Alternate; + let mouse_alternate_scroll = core + .terminal + .mode_get(crate::ghostty::MODE_MOUSE_ALTERNATE_SCROLL) + .ok()?; + let mouse_reporting = core.terminal.mode_get(MODE_MOUSE_ANY_MOTION).ok()? + || core.terminal.mode_get(MODE_MOUSE_BUTTON_MOTION).ok()? + || core.terminal.mode_get(MODE_MOUSE_PRESS_RELEASE).ok()? + || core.terminal.mode_get(MODE_MOUSE_X10).ok()?; + Some(if mouse_reporting { + crate::pane::WheelRouting::MouseReport + } else if alternate_screen && mouse_alternate_scroll { + crate::pane::WheelRouting::AlternateScroll + } else { + crate::pane::WheelRouting::HostScroll + }) + } + pub fn cursor_state(&self) -> Option { let mut core = self.core.lock().ok()?; let GhosttyPaneCore { diff --git a/src/server/headless.rs b/src/server/headless.rs index 0f9f3bb6..edd1a388 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -2795,7 +2795,7 @@ impl HeadlessServer { return false; }; let prepare_started = crate::render_prof::timer(); - let Some(prepared) = client.render_state.prepare_frame(&frame) else { + let Some(prepared) = client.render_state.prepare_frame(frame) else { client.render_pending = false; crate::render_prof::event("retained_send.skip_identical"); crate::render_prof::duration_since("retained_send.prepare_frame", prepare_started); @@ -2831,7 +2831,7 @@ impl HeadlessServer { match writer.render.try_send(serialized) { Ok(()) => { client.render_pending = false; - client.render_state.commit_sent_frame(frame, prepared); + client.render_state.commit_sent_frame(prepared); crate::render_prof::event("retained_send.sent"); crate::render_prof::duration_since("retained_send.try_send", send_started); true @@ -3007,21 +3007,21 @@ impl HeadlessServer { commit_graphics_cache = false; } + let max_frame_size = if frame.graphics.is_empty() { + MAX_FRAME_SIZE + } else { + MAX_GRAPHICS_FRAME_SIZE + }; + let has_graphics = !frame.graphics.is_empty(); let prepare_started = crate::render_prof::timer(); - let Some(mut prepared) = client.render_state.prepare_frame(&frame) else { + let Some(mut prepared) = client.render_state.prepare_frame(frame) else { client.render_pending = false; crate::render_prof::event("full_render.skip_identical"); crate::render_prof::duration_since("full_render.prepare_frame", prepare_started); continue; }; crate::render_prof::duration_since("full_render.prepare_frame", prepare_started); - let mut frame_to_commit = frame.clone(); - let max_frame_size = if frame.graphics.is_empty() { - MAX_FRAME_SIZE - } else { - MAX_GRAPHICS_FRAME_SIZE - }; let serialize_started = crate::render_prof::timer(); let serialized = match Self::frame_server_message_with_max( prepared.message(), @@ -3031,17 +3031,22 @@ impl HeadlessServer { crate::render_prof::duration_since("full_render.serialize", serialize_started); framed } - Err(protocol::FramingError::Oversized { claimed, max }) - if !frame.graphics.is_empty() => - { + Err(protocol::FramingError::Oversized { claimed, max }) if has_graphics => { warn!( client_id, claimed, max, "dropping graphics from oversized frame for client" ); - let mut text_only_frame = frame.clone(); + let Some(mut text_only_frame) = prepared.into_frame() else { + crate::render_prof::event("full_render.serialize_error"); + crate::render_prof::duration_since( + "full_render.serialize", + serialize_started, + ); + continue; + }; text_only_frame.graphics.clear(); let Some(text_only_prepared) = - client.render_state.prepare_frame(&text_only_frame) + client.render_state.prepare_frame(text_only_frame) else { client.render_pending = false; crate::render_prof::event("full_render.skip_identical_text_only"); @@ -3065,7 +3070,6 @@ impl HeadlessServer { } }; prepared = text_only_prepared; - frame_to_commit = text_only_frame; commit_graphics_cache = false; crate::render_prof::duration_since("full_render.serialize", serialize_started); framed @@ -3097,9 +3101,7 @@ impl HeadlessServer { client.graphics_cache = next_graphics_cache; client.graphics_surface_reset_pending = false; } - client - .render_state - .commit_sent_frame(frame_to_commit, prepared); + client.render_state.commit_sent_frame(prepared); crate::render_prof::event("full_render.sent"); crate::render_prof::duration_since("full_render.try_send", send_started); } @@ -4504,6 +4506,52 @@ next_tab = "" rt.shutdown_timeout(Duration::from_millis(100)); } + #[test] + fn terminal_attach_page_key_forwards_in_alternate_screen_without_mouse_reporting() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = b"\x1b[?1049h".to_vec(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, 5, 4096, &bytes, 4, + ); + runtime.scroll_up(3); + + apply_terminal_attach_scroll( + &runtime, + AttachScrollSource::PageKey { + input: b"\x1b[5~".to_vec(), + }, + AttachScrollDirection::Up, + 4, + None, + None, + 0, + ) + .expect("page key forward"); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded page key"), + Bytes::from_static(b"\x1b[5~") + ); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); + } + #[test] fn headless_scheduled_tasks_expire_agent_metadata() { let mut server = test_headless_server(); @@ -6384,9 +6432,9 @@ next_tab = "" frame.cells[hyperlink_idx].hyperlink = Some(0); let prepared = client .render_state - .prepare_frame(&frame) + .prepare_frame(frame) .expect("hyperlink frame differs"); - client.render_state.commit_sent_frame(frame, prepared); + client.render_state.commit_sent_frame(prepared); let runtime = server .app diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index 6699298d..58f7a456 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -41,25 +41,24 @@ impl ClientRenderState { } } - pub(crate) fn prepare_frame(&mut self, frame: &FrameData) -> Option { + pub(crate) fn prepare_frame(&mut self, frame: FrameData) -> Option { match self { Self::Semantic { last_frame } => { - if last_frame.as_ref() == Some(frame) { + if last_frame.as_ref() == Some(&frame) { crate::render_prof::event("prepare_frame.semantic.skip_current"); return None; } crate::render_prof::event("prepare_frame.semantic.changed"); - Some(PreparedRender { - message: ServerMessage::Frame(frame.clone()), - encoded: None, + Some(PreparedRender::Semantic { + message: ServerMessage::Frame(frame), }) } Self::TerminalAnsi { blit_encoder, seq } => { - if blit_encoder.is_current(frame) { + if blit_encoder.is_current(&frame) { crate::render_prof::event("prepare_frame.ansi.skip_current"); return None; } - let mut encoded = blit_encoder.encode(frame, false); + let mut encoded = blit_encoder.encode(&frame, false); crate::render_prof::event("prepare_frame.ansi.changed"); crate::render_prof::counter("prepare_frame.ansi.bytes", encoded.bytes.len() as u64); if encoded.full { @@ -72,7 +71,7 @@ impl ClientRenderState { "prepare_frame.graphics.bytes", frame.graphics.len() as u64, ); - Some(PreparedRender { + Some(PreparedRender::TerminalAnsi { message: ServerMessage::Terminal(TerminalFrame { seq: *seq + 1, width: frame.width, @@ -80,6 +79,7 @@ impl ClientRenderState { full: encoded.full, bytes: encoded.bytes.clone(), }), + frame, encoded: Some(encoded), }) } @@ -93,10 +93,22 @@ impl ClientRenderState { } } - pub(crate) fn commit_sent_frame(&mut self, frame: FrameData, prepared: PreparedRender) { - match (self, prepared.encoded) { - (Self::Semantic { last_frame }, None) => *last_frame = Some(frame), - (Self::TerminalAnsi { blit_encoder, seq }, Some(encoded)) => { + pub(crate) fn commit_sent_frame(&mut self, prepared: PreparedRender) { + match (self, prepared) { + ( + Self::Semantic { last_frame }, + PreparedRender::Semantic { + message: ServerMessage::Frame(frame), + }, + ) => *last_frame = Some(frame), + ( + Self::TerminalAnsi { blit_encoder, seq }, + PreparedRender::TerminalAnsi { + frame, + encoded: Some(encoded), + .. + }, + ) => { blit_encoder.commit(frame, encoded); *seq += 1; } @@ -138,14 +150,32 @@ fn rfind_subslice(haystack: &[u8], needle: &[u8]) -> Option { } /// A prepared client render message plus any baseline state needed after send. -pub(crate) struct PreparedRender { - message: ServerMessage, - encoded: Option, +pub(crate) enum PreparedRender { + Semantic { + message: ServerMessage, + }, + TerminalAnsi { + message: ServerMessage, + frame: FrameData, + encoded: Option, + }, } impl PreparedRender { pub(crate) fn message(&self) -> &ServerMessage { - &self.message + match self { + Self::Semantic { message } | Self::TerminalAnsi { message, .. } => message, + } + } + + pub(crate) fn into_frame(self) -> Option { + match self { + Self::Semantic { + message: ServerMessage::Frame(frame), + } => Some(frame), + Self::TerminalAnsi { frame, .. } => Some(frame), + _ => None, + } } }