aboutsummaryrefslogtreecommitdiff
path: root/src/terminal
diff options
context:
space:
mode:
Diffstat (limited to 'src/terminal')
-rw-r--r--src/terminal/history_read.rs307
-rw-r--r--src/terminal/mod.rs2
-rw-r--r--src/terminal/runtime.rs10
3 files changed, 319 insertions, 0 deletions
diff --git a/src/terminal/history_read.rs b/src/terminal/history_read.rs
new file mode 100644
index 00000000..decce3aa
--- /dev/null
+++ b/src/terminal/history_read.rs
@@ -0,0 +1,307 @@
+use crate::ghostty::{CellWide, ScreenTextRow};
+use crate::pane::TerminalReadSnapshot;
+
+const MIN_ALIGNMENT_RATIO_PERCENT: usize = 30;
+const SIMILAR_VIEWPORT_RATIO_PERCENT: usize = 70;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct ScreenSnapshot {
+ pub(crate) cols: u16,
+ pub(crate) rows: Vec<ScreenTextRow>,
+}
+
+impl ScreenSnapshot {
+ pub(crate) fn similar_text(&self, other: &Self) -> bool {
+ if self.cols != other.cols || self.rows.len() != other.rows.len() {
+ return false;
+ }
+ let left = row_identities(&self.rows);
+ let right = row_identities(&other.rows);
+ let comparable = left
+ .iter()
+ .zip(&right)
+ .filter(|(left, right)| !left.is_empty() || !right.is_empty())
+ .count();
+ if comparable == 0 {
+ return true;
+ }
+ let matches = left
+ .iter()
+ .zip(&right)
+ .filter(|(left, right)| left == right && (!left.is_empty() || !right.is_empty()))
+ .count();
+ matches.saturating_mul(100) >= comparable.saturating_mul(SIMILAR_VIEWPORT_RATIO_PERCENT)
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum UpwardMerge {
+ Advanced { rows: usize },
+ Unchanged,
+ Unaligned,
+}
+
+pub(crate) fn merge_scrolled_up(
+ history: &mut Vec<ScreenTextRow>,
+ previous: &ScreenSnapshot,
+ next: &ScreenSnapshot,
+) -> UpwardMerge {
+ if previous.cols != next.cols || previous.rows.len() != next.rows.len() {
+ return UpwardMerge::Unaligned;
+ }
+ let previous_text = row_identities(&previous.rows);
+ let next_text = row_identities(&next.rows);
+ if previous_text == next_text {
+ return UpwardMerge::Unchanged;
+ }
+ let Some(shift) = best_upward_shift(&previous_text, &next_text) else {
+ return UpwardMerge::Unaligned;
+ };
+ let Some(boundary) = (0..previous_text.len().saturating_sub(shift)).find_map(|index| {
+ let next_index = index + shift;
+ (!previous_text[index].is_empty() && previous_text[index] == next_text[next_index])
+ .then_some(next_index)
+ }) else {
+ return UpwardMerge::Unaligned;
+ };
+ let added: Vec<_> = next.rows[..boundary]
+ .iter()
+ .enumerate()
+ .filter(|(index, _)| {
+ next_text[*index].is_empty() || previous_text.get(*index) != Some(&next_text[*index])
+ })
+ .map(|(_, row)| row.clone())
+ .collect();
+ if added.is_empty() {
+ return UpwardMerge::Unaligned;
+ }
+ let rows = added.len();
+ history.splice(0..0, added);
+ UpwardMerge::Advanced { rows }
+}
+
+pub(crate) fn snapshot_text(
+ rows: &[ScreenTextRow],
+ lines: usize,
+ unwrap: bool,
+ truncated: bool,
+) -> TerminalReadSnapshot {
+ let start = rows.len().saturating_sub(lines);
+ let rows = &rows[start..];
+ let text = if unwrap {
+ unwrapped_text(rows)
+ } else {
+ wrapped_text(rows)
+ };
+ TerminalReadSnapshot { text, truncated }
+}
+
+fn best_upward_shift(previous: &[String], next: &[String]) -> Option<usize> {
+ let mut best = None;
+ for shift in 1..previous.len() {
+ let overlap = previous.len() - shift;
+ let mut comparable = 0usize;
+ let mut matches = 0usize;
+ for index in 0..overlap {
+ let before = &previous[index];
+ let after = &next[index + shift];
+ if before.is_empty() || after.is_empty() {
+ continue;
+ }
+ comparable += 1;
+ if before == after {
+ matches += 1;
+ }
+ }
+ if comparable == 0
+ || matches.saturating_mul(100) < comparable.saturating_mul(MIN_ALIGNMENT_RATIO_PERCENT)
+ {
+ continue;
+ }
+ if best.is_none_or(|(_, best_matches, best_comparable)| {
+ matches > best_matches || (matches == best_matches && comparable > best_comparable)
+ }) {
+ best = Some((shift, matches, comparable));
+ }
+ }
+ best.map(|(shift, _, _)| shift)
+}
+
+fn row_identities(rows: &[ScreenTextRow]) -> Vec<String> {
+ rows.iter()
+ .map(|row| row_text(row).trim_end().to_string())
+ .collect()
+}
+
+fn wrapped_text(rows: &[ScreenTextRow]) -> String {
+ let mut lines: Vec<_> = rows
+ .iter()
+ .map(|row| row_text(row).trim_end().to_string())
+ .collect();
+ while lines.last().is_some_and(|line| line.trim().is_empty()) {
+ lines.pop();
+ }
+ lines_to_text(lines)
+}
+
+fn unwrapped_text(rows: &[ScreenTextRow]) -> String {
+ let mut lines = Vec::new();
+ let mut current = String::new();
+ for row in rows {
+ let text = row_text(row);
+ if row.soft_wrapped {
+ current.push_str(text.trim_end());
+ } else {
+ current.push_str(text.trim_end());
+ lines.push(std::mem::take(&mut current));
+ }
+ }
+ if !current.is_empty() {
+ lines.push(current);
+ }
+ while lines.last().is_some_and(|line| line.trim().is_empty()) {
+ lines.pop();
+ }
+ lines_to_text(lines)
+}
+
+fn lines_to_text(lines: Vec<String>) -> String {
+ let text = lines.join("\n");
+ if text.is_empty() {
+ text
+ } else {
+ format!("{text}\n")
+ }
+}
+
+fn row_text(row: &ScreenTextRow) -> String {
+ let mut text = String::new();
+ for cell in &row.cells {
+ if cell.wide == CellWide::SpacerTail {
+ continue;
+ }
+ if cell.graphemes.is_empty()
+ || cell.graphemes.first().copied() == Some(crate::ghostty::KITTY_UNICODE_PLACEHOLDER)
+ {
+ text.push(' ');
+ } else {
+ text.extend(cell.graphemes.iter().map(|codepoint| {
+ char::from_u32(*codepoint).unwrap_or(char::REPLACEMENT_CHARACTER)
+ }));
+ }
+ }
+ text
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::ghostty::{ScreenTextCell, ScreenTextRow};
+
+ fn row(text: &str) -> ScreenTextRow {
+ ScreenTextRow {
+ cells: text
+ .chars()
+ .map(|ch| ScreenTextCell {
+ wide: CellWide::Narrow,
+ graphemes: vec![ch as u32],
+ })
+ .collect(),
+ soft_wrapped: false,
+ wrap_continuation: false,
+ }
+ }
+
+ fn snapshot(lines: &[&str]) -> ScreenSnapshot {
+ ScreenSnapshot {
+ cols: 20,
+ rows: lines.iter().map(|line| row(line)).collect(),
+ }
+ }
+
+ #[test]
+ fn viewport_similarity_tolerates_small_dynamic_regions() {
+ let initial = snapshot(&["line 1", "line 2", "worked for 2s", "prompt"]);
+ let status_changed = snapshot(&["line 1", "line 2", "worked for 3s", "prompt"]);
+ let scrolled = snapshot(&["older", "line 1", "line 2", "prompt"]);
+
+ assert!(initial.similar_text(&status_changed));
+ assert!(!initial.similar_text(&scrolled));
+ }
+
+ #[test]
+ fn controlled_upward_scroll_prepends_only_new_rows() {
+ let previous = snapshot(&["line 3", "line 4", "line 5", "status"]);
+ let next = snapshot(&["line 1", "line 2", "line 3", "line 4"]);
+ let mut history = previous.rows.clone();
+
+ assert_eq!(
+ merge_scrolled_up(&mut history, &previous, &next),
+ UpwardMerge::Advanced { rows: 2 }
+ );
+ assert_eq!(
+ row_identities(&history),
+ ["line 1", "line 2", "line 3", "line 4", "line 5", "status"]
+ );
+ }
+
+ #[test]
+ fn fixed_header_is_not_repeated_or_counted_as_scrolled_history() {
+ let previous = snapshot(&["sticky", "line 4", "line 5", "line 6", "line 7"]);
+ let next = snapshot(&["sticky", "line 2", "line 3", "line 4", "line 5"]);
+ let mut history = previous.rows.clone();
+
+ assert_eq!(
+ merge_scrolled_up(&mut history, &previous, &next),
+ UpwardMerge::Advanced { rows: 2 }
+ );
+ assert_eq!(
+ row_identities(&history),
+ ["line 2", "line 3", "sticky", "line 4", "line 5", "line 6", "line 7"]
+ );
+ }
+
+ #[test]
+ fn unchanged_and_unaligned_frames_do_not_change_history() {
+ let previous = snapshot(&["line 1", "line 2", "line 3"]);
+ let mut history = previous.rows.clone();
+
+ assert_eq!(
+ merge_scrolled_up(&mut history, &previous, &previous),
+ UpwardMerge::Unchanged
+ );
+ assert_eq!(
+ merge_scrolled_up(
+ &mut history,
+ &previous,
+ &snapshot(&["other a", "other b", "other c"]),
+ ),
+ UpwardMerge::Unaligned
+ );
+ assert_eq!(history, previous.rows);
+ }
+
+ #[test]
+ fn snapshot_text_limits_rendered_rows_before_unwrapping() {
+ let mut first = row("hello ");
+ first.soft_wrapped = true;
+ let mut second = row("world");
+ second.wrap_continuation = true;
+ let rows = vec![row("older"), first, second];
+
+ assert_eq!(
+ snapshot_text(&rows, 2, false, true),
+ TerminalReadSnapshot {
+ text: "hello\nworld\n".into(),
+ truncated: true,
+ }
+ );
+ assert_eq!(
+ snapshot_text(&rows, 2, true, true),
+ TerminalReadSnapshot {
+ text: "helloworld\n".into(),
+ truncated: true,
+ }
+ );
+ }
+}
diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs
index a67b7bae..129634c6 100644
--- a/src/terminal/mod.rs
+++ b/src/terminal/mod.rs
@@ -1,9 +1,11 @@
+mod history_read;
mod id;
mod runtime;
mod runtime_registry;
pub mod state;
mod title;
+pub(crate) use history_read::{merge_scrolled_up, snapshot_text, ScreenSnapshot, UpwardMerge};
pub use id::TerminalId;
pub use runtime::TerminalRuntime;
pub(crate) use runtime_registry::TerminalRuntimeRegistry;
diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
index d4b66f06..38b36942 100644
--- a/src/terminal/runtime.rs
+++ b/src/terminal/runtime.rs
@@ -433,6 +433,16 @@ impl TerminalRuntime {
self.0.wheel_routing()
}
+ pub(crate) fn screen_text_snapshot(
+ &self,
+ ) -> Option<(
+ crate::ghostty::ActiveScreen,
+ crate::terminal::ScreenSnapshot,
+ )> {
+ let (screen, cols, rows) = self.0.screen_text_snapshot()?;
+ Some((screen, crate::terminal::ScreenSnapshot { cols, rows }))
+ }
+
pub fn encode_mouse_button(
&self,
kind: crossterm::event::MouseEventKind,