diff options
Diffstat (limited to 'src/server/alt_screen_read.rs')
| -rw-r--r-- | src/server/alt_screen_read.rs | 383 |
1 files changed, 383 insertions, 0 deletions
diff --git a/src/server/alt_screen_read.rs b/src/server/alt_screen_read.rs new file mode 100644 index 00000000..240e96de --- /dev/null +++ b/src/server/alt_screen_read.rs @@ -0,0 +1,383 @@ +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use crossterm::event::{KeyModifiers, MouseEventKind}; +use tracing::debug; + +use crate::api::schema::{PaneReadResult, ResponseResult, SuccessResponse}; +use crate::terminal::{ScreenSnapshot, TerminalId, TerminalRuntime, UpwardMerge}; + +const STEP_SETTLE: Duration = Duration::from_millis(120); +const MAX_DURATION: Duration = Duration::from_secs(15); +const MAX_RESTORE_DURATION: Duration = Duration::from_secs(5); +const MAX_UNALIGNED_CHECKS: u8 = 4; +const WHEEL_STEP_EVENTS: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + SettleInitial { checks: u8 }, + ProbeBottom, + RestoreProbe, + Harvest { unaligned_checks: u8 }, + Restore { stable_checks: u8 }, +} + +pub(crate) struct PendingAltScreenRead { + pub(crate) terminal_id: TerminalId, + request_id: String, + respond_to: mpsc::Sender<String>, + fallback_response: String, + read: PaneReadResult, + lines: usize, + unwrap: bool, + initial: ScreenSnapshot, + previous: ScreenSnapshot, + history: Vec<crate::ghostty::ScreenTextRow>, + phase: Phase, + next_poll_at: Instant, + started_at: Instant, + restore_started_at: Option<Instant>, + upward_events: usize, + reached_top: bool, + valid: bool, +} + +impl PendingAltScreenRead { + pub(crate) fn start( + terminal_id: TerminalId, + request_id: String, + respond_to: mpsc::Sender<String>, + fallback_response: String, + read: PaneReadResult, + lines: usize, + unwrap: bool, + initial: ScreenSnapshot, + now: Instant, + ) -> Self { + Self { + terminal_id, + request_id, + respond_to, + fallback_response, + read, + lines, + unwrap, + previous: initial.clone(), + history: initial.rows.clone(), + initial, + phase: Phase::SettleInitial { checks: 0 }, + next_poll_at: now + STEP_SETTLE, + started_at: now, + restore_started_at: None, + upward_events: 0, + reached_top: false, + valid: true, + } + } + + pub(crate) fn next_deadline(&self) -> Instant { + self.next_poll_at + } + + pub(crate) fn frozen_snapshot( + &self, + source: crate::api::schema::ReadSource, + lines: Option<u32>, + ) -> crate::pane::TerminalReadSnapshot { + let line_limit = lines.map(|lines| lines.min(1000) as usize); + match source { + crate::api::schema::ReadSource::Recent + | crate::api::schema::ReadSource::RecentUnwrapped => { + let limit = line_limit.unwrap_or(80); + crate::terminal::snapshot_text( + &self.initial.rows, + limit, + source == crate::api::schema::ReadSource::RecentUnwrapped, + self.initial.rows.len() > limit, + ) + } + crate::api::schema::ReadSource::Visible | crate::api::schema::ReadSource::Detection => { + let snapshot = crate::terminal::snapshot_text( + &self.initial.rows, + self.initial.rows.len(), + false, + false, + ); + crate::app::limit_snapshot_lines(snapshot.text, line_limit) + } + } + } + + pub(crate) fn abort(mut self, runtime: Option<&TerminalRuntime>, now: Instant) -> PollOutcome { + self.valid = false; + match self.phase { + Phase::SettleInitial { .. } => self.complete_fallback(), + Phase::Harvest { .. } => match runtime { + Some(runtime) => self.start_restore(runtime, now), + None => self.complete_fallback(), + }, + Phase::ProbeBottom | Phase::RestoreProbe | Phase::Restore { .. } => { + self.poll(runtime, now) + } + } + } + + pub(crate) fn poll(mut self, runtime: Option<&TerminalRuntime>, now: Instant) -> PollOutcome { + if now < self.next_poll_at { + return Some(self); + } + let Some(runtime) = runtime else { + return self.complete_fallback(); + }; + let Some((screen, snapshot)) = runtime.screen_text_snapshot() else { + return self.complete_fallback(); + }; + if screen != crate::ghostty::ActiveScreen::Alternate + || snapshot.cols != self.initial.cols + || snapshot.rows.len() != self.initial.rows.len() + { + return self.complete_fallback(); + } + if self + .restore_started_at + .is_some_and(|started| now.duration_since(started) >= MAX_RESTORE_DURATION) + { + return self.complete_fallback(); + } + if now.duration_since(self.started_at) >= MAX_DURATION + && !matches!( + self.phase, + Phase::ProbeBottom | Phase::Restore { .. } | Phase::RestoreProbe + ) + { + self.valid = false; + return self.start_restore(runtime, now); + } + + match self.phase { + Phase::SettleInitial { checks } => { + if snapshot.similar_text(&self.initial) { + if checks >= 1 { + if send_wheel( + runtime, + MouseEventKind::ScrollDown, + WHEEL_STEP_EVENTS, + &snapshot, + ) + .is_err() + { + return self.complete_fallback(); + } + self.phase = Phase::ProbeBottom; + } else { + self.phase = Phase::SettleInitial { checks: checks + 1 }; + } + } else { + self.initial = snapshot.clone(); + self.previous = snapshot.clone(); + self.history = snapshot.rows; + self.phase = Phase::SettleInitial { checks: 0 }; + } + self.next_poll_at = now + STEP_SETTLE; + Some(self) + } + Phase::ProbeBottom => { + debug!( + terminal_id = %self.terminal_id, + at_bottom = snapshot.similar_text(&self.initial), + "alternate-screen read bottom probe settled" + ); + if snapshot.similar_text(&self.initial) { + if self.valid { + self.start_harvest(runtime, now) + } else { + self.complete_fallback() + } + } else if send_wheel( + runtime, + MouseEventKind::ScrollUp, + WHEEL_STEP_EVENTS, + &snapshot, + ) + .is_ok() + { + self.phase = Phase::RestoreProbe; + self.restore_started_at = Some(now); + self.next_poll_at = now + STEP_SETTLE; + Some(self) + } else { + self.complete_fallback() + } + } + Phase::RestoreProbe => { + if snapshot.similar_text(&self.initial) { + self.complete_fallback() + } else { + self.next_poll_at = now + STEP_SETTLE; + Some(self) + } + } + Phase::Harvest { unaligned_checks } => { + let merge = crate::terminal::merge_scrolled_up( + &mut self.history, + &self.previous, + &snapshot, + ); + match merge { + UpwardMerge::Advanced { .. } => { + self.previous = snapshot; + if self.history.len() >= self.lines { + self.start_restore(runtime, now) + } else { + self.start_harvest(runtime, now) + } + } + UpwardMerge::Unchanged => { + self.reached_top = true; + self.start_restore(runtime, now) + } + UpwardMerge::Unaligned if unaligned_checks + 1 < MAX_UNALIGNED_CHECKS => { + self.phase = Phase::Harvest { + unaligned_checks: unaligned_checks + 1, + }; + self.next_poll_at = now + STEP_SETTLE; + Some(self) + } + UpwardMerge::Unaligned => { + self.valid = false; + self.start_restore(runtime, now) + } + } + } + Phase::Restore { stable_checks } => { + if snapshot.similar_text(&self.previous) { + if stable_checks >= 1 { + if self.valid { + self.complete_success() + } else { + self.complete_fallback() + } + } else { + self.phase = Phase::Restore { + stable_checks: stable_checks + 1, + }; + self.next_poll_at = now + STEP_SETTLE; + Some(self) + } + } else { + self.previous = snapshot; + if send_wheel( + runtime, + MouseEventKind::ScrollDown, + restore_batch_size(&self.previous), + &self.previous, + ) + .is_ok() + { + self.phase = Phase::Restore { stable_checks: 0 }; + self.next_poll_at = now + STEP_SETTLE; + Some(self) + } else { + self.complete_fallback() + } + } + } + } + } + + fn start_harvest(mut self, runtime: &TerminalRuntime, now: Instant) -> PollOutcome { + let events = WHEEL_STEP_EVENTS; + if send_wheel(runtime, MouseEventKind::ScrollUp, events, &self.previous).is_err() { + return self.complete_fallback(); + } + self.upward_events = self.upward_events.saturating_add(events); + self.phase = Phase::Harvest { + unaligned_checks: 0, + }; + self.next_poll_at = now + STEP_SETTLE; + Some(self) + } + + fn start_restore(mut self, runtime: &TerminalRuntime, now: Instant) -> PollOutcome { + if self.upward_events == 0 { + return self.complete_fallback(); + } + if send_wheel( + runtime, + MouseEventKind::ScrollDown, + self.upward_events, + &self.previous, + ) + .is_err() + { + return self.complete_fallback(); + } + self.phase = Phase::Restore { stable_checks: 0 }; + self.restore_started_at = Some(now); + self.next_poll_at = now + STEP_SETTLE; + Some(self) + } + + fn complete_success(mut self) -> PollOutcome { + debug!( + terminal_id = %self.terminal_id, + retained_rows = self.history.len(), + requested_rows = self.lines, + reached_top = self.reached_top, + "alternate-screen read completed" + ); + let truncated = !self.reached_top || self.history.len() > self.lines; + let snapshot = + crate::terminal::snapshot_text(&self.history, self.lines, self.unwrap, truncated); + self.read.text = snapshot.text; + self.read.truncated = snapshot.truncated; + let response = serde_json::to_string(&SuccessResponse { + id: self.request_id, + result: ResponseResult::PaneRead { read: self.read }, + }) + .unwrap_or(self.fallback_response); + let _ = self.respond_to.send(response); + None + } + + fn complete_fallback(self) -> PollOutcome { + debug!( + terminal_id = %self.terminal_id, + ?self.phase, + retained_rows = self.history.len(), + upward_events = self.upward_events, + valid = self.valid, + "alternate-screen read fell back to passive snapshot" + ); + let _ = self.respond_to.send(self.fallback_response); + None + } +} + +pub(crate) type PollOutcome = Option<PendingAltScreenRead>; + +fn restore_batch_size(snapshot: &ScreenSnapshot) -> usize { + snapshot.rows.len().saturating_div(2).max(1) +} + +fn send_wheel( + runtime: &TerminalRuntime, + kind: MouseEventKind, + events: usize, + snapshot: &ScreenSnapshot, +) -> Result<(), ()> { + if runtime.wheel_routing() != Some(crate::pane::WheelRouting::MouseReport) { + return Err(()); + } + let column = snapshot.cols.saturating_sub(1) / 2; + let row = u16::try_from(snapshot.rows.len().saturating_sub(1) / 2).unwrap_or(0); + let event = runtime + .encode_mouse_wheel(kind, column, row, KeyModifiers::empty()) + .ok_or(())?; + let mut bytes = Vec::with_capacity(event.len().saturating_mul(events)); + for _ in 0..events { + bytes.extend_from_slice(&event); + } + runtime.try_send_bytes(Bytes::from(bytes)).map_err(|_| ()) +} |
