aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/ghostty/bindings.rs37
-rw-r--r--src/ghostty/mod.rs173
-rw-r--r--src/pane/terminal.rs162
3 files changed, 288 insertions, 84 deletions
diff --git a/src/ghostty/bindings.rs b/src/ghostty/bindings.rs
index 4998e2b6..4676c327 100644
--- a/src/ghostty/bindings.rs
+++ b/src/ghostty/bindings.rs
@@ -104,6 +104,34 @@ const _: () = {
["Offset of field: GhosttyString::ptr"][::std::mem::offset_of!(GhosttyString, ptr) - 0usize];
["Offset of field: GhosttyString::len"][::std::mem::offset_of!(GhosttyString, len) - 8usize];
};
+#[doc = " A caller-provided byte buffer.\n\n APIs that write to this type use `len` for the number of bytes written on\n GHOSTTY_SUCCESS and the required byte capacity on GHOSTTY_OUT_OF_SPACE."]
+#[repr(C)]
+#[derive(Debug, Copy, Clone)]
+pub struct GhosttyBuffer {
+ #[doc = " Destination buffer for bytes. May be NULL when cap is 0 to query required size."]
+ pub ptr: *mut u8,
+ #[doc = " Capacity of ptr in bytes."]
+ pub cap: usize,
+ #[doc = " Bytes written on success, or required byte capacity on GHOSTTY_OUT_OF_SPACE."]
+ pub len: usize,
+}
+#[allow(clippy::unnecessary_operation, clippy::identity_op)]
+const _: () = {
+ ["Size of GhosttyBuffer"][::std::mem::size_of::<GhosttyBuffer>() - 24usize];
+ ["Alignment of GhosttyBuffer"][::std::mem::align_of::<GhosttyBuffer>() - 8usize];
+ ["Offset of field: GhosttyBuffer::ptr"][::std::mem::offset_of!(GhosttyBuffer, ptr) - 0usize];
+ ["Offset of field: GhosttyBuffer::cap"][::std::mem::offset_of!(GhosttyBuffer, cap) - 8usize];
+ ["Offset of field: GhosttyBuffer::len"][::std::mem::offset_of!(GhosttyBuffer, len) - 16usize];
+};
+impl Default for GhosttyBuffer {
+ fn default() -> Self {
+ let mut s = ::std::mem::MaybeUninit::<Self>::uninit();
+ unsafe {
+ ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1);
+ s.assume_init()
+ }
+ }
+}
impl Default for GhosttyString {
fn default() -> Self {
let mut s = ::std::mem::MaybeUninit::<Self>::uninit();
@@ -1605,6 +1633,15 @@ pub const GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_
#[doc = " The resolved foreground color of the cell (GhosttyColorRgb).\n Resolves palette indices through the palette. Bold color handling\n is not applied; the caller should handle bold styling separately.\n Returns GHOSTTY_INVALID_VALUE if the cell has no explicit foreground\n color, in which case the caller should use whatever default foreground\n color it wants (e.g. the terminal foreground)."]
pub const GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR:
GhosttyRenderStateRowCellsData = 6;
+#[doc = " Whether the cell is contained within the current selection (bool)."]
+pub const GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_SELECTED:
+ GhosttyRenderStateRowCellsData = 7;
+#[doc = " Whether the cell has any explicit styling (bool)."]
+pub const GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_HAS_STYLING:
+ GhosttyRenderStateRowCellsData = 8;
+#[doc = " Encode the current cell's full grapheme cluster as UTF-8 into a GhosttyBuffer."]
+pub const GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8:
+ GhosttyRenderStateRowCellsData = 9;
#[doc = " Queryable data kinds for ghostty_render_state_row_cells_get().\n\n @ingroup render"]
pub type GhosttyRenderStateRowCellsData = ::std::os::raw::c_uint;
unsafe extern "C" {
diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs
index e2b592d2..57114de4 100644
--- a/src/ghostty/mod.rs
+++ b/src/ghostty/mod.rs
@@ -410,8 +410,15 @@ unsafe extern "C" fn write_pty_trampoline(
if userdata.is_null() {
return;
}
+ if data.is_null() && len != 0 {
+ return;
+ }
let state = unsafe { &mut *(userdata.cast::<WritePtyCallbackState>()) };
- let bytes = unsafe { slice::from_raw_parts(data, len) };
+ let bytes = if len == 0 {
+ &[]
+ } else {
+ unsafe { slice::from_raw_parts(data, len) }
+ };
(state.callback)(bytes);
}
@@ -565,6 +572,8 @@ impl Terminal {
cell_width_px: u32,
cell_height_px: u32,
) -> Result<(), Error> {
+ let cell_width_px = cell_width_px.max(1);
+ let cell_height_px = cell_height_px.max(1);
// SAFETY: self.raw is valid and sizes are plain values.
unsafe {
ffi::ghostty_terminal_resize(self.raw, cols, rows, cell_width_px, cell_height_px)
@@ -726,6 +735,19 @@ impl Terminal {
grid_ref_graphemes(&grid_ref)
}
+ fn viewport_graphemes_and_style(&self, x: u16, y: u32) -> Result<(Vec<u32>, CellStyle), Error> {
+ let grid_ref = self.grid_ref(ghostty_viewport_point(x, y))?;
+ let graphemes = grid_ref_graphemes(&grid_ref)?;
+ let mut style = ffi::GhosttyStyle {
+ size: mem::size_of::<ffi::GhosttyStyle>(),
+ ..Default::default()
+ };
+ unsafe {
+ ffi::ghostty_grid_ref_style(&grid_ref, &mut style).into_result()?;
+ }
+ Ok((graphemes, style.into()))
+ }
+
pub fn viewport_hyperlink_uri(&self, x: u16, y: u32) -> Result<Option<String>, Error> {
let grid_ref = self.grid_ref(ghostty_viewport_point(x, y))?;
grid_ref_hyperlink_uri(&grid_ref)
@@ -1182,29 +1204,20 @@ impl Terminal {
return Ok(Vec::new());
}
- let cols = self.cols()?.max(1) as u32;
- let rows = self.rows()?.max(1) as u32;
- let cell_width = (self.width_px()? / cols).max(1);
- let cell_height = (self.height_px()? / rows).max(1);
- let mut render_state = RenderState::new()?;
- render_state.update(self)?;
- let mut row_iterator = RowIterator::new()?;
- let mut row_cells = RowCells::new()?;
- let mut row_iter = render_state.populate_row_iterator(&mut row_iterator)?;
- let mut graphemes = Vec::new();
+ let viewport_cols = self.cols()?.max(1);
+ let viewport_rows = self.rows()?.max(1);
+ let cell_width = (self.width_px()? / u32::from(viewport_cols)).max(1);
+ let cell_height = (self.height_px()? / u32::from(viewport_rows)).max(1);
let mut runs = Vec::new();
- let mut y = 0u16;
- while row_iter.next() {
- let mut cells = row_iter.populate_cells(&mut row_cells)?;
+ for y in 0..viewport_rows {
let mut current: Option<KittyVirtualRun> = None;
- let mut x = 0u16;
- while cells.next() {
- let cell = kitty_virtual_cell(x, y, &cells, &mut graphemes)?;
+ for x in 0..viewport_cols {
+ let (graphemes, style) = self.viewport_graphemes_and_style(x, u32::from(y))?;
+ let cell = kitty_virtual_cell(x, y, &graphemes, style);
match cell {
Some(cell) => {
if let Some(run) = current.as_mut() {
if run.append(cell) {
- x = x.saturating_add(1);
continue;
}
runs.push(*run);
@@ -1217,12 +1230,10 @@ impl Terminal {
}
}
}
- x = x.saturating_add(1);
}
if let Some(run) = current {
runs.push(run);
}
- y = y.saturating_add(1);
}
let mut placements = Vec::new();
@@ -1455,14 +1466,12 @@ fn find_virtual_placement_spec(
fn kitty_virtual_cell(
x: u16,
y: u16,
- cells: &RowCellIter<'_>,
- graphemes: &mut Vec<u32>,
-) -> Result<Option<KittyVirtualCell>, Error> {
- cells.graphemes_into(graphemes)?;
+ graphemes: &[u32],
+ style: CellStyle,
+) -> Option<KittyVirtualCell> {
if graphemes.first().copied() != Some(KITTY_UNICODE_PLACEHOLDER) {
- return Ok(None);
+ return None;
}
- let style = cells.style()?;
let image_id_low = style
.fg_color
.map(kitty_placeholder_color_to_id)
@@ -1482,7 +1491,7 @@ fn kitty_virtual_cell(
.and_then(|codepoint| kitty_placeholder_diacritic_index(*codepoint))
.filter(|high| *high <= u32::from(u8::MAX));
- Ok(Some(KittyVirtualCell {
+ Some(KittyVirtualCell {
x,
y,
image_id_low,
@@ -1490,7 +1499,7 @@ fn kitty_virtual_cell(
placement_id,
row,
col,
- }))
+ })
}
fn kitty_placeholder_color_to_id(color: CellColor) -> u32 {
@@ -2365,42 +2374,95 @@ impl<'a> RowCellIter<'a> {
}
}
- pub fn grapheme_len(&self) -> Result<u32, Error> {
- let mut len = 0u32;
- // SAFETY: len output matches requested cell data type.
+ fn raw_cell_text_into(&self, text: &mut String) -> Result<(), Error> {
+ let raw = self.raw_cell()?;
+ let mut has_text = false;
unsafe {
- ffi::ghostty_render_state_row_cells_get(
- self.cells.raw,
- ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN,
- (&mut len as *mut u32).cast(),
+ ffi::ghostty_cell_get(
+ raw,
+ ffi::GhosttyCellData_GHOSTTY_CELL_DATA_HAS_TEXT,
+ (&mut has_text as *mut bool).cast(),
)
.into_result()?;
}
- Ok(len)
+ if !has_text {
+ return Ok(());
+ }
+
+ let mut codepoint = 0u32;
+ unsafe {
+ ffi::ghostty_cell_get(
+ raw,
+ ffi::GhosttyCellData_GHOSTTY_CELL_DATA_CODEPOINT,
+ (&mut codepoint as *mut u32).cast(),
+ )
+ .into_result()?;
+ }
+ if let Some(ch) = char::from_u32(codepoint) {
+ text.push(ch);
+ }
+ Ok(())
}
- pub fn graphemes(&self) -> Result<Vec<u32>, Error> {
- let mut out = Vec::new();
- self.graphemes_into(&mut out)?;
- Ok(out)
+ pub fn grapheme_text(&self) -> Result<String, Error> {
+ let mut bytes = Vec::new();
+ let mut text = String::new();
+ self.grapheme_text_into(&mut bytes, &mut text)?;
+ Ok(text)
}
- pub fn graphemes_into(&self, out: &mut Vec<u32>) -> Result<(), Error> {
- let len = self.grapheme_len()? as usize;
- out.clear();
- out.resize(len, 0);
- if len == 0 {
- return Ok(());
+ pub fn grapheme_text_into(&self, bytes: &mut Vec<u8>, text: &mut String) -> Result<(), Error> {
+ text.clear();
+ bytes.clear();
+
+ let mut buffer = ffi::GhosttyBuffer {
+ ptr: ptr::null_mut(),
+ cap: 0,
+ len: 0,
+ };
+ let result = unsafe {
+ ffi::ghostty_render_state_row_cells_get(
+ self.cells.raw,
+ ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8,
+ (&mut buffer as *mut ffi::GhosttyBuffer).cast(),
+ )
+ };
+ match result {
+ ffi::GhosttyResult_GHOSTTY_SUCCESS if buffer.len == 0 => {
+ return self.raw_cell_text_into(text);
+ }
+ ffi::GhosttyResult_GHOSTTY_SUCCESS => {
+ return Err(Error(ffi::GhosttyResult_GHOSTTY_INVALID_VALUE));
+ }
+ ffi::GhosttyResult_GHOSTTY_OUT_OF_SPACE => {}
+ other => return Err(Error(other)),
+ }
+
+ if buffer.len == 0 {
+ return self.raw_cell_text_into(text);
}
- // SAFETY: out buffer is allocated for the grapheme count returned by the API.
+ bytes.resize(buffer.len, 0);
+ let mut buffer = ffi::GhosttyBuffer {
+ ptr: bytes.as_mut_ptr(),
+ cap: bytes.len(),
+ len: 0,
+ };
unsafe {
ffi::ghostty_render_state_row_cells_get(
self.cells.raw,
- ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF,
- out.as_mut_ptr().cast::<c_void>(),
+ ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8,
+ (&mut buffer as *mut ffi::GhosttyBuffer).cast(),
)
.into_result()?;
}
+ if buffer.len > bytes.len() {
+ return Err(Error(ffi::GhosttyResult_GHOSTTY_OUT_OF_SPACE));
+ }
+ bytes.truncate(buffer.len);
+ match std::str::from_utf8(bytes) {
+ Ok(value) => text.push_str(value),
+ Err(_) => text.push_str(&String::from_utf8_lossy(bytes)),
+ }
Ok(())
}
}
@@ -2513,7 +2575,8 @@ mod tests {
let mut terminal = Terminal::new(10, 5, 0).unwrap();
terminal.enable_kitty_graphics().unwrap();
terminal.resize(10, 5, 8, 16).unwrap();
- terminal.write(b"\x1b_Gq=2,a=T,C=1,U=1,f=32,s=1,v=1,i=1193046,c=2,r=1,m=0;/wAA/w==\x1b\\");
+ terminal.write(b"\x1b_Gq=2,a=t,t=d,f=32,s=1,v=1,i=1193046,m=0;/wAA/w==\x1b\\");
+ terminal.write(b"\x1b_Gq=2,a=p,U=1,i=1193046,c=2,r=1\x1b\\");
terminal.write("\x1b[2;3H\x1b[38;2;18;52;86m\u{10eeee}\u{0305}\u{0305}\u{10eeee}\u{0305}\u{030d}\x1b[0m".as_bytes());
let placements = terminal.kitty_image_placements().unwrap();
@@ -2721,13 +2784,11 @@ mod tests {
let mut cells = row_iter.populate_cells(&mut row_cells).unwrap();
let mut line = String::new();
while cells.next() {
- let graphemes = cells.graphemes().unwrap();
- if let Some(codepoint) = graphemes.first().copied() {
- if let Some(ch) = char::from_u32(codepoint) {
- line.push(ch);
- }
- } else {
+ let text = cells.grapheme_text().unwrap();
+ if text.is_empty() {
line.push(' ');
+ } else {
+ line.push_str(&text);
}
}
let trimmed = line.trim_end().to_string();
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index db05d1fa..8841bad0 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -562,9 +562,58 @@ impl GhosttyPaneTerminal {
pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) {
if let Ok(mut core) = self.core.lock() {
+ let offset_from_bottom = core
+ .terminal
+ .scrollbar()
+ .ok()
+ .map(|scrollbar| {
+ scrollbar
+ .total
+ .saturating_sub(scrollbar.offset + scrollbar.len)
+ })
+ .unwrap_or(0);
+ let bottom_before_resize = ghostty_detection_text(&core)
+ .map(|text| !text.trim().is_empty())
+ .unwrap_or(false);
+ let resize_recovery_probe_lines = usize::from(rows)
+ .saturating_mul(8)
+ .max(DEFAULT_DETECTION_ROWS);
+ let replay_ansi = if core.terminal.active_screen().ok()
+ == Some(crate::ghostty::ActiveScreen::Primary)
+ && bottom_before_resize
+ {
+ ghostty_recent_ansi(&core, resize_recovery_probe_lines, true)
+ .ok()
+ .filter(|ansi| !ansi.trim().is_empty())
+ } else {
+ None
+ };
+
let _ = core
.terminal
.resize(cols, rows, cell_width_px, cell_height_px);
+
+ let bottom_is_blank = ghostty_detection_text(&core)
+ .map(|text| text.trim().is_empty())
+ .unwrap_or(false);
+ if bottom_is_blank {
+ if let Some(ansi) = replay_ansi.as_deref() {
+ core.terminal.scroll_viewport_bottom();
+ core.terminal.write(ansi.as_bytes());
+ }
+ }
+ ghostty_restore_scroll_offset_from_bottom(&mut core.terminal, offset_from_bottom);
+ if offset_from_bottom > 0 {
+ let mut remaining = offset_from_bottom.min(resize_recovery_probe_lines);
+ while remaining > 0
+ && ghostty_visible_text(&mut core)
+ .map(|text| text.trim().is_empty())
+ .unwrap_or(false)
+ {
+ core.terminal.scroll_viewport_delta(1);
+ remaining -= 1;
+ }
+ }
}
}
@@ -889,7 +938,7 @@ impl GhosttyPaneTerminal {
Ok(rows) => rows,
Err(_) => return,
};
- let mut grapheme_scratch = Vec::new();
+ let mut grapheme_bytes = Vec::new();
let mut symbol_scratch = String::new();
let mut y = 0u16;
while y < area.height && rows.next() {
@@ -911,7 +960,7 @@ impl GhosttyPaneTerminal {
&cells,
wide,
hide_kitty_placeholders,
- &mut grapheme_scratch,
+ &mut grapheme_bytes,
&mut symbol_scratch,
) {
Ok(symbol) => symbol,
@@ -1034,6 +1083,9 @@ fn ghostty_recent_text(
) -> 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 {
@@ -1053,7 +1105,7 @@ fn ghostty_recent_text_unwrapped(
return Ok(String::new());
}
let start = total_rows.saturating_sub(lines) as u32;
- let end = (total_rows.saturating_sub(1)) as u32;
+ let end = total_rows.saturating_sub(1) as u32;
core.terminal
.read_text_screen((0, start), (cols.saturating_sub(1), end), false)
}
@@ -1069,11 +1121,29 @@ fn ghostty_recent_ansi(
return Ok(String::new());
}
let start = total_rows.saturating_sub(lines) as u32;
- let end = (total_rows.saturating_sub(1)) 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)
}
+fn ghostty_restore_scroll_offset_from_bottom(
+ terminal: &mut crate::ghostty::Terminal,
+ offset_from_bottom: usize,
+) {
+ terminal.scroll_viewport_bottom();
+ if offset_from_bottom == 0 {
+ return;
+ }
+ let Ok(scrollbar) = terminal.scrollbar() else {
+ return;
+ };
+ let max_offset = scrollbar.total.saturating_sub(scrollbar.len);
+ let offset = offset_from_bottom.min(max_offset).min(isize::MAX as usize) as isize;
+ if offset > 0 {
+ terminal.scroll_viewport_delta(-offset);
+ }
+}
+
fn ghostty_extract_selection(
core: &mut GhosttyPaneCore,
selection: &crate::selection::Selection,
@@ -1091,7 +1161,9 @@ fn ghostty_screen_row(
let mut line = String::new();
for x in 0..cols {
let graphemes = core.terminal.screen_graphemes(x, y)?;
- if graphemes.is_empty() {
+ if graphemes.is_empty()
+ || graphemes.first().copied() == Some(crate::ghostty::KITTY_UNICODE_PLACEHOLDER)
+ {
line.push(' ');
} else {
for codepoint in graphemes {
@@ -1117,18 +1189,12 @@ fn ghostty_line_from_cells(
fn ghostty_cell_symbol(
cells: &crate::ghostty::RowCellIter<'_>,
) -> Result<String, crate::ghostty::Error> {
- let graphemes = cells.graphemes()?;
- if graphemes.is_empty() {
+ let text = cells.grapheme_text()?;
+ if text.chars().next().map(u32::from) == Some(crate::ghostty::KITTY_UNICODE_PLACEHOLDER) {
return Ok(" ".to_string());
}
- let mut text = String::new();
- for codepoint in graphemes {
- if let Some(ch) = char::from_u32(codepoint) {
- text.push(ch);
- }
- }
if text.is_empty() {
- text.push(' ');
+ return Ok(" ".to_string());
}
Ok(text)
}
@@ -1167,7 +1233,7 @@ fn ghostty_buffer_symbol_into<'a>(
cells: &crate::ghostty::RowCellIter<'_>,
wide: crate::ghostty::CellWide,
hide_kitty_placeholders: bool,
- grapheme_scratch: &mut Vec<u32>,
+ grapheme_bytes: &mut Vec<u8>,
symbol_scratch: &'a mut String,
) -> Result<&'a str, crate::ghostty::Error> {
symbol_scratch.clear();
@@ -1175,21 +1241,13 @@ fn ghostty_buffer_symbol_into<'a>(
crate::ghostty::CellWide::SpacerTail => {}
crate::ghostty::CellWide::SpacerHead => symbol_scratch.push(' '),
crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::Wide => {
- cells.graphemes_into(grapheme_scratch)?;
+ cells.grapheme_text_into(grapheme_bytes, symbol_scratch)?;
let hidden_kitty_placeholder = hide_kitty_placeholders
- && grapheme_scratch.first().copied()
+ && symbol_scratch.chars().next().map(u32::from)
== Some(crate::ghostty::KITTY_UNICODE_PLACEHOLDER);
- if hidden_kitty_placeholder || grapheme_scratch.is_empty() {
+ if hidden_kitty_placeholder || symbol_scratch.is_empty() {
+ symbol_scratch.clear();
symbol_scratch.push(' ');
- } else {
- for &codepoint in grapheme_scratch.iter() {
- if let Some(ch) = char::from_u32(codepoint) {
- symbol_scratch.push(ch);
- }
- }
- if symbol_scratch.is_empty() {
- symbol_scratch.push(' ');
- }
}
}
}
@@ -2106,8 +2164,18 @@ mod tests {
let metrics = pane.scroll_metrics().expect("scroll metrics after resize");
assert_eq!(metrics.viewport_rows, rows as usize);
assert!(metrics.offset_from_bottom <= metrics.max_offset_from_bottom);
+ assert!(
+ metrics.offset_from_bottom > 0,
+ "resize should preserve a scrolled viewport instead of jumping to bottom"
+ );
assert!(metrics.max_offset_from_bottom > 0);
- assert!(!pane.visible_text().trim().is_empty());
+ let visible = pane.visible_text();
+ assert!(
+ !visible.trim().is_empty(),
+ "visible text should not be empty after resize to {rows}x{cols}; metrics={metrics:?}; detection={:?}; recent={:?}",
+ pane.detection_text(),
+ pane.recent_text(6)
+ );
assert!(
pane.detection_text().contains("END"),
"bottom detection should remain independent from the scrolled viewport after resize"
@@ -2116,6 +2184,42 @@ mod tests {
}
#[test]
+ fn resize_recovery_does_not_replay_history_when_visible_screen_was_blank() {
+ let (tx, _rx) = mpsc::channel(4);
+ let mut terminal = crate::ghostty::Terminal::new(20, 3, 10_000).unwrap();
+ terminal.write(b"old history\r\n\x1b[2J\x1b[H");
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+
+ assert!(pane.visible_text().trim().is_empty());
+ assert!(pane.detection_text().trim().is_empty());
+
+ pane.resize(3, 20, 0, 0);
+
+ assert!(pane.visible_text().trim().is_empty());
+ assert!(pane.detection_text().trim().is_empty());
+ assert!(pane.recent_text(3).trim().is_empty());
+ }
+
+ #[test]
+ fn resize_recovery_does_not_replay_scrolled_history_over_blank_bottom() {
+ let (tx, _rx) = mpsc::channel(4);
+ let mut terminal = crate::ghostty::Terminal::new(20, 3, 10_000).unwrap();
+ write_numbered_lines(&mut terminal, 20);
+ terminal.write(b"\x1b[2J\x1b[H");
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+
+ assert!(pane.detection_text().trim().is_empty());
+ let metrics = pane.scroll_metrics().expect("scroll metrics");
+ pane.set_scroll_offset_from_bottom(metrics.max_offset_from_bottom);
+ assert!(!pane.visible_text().trim().is_empty());
+
+ pane.resize(3, 20, 0, 0);
+
+ assert!(pane.detection_text().trim().is_empty());
+ assert!(pane.recent_text(3).trim().is_empty());
+ }
+
+ #[test]
fn synchronized_output_suppresses_intermediate_render_requests_until_batch_ends() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
@@ -2198,6 +2302,8 @@ mod tests {
assert_eq!(buffer[(0, 0)].symbol(), "b");
assert_eq!(buffer[(6, 0)].symbol(), " ");
assert_eq!(buffer[(7, 0)].symbol(), "a");
+ assert_eq!(pane.visible_text().lines().next(), Some("before after"));
+ assert_eq!(pane.recent_text(5), "before after\n");
}
#[test]