diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-04 02:14:49 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-04 02:15:23 +0300 |
| commit | 5abdae1ac7d5475e63e39681f7014acc35d36a89 (patch) | |
| tree | 5aceecb3de0f7419c31e98e9d632dc5ec86b490d | |
| parent | 96c485653b5186b71fa88aace1128e50657197a5 (diff) | |
fix: restore windows recent pane reads
refs #962
| -rw-r--r-- | src/ghostty/mod.rs | 35 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 129 | ||||
| -rw-r--r-- | src/pane/terminal/windows_recent_fallback.rs | 336 |
3 files changed, 472 insertions, 28 deletions
diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 8a909b8c..e79c9a16 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -2277,6 +2277,41 @@ impl<'a> RowIter<'a> { Ok(dirty) } + #[cfg(windows)] + pub fn wrap_state(&self) -> Result<(bool, bool), Error> { + let mut row = 0; + // SAFETY: row output matches requested row data type. + unsafe { + ffi::ghostty_render_state_row_get( + self.iterator.raw, + ffi::GhosttyRenderStateRowData_GHOSTTY_RENDER_STATE_ROW_DATA_RAW, + (&mut row as *mut ffi::GhosttyRow).cast(), + ) + .into_result()?; + } + let mut soft_wrapped = false; + // SAFETY: wrap output matches requested row data type. + unsafe { + ffi::ghostty_row_get( + row, + ffi::GhosttyRowData_GHOSTTY_ROW_DATA_WRAP, + (&mut soft_wrapped as *mut bool).cast(), + ) + .into_result()?; + } + let mut wrap_continuation = false; + // SAFETY: wrap continuation output matches requested row data type. + unsafe { + ffi::ghostty_row_get( + row, + ffi::GhosttyRowData_GHOSTTY_ROW_DATA_WRAP_CONTINUATION, + (&mut wrap_continuation as *mut bool).cast(), + ) + .into_result()?; + } + Ok((soft_wrapped, wrap_continuation)) + } + pub fn clear_dirty(&mut self) -> Result<(), Error> { self.set_dirty(false) } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 4876121f..98888f6a 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -13,6 +13,9 @@ use unicode_width::UnicodeWidthStr; use crate::layout::PaneId; use crate::protocol::CellData; +#[cfg(windows)] +mod windows_recent_fallback; + use super::cursor::{CursorPositionSettleState, DecscusrTracker, CURSOR_POSITION_SETTLE}; use super::{ input::{ @@ -120,6 +123,8 @@ pub(crate) struct GhosttyPaneTerminal { pub(crate) struct GhosttyPaneCore { pub terminal: crate::ghostty::Terminal, + #[cfg(windows)] + recent_fallback: windows_recent_fallback::Cache, pub render_state: crate::ghostty::RenderState, pub kitty_keyboard: KittyKeyboardTracker, pub initial_default_foreground: Option<crate::ghostty::RgbColor>, @@ -379,6 +384,8 @@ impl GhosttyPaneTerminal { Ok(Self { core: Mutex::new(GhosttyPaneCore { terminal, + #[cfg(windows)] + recent_fallback: windows_recent_fallback::Cache::default(), render_state, kitty_keyboard: KittyKeyboardTracker::default(), initial_default_foreground, @@ -576,6 +583,8 @@ impl GhosttyPaneTerminal { xtgettcap_responses, &mut terminal_responses, ); + #[cfg(windows)] + windows_recent_fallback::update(&mut core); crate::render_prof::duration_since("pty.ghostty_write", write_started); let has_kitty_graphics_sequence = crate::kitty_graphics::is_enabled() @@ -693,6 +702,8 @@ impl GhosttyPaneTerminal { return; }; core.terminal.write(ansi.as_bytes()); + #[cfg(windows)] + windows_recent_fallback::update(&mut core); if let Ok(mut key_encoder) = self.key_encoder.lock() { key_encoder.set_from_terminal(&core.terminal); } @@ -1710,33 +1721,34 @@ fn ghostty_recent_text( core: &GhosttyPaneCore, lines: usize, ) -> Result<String, crate::ghostty::Error> { - let total_rows = core.terminal.total_rows()?; - let cols = core.terminal.cols()?; - if total_rows == 0 || cols == 0 { - return Ok(String::new()); - } - let start = total_rows.saturating_sub(lines); - let mut rows = Vec::with_capacity(total_rows.saturating_sub(start)); - for y in start..total_rows { - rows.push(ghostty_screen_row(core, cols, y as u32)?); + let text = ghostty_recent_text_for_terminal(&core.terminal, lines)?; + #[cfg(windows)] + { + if text.trim().is_empty() { + let fallback = windows_recent_fallback::recent_text(core, lines, false); + if !fallback.trim().is_empty() { + return Ok(fallback); + } + } } - trim_trailing_blank_rows(&mut rows); - Ok(recent_text_from_rows(&rows, lines)) + Ok(text) } fn ghostty_recent_text_unwrapped( core: &GhosttyPaneCore, lines: usize, ) -> Result<String, crate::ghostty::Error> { - let total_rows = core.terminal.total_rows()?; - let cols = core.terminal.cols()?; - if total_rows == 0 || cols == 0 { - return Ok(String::new()); + let text = ghostty_recent_text_unwrapped_for_terminal(&core.terminal, lines)?; + #[cfg(windows)] + { + if text.trim().is_empty() { + let fallback = windows_recent_fallback::recent_text(core, lines, true); + if !fallback.trim().is_empty() { + return Ok(fallback); + } + } } - let start = total_rows.saturating_sub(lines) as u32; - let end = total_rows.saturating_sub(1) as u32; - core.terminal - .read_text_screen((0, start), (cols.saturating_sub(1), end), false) + Ok(text) } fn ghostty_recent_ansi( @@ -1744,15 +1756,76 @@ fn ghostty_recent_ansi( lines: usize, unwrap: bool, ) -> Result<String, crate::ghostty::Error> { - let total_rows = core.terminal.total_rows()?; - let cols = core.terminal.cols()?; - if total_rows == 0 || cols == 0 { + let ansi = ghostty_recent_ansi_for_terminal(&core.terminal, lines, unwrap)?; + #[cfg(windows)] + { + if ansi.trim().is_empty() { + let fallback = windows_recent_fallback::recent_text(core, lines, unwrap); + if !fallback.trim().is_empty() { + return Ok(fallback); + } + } + } + Ok(ansi) +} + +fn ghostty_recent_text_for_terminal( + terminal: &crate::ghostty::Terminal, + lines: usize, +) -> Result<String, crate::ghostty::Error> { + let Some((start, end, cols)) = ghostty_recent_read_range(terminal, lines)? else { return Ok(String::new()); + }; + let mut rows = Vec::with_capacity(end.saturating_sub(start).saturating_add(1)); + for y in start..=end { + rows.push(ghostty_screen_row(terminal, cols, y as u32)?); } - let start = total_rows.saturating_sub(lines) as u32; - let end = total_rows.saturating_sub(1) as u32; - core.terminal - .read_ansi_screen((0, start), (cols.saturating_sub(1), end), false, unwrap) + trim_trailing_blank_rows(&mut rows); + Ok(recent_text_from_rows(&rows, lines)) +} + +fn ghostty_recent_text_unwrapped_for_terminal( + terminal: &crate::ghostty::Terminal, + lines: usize, +) -> Result<String, crate::ghostty::Error> { + let Some((start, end, cols)) = ghostty_recent_read_range(terminal, lines)? else { + return Ok(String::new()); + }; + terminal.read_text_screen( + (0, start as u32), + (cols.saturating_sub(1), end as u32), + false, + ) +} + +fn ghostty_recent_ansi_for_terminal( + terminal: &crate::ghostty::Terminal, + lines: usize, + unwrap: bool, +) -> Result<String, crate::ghostty::Error> { + let Some((start, end, cols)) = ghostty_recent_read_range(terminal, lines)? else { + return Ok(String::new()); + }; + terminal.read_ansi_screen( + (0, start as u32), + (cols.saturating_sub(1), end as u32), + false, + unwrap, + ) +} + +fn ghostty_recent_read_range( + terminal: &crate::ghostty::Terminal, + lines: usize, +) -> Result<Option<(usize, usize, u16)>, crate::ghostty::Error> { + let total_rows = terminal.total_rows()?; + let cols = terminal.cols()?; + if total_rows == 0 || cols == 0 || lines == 0 { + return Ok(None); + } + let end = total_rows.saturating_sub(1); + let start = end.saturating_add(1).saturating_sub(lines); + Ok(Some((start, end, cols))) } fn ghostty_restore_scroll_offset_from_bottom( @@ -1783,13 +1856,13 @@ fn ghostty_extract_selection( } fn ghostty_screen_row( - core: &GhosttyPaneCore, + terminal: &crate::ghostty::Terminal, cols: u16, y: u32, ) -> Result<String, crate::ghostty::Error> { let mut line = String::new(); for x in 0..cols { - let (wide, graphemes) = core.terminal.screen_cell(x, y)?; + let (wide, graphemes) = terminal.screen_cell(x, y)?; if wide == crate::ghostty::CellWide::SpacerTail { continue; } diff --git a/src/pane/terminal/windows_recent_fallback.rs b/src/pane/terminal/windows_recent_fallback.rs new file mode 100644 index 00000000..c9a02f67 --- /dev/null +++ b/src/pane/terminal/windows_recent_fallback.rs @@ -0,0 +1,336 @@ +use super::{ghostty_line_from_cells, GhosttyPaneCore}; + +const CACHE_LINES: usize = 2000; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(super) struct Cache { + rows: Vec<RenderedLine>, + last_snapshot: Vec<RenderedLine>, + usable: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct RenderedLine { + text: String, + soft_wrapped: bool, + wrap_continuation: bool, +} + +pub(super) fn update(core: &mut GhosttyPaneCore) { + if !primary_screen_active(core) { + return; + } + if !viewport_is_at_bottom(core) { + core.recent_fallback.usable = false; + return; + } + let Ok(snapshot) = visible_render_lines(core) else { + core.recent_fallback.usable = false; + return; + }; + if snapshot.is_empty() { + core.recent_fallback.rows.clear(); + core.recent_fallback.last_snapshot.clear(); + core.recent_fallback.usable = false; + return; + } + if snapshot == core.recent_fallback.last_snapshot { + core.recent_fallback.usable = true; + return; + } + + merge_snapshot(&mut core.recent_fallback.rows, &snapshot); + core.recent_fallback.last_snapshot = snapshot; + core.recent_fallback.usable = true; +} + +pub(super) fn recent_text(core: &GhosttyPaneCore, lines: usize, unwrap: bool) -> String { + if !primary_screen_active(core) { + return String::new(); + } + if unwrap { + unwrapped_text(core, lines) + } else { + wrapped_text(core, lines) + } +} + +fn primary_screen_active(core: &GhosttyPaneCore) -> bool { + matches!( + core.terminal.active_screen(), + Ok(crate::ghostty::ActiveScreen::Primary) + ) +} + +fn wrapped_text(core: &GhosttyPaneCore, lines: usize) -> String { + if !core.recent_fallback.usable { + return String::new(); + } + let rows: Vec<&str> = core + .recent_fallback + .rows + .iter() + .map(|line| line.text.as_str()) + .collect(); + cache_text(&rows, lines) +} + +fn unwrapped_text(core: &GhosttyPaneCore, lines: usize) -> String { + if !core.recent_fallback.usable { + return String::new(); + } + let unwrapped = unwrap_render_lines(&core.recent_fallback.rows); + let rows: Vec<&str> = unwrapped.iter().map(String::as_str).collect(); + cache_text(&rows, lines) +} + +fn viewport_is_at_bottom(core: &GhosttyPaneCore) -> bool { + let Ok(scrollbar) = core.terminal.scrollbar() else { + return true; + }; + scrollbar.offset.saturating_add(scrollbar.len) >= scrollbar.total +} + +fn visible_render_lines( + core: &mut GhosttyPaneCore, +) -> Result<Vec<RenderedLine>, crate::ghostty::Error> { + let GhosttyPaneCore { + terminal, + render_state, + .. + } = core; + render_state.update(terminal)?; + let mut row_iterator = crate::ghostty::RowIterator::new()?; + let mut row_cells = crate::ghostty::RowCells::new()?; + let mut rows = render_state.populate_row_iterator(&mut row_iterator)?; + let mut lines = Vec::new(); + while rows.next() { + let (soft_wrapped, wrap_continuation) = rows.wrap_state().unwrap_or((false, false)); + let mut cells = rows.populate_cells(&mut row_cells)?; + let text = ghostty_line_from_cells(&mut cells)?; + if !text.is_empty() { + lines.push(RenderedLine { + text, + soft_wrapped, + wrap_continuation, + }); + } + } + Ok(lines) +} + +fn unwrap_render_lines(snapshot: &[RenderedLine]) -> Vec<String> { + let mut unwrapped = Vec::new(); + let mut current = String::new(); + for line in snapshot { + if line.wrap_continuation && current.is_empty() { + continue; + } + current.push_str(&line.text); + if !line.soft_wrapped { + unwrapped.push(std::mem::take(&mut current)); + } + } + if !current.is_empty() { + unwrapped.push(current); + } + unwrapped +} + +fn merge_snapshot(cache: &mut Vec<RenderedLine>, snapshot: &[RenderedLine]) { + if snapshot.is_empty() { + return; + } + + let max_overlap = cache.len().min(snapshot.len()); + let overlap = (0..=max_overlap) + .rev() + .find(|count| *count == 0 || cache[cache.len() - count..] == snapshot[..*count]) + .unwrap_or(0); + cache.extend(snapshot[overlap..].iter().cloned()); + let overflow = cache.len().saturating_sub(CACHE_LINES); + if overflow > 0 { + cache.drain(0..overflow); + } +} + +fn cache_text(cache: &[&str], lines: usize) -> String { + if lines == 0 || cache.is_empty() { + return String::new(); + } + let start = cache.len().saturating_sub(lines); + let text = cache[start..].join("\n"); + if text.is_empty() { + text + } else { + format!("{text}\n") + } +} + +#[cfg(test)] +mod tests { + use tokio::sync::mpsc; + + use super::*; + use crate::layout::PaneId; + use crate::pane::terminal::GhosttyPaneTerminal; + + fn rendered_line( + text: impl Into<String>, + soft_wrapped: bool, + wrap_continuation: bool, + ) -> RenderedLine { + RenderedLine { + text: text.into(), + soft_wrapped, + wrap_continuation, + } + } + + #[test] + fn merges_overlapping_snapshots() { + let mut cache = Vec::new(); + + merge_snapshot( + &mut cache, + &[ + rendered_line("one", false, false), + rendered_line("two", false, false), + ], + ); + merge_snapshot( + &mut cache, + &[ + rendered_line("two", false, false), + rendered_line("three", false, false), + rendered_line("four", false, false), + ], + ); + merge_snapshot( + &mut cache, + &[ + rendered_line("three", false, false), + rendered_line("four", false, false), + rendered_line("five", false, false), + ], + ); + + let text: Vec<&str> = cache.iter().map(|line| line.text.as_str()).collect(); + assert_eq!(text, vec!["one", "two", "three", "four", "five"]); + } + + #[test] + fn unwraps_soft_wrapped_rows() { + let snapshot = vec![ + rendered_line("ABCDE", true, false), + rendered_line("FGHIJ", false, true), + rendered_line("next", false, false), + ]; + + assert_eq!(unwrap_render_lines(&snapshot), vec!["ABCDEFGHIJ", "next"]); + } + + #[test] + fn suppresses_leading_wrap_continuation() { + let snapshot = vec![ + rendered_line("suffix", false, true), + rendered_line("next", false, false), + ]; + + assert_eq!(unwrap_render_lines(&snapshot), vec!["next"]); + } + + #[test] + fn clears_on_blank_snapshot() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(40, 3, 1024).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + pane.process_pty_bytes(pane_id, 0, b"old\r\n", &tx); + assert!(pane.recent_text(3).contains("old")); + + pane.process_pty_bytes(pane_id, 0, b"\x1b[2J\x1b[H", &tx); + assert_eq!(pane.recent_text(3).trim(), ""); + } + + #[test] + fn ignores_alternate_screen_snapshots() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(40, 3, 1024).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + for line in 0..20 { + pane.process_pty_bytes(pane_id, 0, format!("{line:06}\r\n").as_bytes(), &tx); + } + { + let core = pane.core.lock().unwrap(); + assert!(core + .recent_fallback + .rows + .iter() + .any(|line| line.text.contains("000000"))); + } + + pane.process_pty_bytes(pane_id, 0, b"\x1b[?1049h\x1b[2J\x1b[H", &tx); + + let core = pane.core.lock().unwrap(); + assert!(core + .recent_fallback + .rows + .iter() + .any(|line| line.text.contains("000000"))); + } + + #[test] + fn invalidates_fallback_when_output_arrives_while_scrolled_up() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(40, 3, 1024).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + for line in 0..20 { + pane.process_pty_bytes(pane_id, 0, format!("{line:06}\r\n").as_bytes(), &tx); + } + let before = pane.scroll_metrics().expect("scroll metrics before scroll"); + pane.set_scroll_offset_from_bottom(before.max_offset_from_bottom); + pane.process_pty_bytes(pane_id, 0, b"new output\r\n", &tx); + + let core = pane.core.lock().unwrap(); + assert_eq!(recent_text(&core, 3, false), ""); + assert_eq!(recent_text(&core, 3, true), ""); + } + + #[test] + fn seed_history_updates_fallback() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(40, 3, 1024).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + pane.seed_history_ansi("seeded history\r\n"); + + let core = pane.core.lock().unwrap(); + assert!(recent_text(&core, 3, false).contains("seeded history")); + assert!(recent_text(&core, 3, true).contains("seeded history")); + } + + #[test] + fn recent_ansi_unwrapped_uses_unwrapped_render_cache() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(40, 3, 1024).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + { + let mut core = pane.core.lock().unwrap(); + core.recent_fallback.rows = vec![ + rendered_line("wrapped", true, false), + rendered_line("rows", false, true), + ]; + core.recent_fallback.usable = true; + } + + assert_eq!(pane.recent_ansi(2), "wrapped\nrows\n"); + assert_eq!(pane.recent_unwrapped_ansi(2), "wrappedrows\n"); + } +} |
