aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 02:27:14 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 02:31:12 +0300
commitcc527d45fe4821cb11c484372a9f25c51c02e078 (patch)
tree2e3411b39576b0910eba75e2b5b77c5e4e398e74 /src
parentf7d315d494b6a3d583574012526d15c1ddc82bfe (diff)
perf: retain terminal render updates
Diffstat (limited to 'src')
-rw-r--r--src/ghostty/bindings.rs79
-rw-r--r--src/ghostty/mod.rs211
-rw-r--r--src/kitty_graphics.rs64
-rw-r--r--src/main.rs1
-rw-r--r--src/pane.rs14
-rw-r--r--src/pane/terminal.rs298
-rw-r--r--src/protocol/render_ansi.rs88
-rw-r--r--src/protocol/wire.rs4
-rw-r--r--src/render_prof.rs135
-rw-r--r--src/server/headless.rs791
-rw-r--r--src/server/render_stream.rs23
-rw-r--r--src/terminal/runtime.rs12
12 files changed, 1687 insertions, 33 deletions
diff --git a/src/ghostty/bindings.rs b/src/ghostty/bindings.rs
index 4676c327..aa99b150 100644
--- a/src/ghostty/bindings.rs
+++ b/src/ghostty/bindings.rs
@@ -86,6 +86,8 @@ pub const GhosttyResult_GHOSTTY_OUT_OF_MEMORY: GhosttyResult = -1;
pub const GhosttyResult_GHOSTTY_INVALID_VALUE: GhosttyResult = -2;
#[doc = " Operation failed because the provided buffer was too small"]
pub const GhosttyResult_GHOSTTY_OUT_OF_SPACE: GhosttyResult = -3;
+#[doc = " Operation completed but the requested value was not present"]
+pub const GhosttyResult_GHOSTTY_NO_VALUE: GhosttyResult = -4;
#[doc = " Result codes for libghostty-vt operations."]
pub type GhosttyResult = ::std::os::raw::c_int;
#[doc = " A borrowed byte string (pointer + length).\n\n The memory is not owned by this struct. The pointer is only valid\n for the lifetime documented by the API that produces or consumes it."]
@@ -575,6 +577,16 @@ unsafe extern "C" {
) -> GhosttyResult;
}
unsafe extern "C" {
+ #[doc = " Get multiple data fields from a cell in a single call."]
+ pub fn ghostty_cell_get_multi(
+ cell: GhosttyCell,
+ count: usize,
+ keys: *const GhosttyCellData,
+ values: *mut *mut ::std::os::raw::c_void,
+ out_written: *mut usize,
+ ) -> GhosttyResult;
+}
+unsafe extern "C" {
#[doc = " Get data from a row.\n\n Extracts typed data from the given row based on the specified\n data type. The output pointer must be of the appropriate type for the\n requested data kind. Valid data types and output types are documented\n in the `GhosttyRowData` enum.\n\n @param row The row value\n @param data The type of data to extract\n @param out Pointer to store the extracted data (type depends on data parameter)\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the\n data type is invalid\n\n @ingroup screen"]
pub fn ghostty_row_get(
row: GhosttyRow,
@@ -582,6 +594,16 @@ unsafe extern "C" {
out: *mut ::std::os::raw::c_void,
) -> GhosttyResult;
}
+unsafe extern "C" {
+ #[doc = " Get multiple data fields from a row in a single call."]
+ pub fn ghostty_row_get_multi(
+ row: GhosttyRow,
+ count: usize,
+ keys: *const GhosttyRowData,
+ values: *mut *mut ::std::os::raw::c_void,
+ out_written: *mut usize,
+ ) -> GhosttyResult;
+}
#[doc = " Style identifier type.\n\n Used to look up the full style from a grid reference.\n Obtain this from a cell via GHOSTTY_CELL_DATA_STYLE_ID.\n\n @ingroup style"]
pub type GhosttyStyleId = u16;
pub const GhosttyStyleColorTag_GHOSTTY_STYLE_COLOR_NONE: GhosttyStyleColorTag = 0;
@@ -1479,6 +1501,9 @@ pub const GhosttyRenderStateRowData_GHOSTTY_RENDER_STATE_ROW_DATA_RAW: GhosttyRe
#[doc = " Populate a pre-allocated GhosttyRenderStateRowCells with cell data for\n the current row (GhosttyRenderStateRowCells). Cell data is only\n valid as long as the underlying render state is not updated.\n It is unsafe to use cell data after updating the render state."]
pub const GhosttyRenderStateRowData_GHOSTTY_RENDER_STATE_ROW_DATA_CELLS: GhosttyRenderStateRowData =
3;
+#[doc = " Row-local selected cell range (GhosttyRenderStateRowSelection)."]
+pub const GhosttyRenderStateRowData_GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION:
+ GhosttyRenderStateRowData = 4;
#[doc = " Queryable data kinds for ghostty_render_state_row_get().\n\n @ingroup render"]
pub type GhosttyRenderStateRowData = ::std::os::raw::c_uint;
#[doc = " Set dirty state for the current row (bool)."]
@@ -1486,6 +1511,30 @@ pub const GhosttyRenderStateRowOption_GHOSTTY_RENDER_STATE_ROW_OPTION_DIRTY:
GhosttyRenderStateRowOption = 0;
#[doc = " Settable options for ghostty_render_state_row_set().\n\n @ingroup render"]
pub type GhosttyRenderStateRowOption = ::std::os::raw::c_uint;
+#[doc = " Row-local selection range."]
+#[repr(C)]
+#[derive(Debug, Default, Copy, Clone)]
+pub struct GhosttyRenderStateRowSelection {
+ #[doc = " Size of this struct in bytes."]
+ pub size: usize,
+ #[doc = " Start column of the row-local selection range, inclusive."]
+ pub start_x: u16,
+ #[doc = " End column of the row-local selection range, inclusive."]
+ pub end_x: u16,
+}
+#[allow(clippy::unnecessary_operation, clippy::identity_op)]
+const _: () = {
+ ["Size of GhosttyRenderStateRowSelection"]
+ [::std::mem::size_of::<GhosttyRenderStateRowSelection>() - 16usize];
+ ["Alignment of GhosttyRenderStateRowSelection"]
+ [::std::mem::align_of::<GhosttyRenderStateRowSelection>() - 8usize];
+ ["Offset of field: GhosttyRenderStateRowSelection::size"]
+ [::std::mem::offset_of!(GhosttyRenderStateRowSelection, size) - 0usize];
+ ["Offset of field: GhosttyRenderStateRowSelection::start_x"]
+ [::std::mem::offset_of!(GhosttyRenderStateRowSelection, start_x) - 8usize];
+ ["Offset of field: GhosttyRenderStateRowSelection::end_x"]
+ [::std::mem::offset_of!(GhosttyRenderStateRowSelection, end_x) - 10usize];
+};
#[doc = " Render-state color information.\n\n This struct uses the sized-struct ABI pattern. Initialize with\n GHOSTTY_INIT_SIZED(GhosttyRenderStateColors) before calling\n ghostty_render_state_colors_get().\n\n Example:\n @code\n GhosttyRenderStateColors colors = GHOSTTY_INIT_SIZED(GhosttyRenderStateColors);\n GhosttyResult result = ghostty_render_state_colors_get(state, &colors);\n @endcode\n\n @ingroup render"]
#[repr(C)]
#[derive(Debug, Copy, Clone)]
@@ -1558,6 +1607,16 @@ unsafe extern "C" {
) -> GhosttyResult;
}
unsafe extern "C" {
+ #[doc = " Get multiple data fields from a render state in a single call."]
+ pub fn ghostty_render_state_get_multi(
+ state: GhosttyRenderState_ptr,
+ count: usize,
+ keys: *const GhosttyRenderStateData,
+ values: *mut *mut ::std::os::raw::c_void,
+ out_written: *mut usize,
+ ) -> GhosttyResult;
+}
+unsafe extern "C" {
#[doc = " Set an option on a render state.\n\n The `value` pointer must point to a value of the type corresponding to the\n requested option kind (see GhosttyRenderStateOption).\n\n @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param option The option to set\n @param[in] value Pointer to the value to set (NULL returns\n GHOSTTY_INVALID_VALUE)\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or\n `value` is NULL\n\n @ingroup render"]
pub fn ghostty_render_state_set(
state: GhosttyRenderState_ptr,
@@ -1598,6 +1657,16 @@ unsafe extern "C" {
) -> GhosttyResult;
}
unsafe extern "C" {
+ #[doc = " Get multiple data fields from the current row in a single call."]
+ pub fn ghostty_render_state_row_get_multi(
+ iterator: GhosttyRenderStateRowIterator_ptr,
+ count: usize,
+ keys: *const GhosttyRenderStateRowData,
+ values: *mut *mut ::std::os::raw::c_void,
+ out_written: *mut usize,
+ ) -> GhosttyResult;
+}
+unsafe extern "C" {
#[doc = " Set an option on the current row in a render-state row iterator.\n\n The `value` pointer must point to a value of the type corresponding to the\n requested option kind (see GhosttyRenderStateRowOption).\n Call ghostty_render_state_row_iterator_next() at least once before\n calling this function.\n\n @param iterator The iterator handle to update (NULL returns GHOSTTY_INVALID_VALUE)\n @param option The option to set\n @param[in] value Pointer to the value to set (NULL returns\n GHOSTTY_INVALID_VALUE)\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if\n `iterator` is NULL or the iterator is not positioned on a row\n\n @ingroup render"]
pub fn ghostty_render_state_row_set(
iterator: GhosttyRenderStateRowIterator_ptr,
@@ -1664,6 +1733,16 @@ unsafe extern "C" {
) -> GhosttyResult;
}
unsafe extern "C" {
+ #[doc = " Get multiple data fields from the current cell in a single call."]
+ pub fn ghostty_render_state_row_cells_get_multi(
+ cells: GhosttyRenderStateRowCells_ptr,
+ count: usize,
+ keys: *const GhosttyRenderStateRowCellsData,
+ values: *mut *mut ::std::os::raw::c_void,
+ out_written: *mut usize,
+ ) -> GhosttyResult;
+}
+unsafe extern "C" {
#[doc = " Free a row cells instance.\n\n @param cells The row cells handle to free (may be NULL)\n\n @ingroup render"]
pub fn ghostty_render_state_row_cells_free(cells: GhosttyRenderStateRowCells_ptr);
}
diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs
index 57114de4..d0700ef4 100644
--- a/src/ghostty/mod.rs
+++ b/src/ghostty/mod.rs
@@ -17,6 +17,7 @@ use std::fmt;
use std::hash::{Hash, Hasher};
use std::marker::PhantomData;
use std::mem;
+use std::ops::RangeInclusive;
use std::os::raw::c_char;
use std::ptr;
use std::slice;
@@ -71,6 +72,18 @@ pub enum Dirty {
Full,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct RowSelection {
+ pub start_x: u16,
+ pub end_x: u16,
+}
+
+impl RowSelection {
+ pub fn range(self) -> RangeInclusive<u16> {
+ self.start_x..=self.end_x
+ }
+}
+
impl Dirty {
fn from_raw(value: ffi::GhosttyRenderStateDirty) -> Self {
match value {
@@ -2186,6 +2199,44 @@ impl<'a> RowIter<'a> {
Ok(dirty)
}
+ pub fn clear_dirty(&mut self) -> Result<(), Error> {
+ self.set_dirty(false)
+ }
+
+ pub fn set_dirty(&mut self, dirty: bool) -> Result<(), Error> {
+ // SAFETY: dirty pointer matches the expected row option type.
+ unsafe {
+ ffi::ghostty_render_state_row_set(
+ self.iterator.raw,
+ ffi::GhosttyRenderStateRowOption_GHOSTTY_RENDER_STATE_ROW_OPTION_DIRTY,
+ (&dirty as *const bool).cast(),
+ )
+ .into_result()
+ }
+ }
+
+ pub fn selection(&self) -> Result<Option<RowSelection>, Error> {
+ let mut selection = ffi::GhosttyRenderStateRowSelection {
+ size: mem::size_of::<ffi::GhosttyRenderStateRowSelection>(),
+ ..Default::default()
+ };
+ let result = unsafe {
+ ffi::ghostty_render_state_row_get(
+ self.iterator.raw,
+ ffi::GhosttyRenderStateRowData_GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION,
+ (&mut selection as *mut ffi::GhosttyRenderStateRowSelection).cast(),
+ )
+ };
+ match result {
+ ffi::GhosttyResult_GHOSTTY_SUCCESS => Ok(Some(RowSelection {
+ start_x: selection.start_x,
+ end_x: selection.end_x,
+ })),
+ ffi::GhosttyResult_GHOSTTY_NO_VALUE => Ok(None),
+ other => Err(Error(other)),
+ }
+ }
+
pub fn populate_cells<'b>(
&'b mut self,
cells: &'b mut RowCells,
@@ -2234,6 +2285,25 @@ pub struct RowCellIter<'a> {
cells: &'a mut RowCells,
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CellBasicData {
+ pub wide: CellWide,
+ pub has_hyperlink: bool,
+ pub has_styling: bool,
+ pub style: CellStyle,
+}
+
+impl Default for CellBasicData {
+ fn default() -> Self {
+ Self {
+ wide: CellWide::Narrow,
+ has_hyperlink: false,
+ has_styling: false,
+ style: CellStyle::default(),
+ }
+ }
+}
+
impl<'a> RowCellIter<'a> {
pub fn next(&mut self) -> bool {
// SAFETY: cells handle is valid while self is alive.
@@ -2257,6 +2327,64 @@ impl<'a> RowCellIter<'a> {
Ok(raw)
}
+ pub fn basic_data(&self) -> Result<CellBasicData, Error> {
+ let mut raw = ffi::GhosttyCell::default();
+ let mut style = ffi::GhosttyStyle {
+ size: mem::size_of::<ffi::GhosttyStyle>(),
+ ..Default::default()
+ };
+ let mut has_styling = false;
+ let row_keys = [
+ ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW,
+ ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE,
+ ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_HAS_STYLING,
+ ];
+ let mut row_values = [
+ (&mut raw as *mut ffi::GhosttyCell).cast::<c_void>(),
+ (&mut style as *mut ffi::GhosttyStyle).cast::<c_void>(),
+ (&mut has_styling as *mut bool).cast::<c_void>(),
+ ];
+ let mut written = 0usize;
+ unsafe {
+ ffi::ghostty_render_state_row_cells_get_multi(
+ self.cells.raw,
+ row_keys.len(),
+ row_keys.as_ptr(),
+ row_values.as_mut_ptr(),
+ &mut written,
+ )
+ .into_result()?;
+ }
+
+ let mut wide = ffi::GhosttyCellWide_GHOSTTY_CELL_WIDE_NARROW;
+ let mut has_hyperlink = false;
+ let cell_keys = [
+ ffi::GhosttyCellData_GHOSTTY_CELL_DATA_WIDE,
+ ffi::GhosttyCellData_GHOSTTY_CELL_DATA_HAS_HYPERLINK,
+ ];
+ let mut cell_values = [
+ (&mut wide as *mut ffi::GhosttyCellWide).cast::<c_void>(),
+ (&mut has_hyperlink as *mut bool).cast::<c_void>(),
+ ];
+ unsafe {
+ ffi::ghostty_cell_get_multi(
+ raw,
+ cell_keys.len(),
+ cell_keys.as_ptr(),
+ cell_values.as_mut_ptr(),
+ &mut written,
+ )
+ .into_result()?;
+ }
+
+ Ok(CellBasicData {
+ wide: CellWide::from_raw(wide),
+ has_hyperlink,
+ has_styling,
+ style: style.into(),
+ })
+ }
+
pub fn wide(&self) -> Result<CellWide, Error> {
let raw = self.raw_cell()?;
let mut wide = ffi::GhosttyCellWide_GHOSTTY_CELL_WIDE_NARROW;
@@ -2807,4 +2935,87 @@ mod tests {
render_state.set_dirty(Dirty::Clean).unwrap();
assert_eq!(render_state.dirty().unwrap(), Dirty::Clean);
}
+
+ #[test]
+ fn render_state_row_dirty_can_be_cleared_independently() {
+ let mut terminal = Terminal::new(8, 3, 100).unwrap();
+ let mut render_state = RenderState::new().unwrap();
+
+ render_state.update(&terminal).unwrap();
+ {
+ let mut row_iterator = RowIterator::new().unwrap();
+ let mut rows = render_state
+ .populate_row_iterator(&mut row_iterator)
+ .unwrap();
+ while rows.next() {
+ rows.clear_dirty().unwrap();
+ assert!(!rows.dirty().unwrap());
+ }
+ }
+ render_state.set_dirty(Dirty::Clean).unwrap();
+ assert_eq!(render_state.dirty().unwrap(), Dirty::Clean);
+
+ terminal.write(b"A");
+ render_state.update(&terminal).unwrap();
+ assert_eq!(render_state.dirty().unwrap(), Dirty::Partial);
+
+ let mut dirty_rows = 0usize;
+ {
+ let mut row_iterator = RowIterator::new().unwrap();
+ let mut rows = render_state
+ .populate_row_iterator(&mut row_iterator)
+ .unwrap();
+ while rows.next() {
+ if rows.dirty().unwrap() {
+ dirty_rows += 1;
+ rows.clear_dirty().unwrap();
+ assert!(!rows.dirty().unwrap());
+ }
+ }
+ }
+ assert_eq!(dirty_rows, 1);
+ assert_eq!(render_state.dirty().unwrap(), Dirty::Partial);
+
+ render_state.set_dirty(Dirty::Clean).unwrap();
+ assert_eq!(render_state.dirty().unwrap(), Dirty::Clean);
+ }
+
+ #[test]
+ fn row_selection_returns_none_without_selection() {
+ let terminal = Terminal::new(8, 3, 100).unwrap();
+ let mut render_state = RenderState::new().unwrap();
+ render_state.update(&terminal).unwrap();
+
+ let mut row_iterator = RowIterator::new().unwrap();
+ let mut rows = render_state
+ .populate_row_iterator(&mut row_iterator)
+ .unwrap();
+ assert!(rows.next());
+ assert_eq!(rows.selection().unwrap(), None);
+ }
+
+ #[test]
+ fn row_cell_basic_data_uses_batched_vendor_reads() {
+ let mut terminal = Terminal::new(8, 3, 100).unwrap();
+ terminal.write(b"\x1b[31mA\x1b[0m");
+
+ let mut render_state = RenderState::new().unwrap();
+ render_state.update(&terminal).unwrap();
+
+ let mut row_iterator = RowIterator::new().unwrap();
+ let mut rows = render_state
+ .populate_row_iterator(&mut row_iterator)
+ .unwrap();
+ assert!(rows.next());
+
+ let mut row_cells = RowCells::new().unwrap();
+ let mut cells = rows.populate_cells(&mut row_cells).unwrap();
+ assert!(cells.next());
+
+ let basic = cells.basic_data().unwrap();
+ assert_eq!(basic.wide, CellWide::Narrow);
+ assert!(basic.has_styling);
+ assert_eq!(basic.style.fg_color, Some(CellColor::Palette(1)));
+ assert!(!basic.has_hyperlink);
+ }
}
diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs
index 90cf4006..4564bde6 100644
--- a/src/kitty_graphics.rs
+++ b/src/kitty_graphics.rs
@@ -215,6 +215,52 @@ pub(crate) fn encode_local_pane_graphics(
bytes
}
+pub(crate) fn has_visible_pane_graphics(
+ app: &AppState,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ cell_size: HostCellSize,
+) -> bool {
+ if app.mode != Mode::Terminal || !cell_size.is_known() {
+ return false;
+ }
+
+ let Some(ws_idx) = app.active else {
+ return false;
+ };
+ if app
+ .workspaces
+ .get(ws_idx)
+ .and_then(crate::workspace::Workspace::active_tab)
+ .is_none()
+ {
+ return false;
+ }
+
+ for info in &app.view.pane_infos {
+ let Some(runtime) = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)
+ else {
+ continue;
+ };
+ let scrollback_offset = runtime
+ .scroll_metrics()
+ .map(|m| m.offset_from_bottom as u32)
+ .unwrap_or(0);
+ for placement in runtime.kitty_image_placements_with_data_filter(|_| false) {
+ let host_placement = HostPlacement {
+ pane_id: info.id,
+ area: info.inner_rect,
+ cell_size,
+ placement,
+ scrollback_offset,
+ };
+ if clipped_placement(&host_placement).is_some() {
+ return true;
+ }
+ }
+ }
+ false
+}
+
fn encode_graphics_update(
bytes: &mut Vec<u8>,
placements: &[HostPlacement],
@@ -328,6 +374,24 @@ pub(crate) fn clear_all_host_graphics() -> io::Result<()> {
}
impl HostGraphicsCache {
+ pub(crate) fn is_empty(&self) -> bool {
+ self.images.is_empty() && self.placements.is_empty()
+ }
+
+ #[cfg(test)]
+ pub(crate) fn test_mark_non_empty(&mut self) {
+ self.images.insert(
+ HOST_IMAGE_ID_BASE,
+ ImageSignature {
+ image_width: 1,
+ image_height: 1,
+ format_code: 32,
+ data_len: 4,
+ data_fingerprint: 1,
+ },
+ );
+ }
+
pub(crate) fn clear_bytes(&mut self) -> Vec<u8> {
let mut bytes = Vec::new();
for id in self.images.keys().copied().collect::<Vec<_>>() {
diff --git a/src/main.rs b/src/main.rs
index c37e870a..d7be42c6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -43,6 +43,7 @@ mod pty;
mod raw_input;
mod release_notes;
mod remote;
+mod render_prof;
mod selection;
mod server;
mod session;
diff --git a/src/pane.rs b/src/pane.rs
index 35b2318e..8230cd2b 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -29,6 +29,7 @@ mod state;
mod terminal;
use self::terminal::{GhosttyPaneTerminal, PaneTerminal};
+pub(crate) use self::terminal::{TerminalDirtyPatch, TerminalDirtyPatchOutcome};
pub use self::{
state::PaneState,
terminal::{InputState, ScrollMetrics, TerminalCursorState},
@@ -1799,6 +1800,14 @@ impl PaneRuntime {
self.terminal.render(frame, area, show_cursor);
}
+ pub(crate) fn collect_dirty_patch(
+ &self,
+ area_width: u16,
+ area_height: u16,
+ ) -> TerminalDirtyPatchOutcome {
+ self.terminal.collect_dirty_patch(area_width, area_height)
+ }
+
pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> {
self.terminal.visible_hyperlinks(area)
}
@@ -1974,6 +1983,11 @@ impl PaneRuntime {
Self::test_with_scrollback_bytes(cols, rows, 0, bytes)
}
+ pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) {
+ let (tx, _rx) = mpsc::channel(1);
+ let _ = self.terminal.process_pty_bytes(self.pane_id, 0, bytes, &tx);
+ }
+
pub(crate) fn test_with_scrollback_bytes(
cols: u16,
rows: u16,
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index 8841bad0..75b4a8a5 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -11,6 +11,7 @@ use tracing::{debug, error};
use unicode_width::UnicodeWidthStr;
use crate::layout::PaneId;
+use crate::protocol::CellData;
use super::{
input::{
@@ -50,6 +51,18 @@ pub struct TerminalCursorState {
pub shape: u8,
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct TerminalDirtyPatch {
+ pub rows: Vec<(u16, Vec<CellData>)>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum TerminalDirtyPatchOutcome {
+ Clean,
+ Patch(TerminalDirtyPatch),
+ Fallback,
+}
+
fn decscusr_cursor_shape(style: crate::ghostty::CursorVisualStyle, blinking: bool) -> u8 {
match (style, blinking) {
(crate::ghostty::CursorVisualStyle::Block, true)
@@ -200,6 +213,14 @@ impl PaneTerminal {
self.ghostty.render(frame, area, show_cursor);
}
+ pub fn collect_dirty_patch(
+ &self,
+ area_width: u16,
+ area_height: u16,
+ ) -> TerminalDirtyPatchOutcome {
+ self.ghostty.collect_dirty_patch(area_width, area_height)
+ }
+
pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> {
self.ghostty.visible_hyperlinks(area)
}
@@ -371,6 +392,7 @@ impl GhosttyPaneTerminal {
bytes: &[u8],
_response_writer: &mpsc::Sender<Bytes>,
) -> ProcessBytesResult {
+ crate::render_prof::counter("pty.bytes", bytes.len() as u64);
let Ok(mut core) = self.core.lock() else {
error!(pane = pane_id.raw(), "ghostty core lock poisoned in reader");
return ProcessBytesResult {
@@ -424,7 +446,9 @@ impl GhosttyPaneTerminal {
core.default_color_event_tracker
.observe(filtered_bytes.as_ref());
respond_to_default_color_events(&mut core, &mut terminal_responses);
+ let write_started = crate::render_prof::timer();
core.terminal.write(filtered_bytes.as_ref());
+ crate::render_prof::duration_since("pty.ghostty_write", write_started);
terminal_responses.extend(self.drain_pending_pty_responses());
let has_kitty_graphics_sequence = crate::kitty_graphics::is_enabled()
@@ -439,10 +463,21 @@ impl GhosttyPaneTerminal {
.terminal
.mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT)
.unwrap_or(false);
+ let request_render = !synchronized_output && !has_kitty_graphics_sequence;
+ let render_delay = (!synchronized_output && has_kitty_graphics_sequence)
+ .then_some(KITTY_GRAPHICS_REDRAW_SETTLE);
+ if request_render {
+ crate::render_prof::event("pty.request_render");
+ }
+ if render_delay.is_some() {
+ crate::render_prof::event("pty.request_render_delayed");
+ }
+ if synchronized_output {
+ crate::render_prof::event("pty.synchronized_output_suppressed");
+ }
ProcessBytesResult {
- request_render: !synchronized_output && !has_kitty_graphics_sequence,
- render_delay: (!synchronized_output && has_kitty_graphics_sequence)
- .then_some(KITTY_GRAPHICS_REDRAW_SETTLE),
+ request_render,
+ render_delay,
clipboard_writes,
terminal_responses,
}
@@ -948,9 +983,10 @@ impl GhosttyPaneTerminal {
};
let mut x = 0u16;
while x < area.width && cells.next() {
- let wide = cells.wide().unwrap_or(crate::ghostty::CellWide::Narrow);
+ let basic = cells.basic_data().unwrap_or_default();
let style = ghostty_cell_style(
&cells,
+ &basic,
default_fg,
default_bg,
resolved_fg,
@@ -958,7 +994,7 @@ impl GhosttyPaneTerminal {
);
let symbol = match ghostty_buffer_symbol_into(
&cells,
- wide,
+ basic.wide,
hide_kitty_placeholders,
&mut grapheme_bytes,
&mut symbol_scratch,
@@ -966,7 +1002,7 @@ impl GhosttyPaneTerminal {
Ok(symbol) => symbol,
Err(_) => {
symbol_scratch.clear();
- symbol_scratch.push_str(ghostty_blank_symbol_for_width(wide));
+ symbol_scratch.push_str(ghostty_blank_symbol_for_width(basic.wide));
symbol_scratch.as_str()
}
};
@@ -992,6 +1028,8 @@ impl GhosttyPaneTerminal {
}
}
+ ghostty_clear_render_dirty(render_state, area.height);
+
if show_cursor && render_state.cursor_visible().ok() == Some(true) {
if let Ok(Some(cursor)) = render_state.cursor_viewport() {
if cursor.x < area.width && cursor.y < area.height {
@@ -1000,10 +1038,193 @@ impl GhosttyPaneTerminal {
}
}
}
+
+ pub fn collect_dirty_patch(
+ &self,
+ area_width: u16,
+ area_height: u16,
+ ) -> TerminalDirtyPatchOutcome {
+ self.core
+ .lock()
+ .ok()
+ .map(|mut core| ghostty_collect_dirty_patch(&mut core, area_width, area_height))
+ .unwrap_or(TerminalDirtyPatchOutcome::Fallback)
+ }
}
type VisibleHyperlinks = Vec<((u16, u16), String, String)>;
+fn ghostty_clear_render_dirty(render_state: &mut crate::ghostty::RenderState, area_height: u16) {
+ let Ok(mut row_iterator) = crate::ghostty::RowIterator::new() else {
+ return;
+ };
+ let Ok(mut rows) = render_state.populate_row_iterator(&mut row_iterator) else {
+ return;
+ };
+ let mut y = 0u16;
+ while y < area_height && rows.next() {
+ let _ = rows.clear_dirty();
+ y += 1;
+ }
+ let _ = render_state.set_dirty(crate::ghostty::Dirty::Clean);
+}
+
+fn ghostty_collect_dirty_patch(
+ core: &mut GhosttyPaneCore,
+ area_width: u16,
+ area_height: u16,
+) -> TerminalDirtyPatchOutcome {
+ let prof_started = crate::render_prof::timer();
+ macro_rules! finish {
+ ($outcome:expr) => {{
+ let outcome = $outcome;
+ if let Some(started) = prof_started {
+ crate::render_prof::duration("dirty_collect.total", started.elapsed());
+ match &outcome {
+ TerminalDirtyPatchOutcome::Clean => {
+ crate::render_prof::event("dirty_collect.clean");
+ }
+ TerminalDirtyPatchOutcome::Fallback => {
+ crate::render_prof::event("dirty_collect.fallback");
+ }
+ TerminalDirtyPatchOutcome::Patch(patch) => {
+ crate::render_prof::event("dirty_collect.patch");
+ crate::render_prof::counter("dirty_collect.rows", patch.rows.len() as u64);
+ let cells = patch.rows.iter().map(|(_, cells)| cells.len() as u64).sum();
+ crate::render_prof::counter("dirty_collect.cells", cells);
+ }
+ }
+ }
+ return outcome;
+ }};
+ }
+ macro_rules! fallback {
+ ($reason:literal) => {{
+ crate::render_prof::event(concat!("dirty_fallback.", $reason));
+ finish!(TerminalDirtyPatchOutcome::Fallback);
+ }};
+ }
+
+ let host_theme = core.host_terminal_theme;
+ let initial_default_foreground = core.initial_default_foreground;
+ let initial_default_background = core.initial_default_background;
+ let GhosttyPaneCore {
+ terminal,
+ render_state,
+ ..
+ } = core;
+ if render_state.update(terminal).is_err() {
+ fallback!("render_state_update_error");
+ }
+ match render_state.dirty() {
+ Ok(crate::ghostty::Dirty::Clean) => finish!(TerminalDirtyPatchOutcome::Clean),
+ Ok(crate::ghostty::Dirty::Partial) => {}
+ Ok(crate::ghostty::Dirty::Full) => fallback!("dirty_full"),
+ Err(_) => fallback!("dirty_read_error"),
+ }
+
+ let colors = render_state.colors().ok();
+ let default_bg = colors
+ .and_then(|c| ghostty_default_bg(c.background, host_theme, initial_default_background));
+ let default_fg = colors
+ .and_then(|c| ghostty_default_fg(c.foreground, host_theme, initial_default_foreground));
+ let resolved_fg = colors.map(|c| ghostty_color(c.foreground));
+ let resolved_bg = colors.map(|c| ghostty_color(c.background));
+ let hide_kitty_placeholders = crate::kitty_graphics::is_enabled();
+
+ let Ok(mut row_iterator) = crate::ghostty::RowIterator::new() else {
+ fallback!("row_iterator_new_error");
+ };
+ let Ok(mut row_cells) = crate::ghostty::RowCells::new() else {
+ fallback!("row_cells_new_error");
+ };
+ let Ok(mut rows) = render_state.populate_row_iterator(&mut row_iterator) else {
+ fallback!("populate_rows_error");
+ };
+ let mut grapheme_bytes = Vec::new();
+ let mut symbol_scratch = String::new();
+ let mut patch_rows = Vec::new();
+ let mut y = 0u16;
+ while y < area_height && rows.next() {
+ let Ok(dirty) = rows.dirty() else {
+ fallback!("row_dirty_read_error");
+ };
+ if dirty {
+ match rows.selection() {
+ Ok(None) => {}
+ Ok(Some(_)) => fallback!("row_selection_present"),
+ Err(_) => fallback!("row_selection_error"),
+ }
+ let Ok(mut cells) = rows.populate_cells(&mut row_cells) else {
+ fallback!("populate_cells_error");
+ };
+ let mut patch_cells = Vec::with_capacity(usize::from(area_width));
+ let mut x = 0u16;
+ while x < area_width && cells.next() {
+ let Ok(basic) = cells.basic_data() else {
+ fallback!("basic_data_error");
+ };
+ if basic.has_hyperlink {
+ fallback!("hyperlink_present");
+ }
+ let style = ghostty_cell_style(
+ &cells,
+ &basic,
+ default_fg,
+ default_bg,
+ resolved_fg,
+ resolved_bg,
+ );
+ let symbol = match ghostty_buffer_symbol_into(
+ &cells,
+ basic.wide,
+ hide_kitty_placeholders,
+ &mut grapheme_bytes,
+ &mut symbol_scratch,
+ ) {
+ Ok(symbol) => symbol.to_owned(),
+ Err(_) => ghostty_blank_symbol_for_width(basic.wide).to_owned(),
+ };
+ patch_cells.push(cell_data_from_style(symbol, style));
+ x += 1;
+ }
+ while x < area_width {
+ patch_cells.push(blank_cell_data(default_fg, default_bg));
+ x += 1;
+ }
+ patch_rows.push((y, patch_cells));
+ }
+ y += 1;
+ }
+
+ let dirty_ys: std::collections::HashSet<u16> = patch_rows.iter().map(|(row, _)| *row).collect();
+ if !dirty_ys.is_empty() {
+ let Ok(mut clear_row_iterator) = crate::ghostty::RowIterator::new() else {
+ fallback!("clear_row_iterator_new_error");
+ };
+ let Ok(mut clear_rows) = render_state.populate_row_iterator(&mut clear_row_iterator) else {
+ fallback!("clear_populate_rows_error");
+ };
+ let mut clear_y = 0u16;
+ while clear_y < area_height && clear_rows.next() {
+ if dirty_ys.contains(&clear_y) && clear_rows.clear_dirty().is_err() {
+ fallback!("clear_dirty_error");
+ }
+ clear_y += 1;
+ }
+ }
+ if render_state
+ .set_dirty(crate::ghostty::Dirty::Clean)
+ .is_err()
+ {
+ fallback!("set_clean_error");
+ }
+
+ finish!(TerminalDirtyPatchOutcome::Patch(TerminalDirtyPatch {
+ rows: patch_rows
+ }));
+}
+
fn ghostty_visible_hyperlinks(
core: &mut GhosttyPaneCore,
area: Rect,
@@ -1283,15 +1504,49 @@ fn ghostty_reset_cell(
}
}
+fn blank_cell_data(default_fg: Option<Color>, default_bg: Option<Color>) -> CellData {
+ cell_data_from_style(
+ " ".to_string(),
+ ghostty_default_style(default_fg, default_bg),
+ )
+}
+
+fn cell_data_from_style(symbol: String, style: Style) -> CellData {
+ CellData {
+ symbol: if symbol.is_empty() {
+ " ".to_string()
+ } else {
+ symbol
+ },
+ fg: crate::protocol::color_to_u32(style.fg.unwrap_or(Color::Reset)),
+ bg: crate::protocol::color_to_u32(style.bg.unwrap_or(Color::Reset)),
+ modifier: crate::protocol::modifier_to_u16(style.add_modifier),
+ skip: false,
+ hyperlink: None,
+ }
+}
+
+fn ghostty_default_style(default_fg: Option<Color>, default_bg: Option<Color>) -> Style {
+ let mut style = Style::default();
+ if let Some(fg) = default_fg {
+ style = style.fg(fg);
+ }
+ if let Some(bg) = default_bg {
+ style = style.bg(bg);
+ }
+ style
+}
+
fn ghostty_cell_style(
cells: &crate::ghostty::RowCellIter<'_>,
+ basic: &crate::ghostty::CellBasicData,
default_fg: Option<Color>,
default_bg: Option<Color>,
resolved_fg: Option<Color>,
resolved_bg: Option<Color>,
) -> Style {
- let style_data = cells.style().unwrap_or_default();
- let mut fg = style_data
+ let mut fg = basic
+ .style
.fg_color
.map(ghostty_cell_color)
.or_else(|| cells.fg_color().ok().flatten().map(ghostty_color))
@@ -1300,14 +1555,14 @@ fn ghostty_cell_style(
.content_bg_color()
.ok()
.flatten()
- .or(style_data.bg_color)
+ .or(basic.style.bg_color)
.map(ghostty_cell_color)
.or_else(|| cells.bg_color().ok().flatten().map(ghostty_color))
.or(default_bg);
- if style_data.invisible {
+ if basic.style.invisible {
fg = bg.or(default_bg);
}
- if style_data.inverse {
+ if basic.style.inverse {
// When the background is transparent (None), resolve it to the
// actual terminal background color before swapping. Otherwise
// the swapped fg becomes None (Color::Reset) which the host
@@ -1322,31 +1577,24 @@ fn ghostty_cell_style(
std::mem::swap(&mut fg, &mut bg);
}
- let mut style = Style::default();
- if let Some(fg) = fg {
- style = style.fg(fg);
- }
- if let Some(bg) = bg {
- style = style.bg(bg);
- }
-
+ let style = ghostty_default_style(fg, bg);
let mut modifiers = Modifier::empty();
- if style_data.bold {
+ if basic.style.bold {
modifiers |= Modifier::BOLD;
}
- if style_data.italic {
+ if basic.style.italic {
modifiers |= Modifier::ITALIC;
}
- if style_data.faint {
+ if basic.style.faint {
modifiers |= Modifier::DIM;
}
- if style_data.blink {
+ if basic.style.blink {
modifiers |= Modifier::SLOW_BLINK;
}
- if style_data.underlined {
+ if basic.style.underlined {
modifiers |= Modifier::UNDERLINED;
}
- if style_data.strikethrough {
+ if basic.style.strikethrough {
modifiers |= Modifier::CROSSED_OUT;
}
style.add_modifier(modifiers)
diff --git a/src/protocol/render_ansi.rs b/src/protocol/render_ansi.rs
index 8caa98d0..1c28b00b 100644
--- a/src/protocol/render_ansi.rs
+++ b/src/protocol/render_ansi.rs
@@ -63,6 +63,9 @@ impl BlitEncoder {
let full = force_full
|| prev.is_none()
|| prev.is_some_and(|p| p.width != frame.width || p.height != frame.height);
+ let prof_stats =
+ crate::render_prof::enabled().then(|| compute_prof_blit_stats(frame, prev, full));
+ let prof_started = crate::render_prof::timer();
let mut bytes = Vec::new();
let mut next_last_visible_cursor = self.last_visible_cursor;
let mut next_last_cursor_shape = self.last_cursor_shape;
@@ -73,6 +76,18 @@ impl BlitEncoder {
&mut next_last_visible_cursor,
&mut next_last_cursor_shape,
);
+ if let Some(stats) = prof_stats {
+ crate::render_prof::duration_since("ansi_encode.total", prof_started);
+ crate::render_prof::counter("ansi_encode.bytes", bytes.len() as u64);
+ crate::render_prof::counter("ansi_encode.scanned_cells", stats.scanned_cells);
+ crate::render_prof::counter("ansi_encode.changed_cells", stats.changed_cells);
+ crate::render_prof::counter("ansi_encode.changed_runs", stats.changed_runs);
+ if full {
+ crate::render_prof::event("ansi_encode.full");
+ } else {
+ crate::render_prof::event("ansi_encode.partial");
+ }
+ }
EncodedBlit {
bytes,
full,
@@ -90,6 +105,79 @@ impl BlitEncoder {
pub(crate) fn is_current(&self, frame: &FrameData) -> bool {
self.last_frame.as_ref() == Some(frame)
}
+
+ pub(crate) fn last_frame(&self) -> Option<&FrameData> {
+ self.last_frame.as_ref()
+ }
+}
+
+#[derive(Clone, Copy, Default)]
+struct ProfBlitStats {
+ scanned_cells: u64,
+ changed_cells: u64,
+ changed_runs: u64,
+}
+
+fn compute_prof_blit_stats(
+ frame: &FrameData,
+ prev: Option<&FrameData>,
+ full: bool,
+) -> ProfBlitStats {
+ let Some(prev) = prev.filter(|_| !full) else {
+ let changed_cells = frame.cells.iter().filter(|cell| !cell.skip).count() as u64;
+ return ProfBlitStats {
+ scanned_cells: frame.cells.len() as u64,
+ changed_cells,
+ changed_runs: changed_cells,
+ };
+ };
+ if prev.width != frame.width || prev.height != frame.height {
+ let changed_cells = frame.cells.iter().filter(|cell| !cell.skip).count() as u64;
+ return ProfBlitStats {
+ scanned_cells: frame.cells.len() as u64,
+ changed_cells,
+ changed_runs: changed_cells,
+ };
+ }
+
+ let sanitized_hyperlinks = sanitized_frame_hyperlinks(frame);
+ let prev_sanitized_hyperlinks = sanitized_frame_hyperlinks(prev);
+ let mut stats = ProfBlitStats {
+ scanned_cells: frame.cells.len() as u64,
+ changed_cells: 0,
+ changed_runs: 0,
+ };
+ for row in 0..frame.height {
+ let mut in_run = false;
+ let mut invalidated = 0usize;
+ let mut to_skip = 0usize;
+ for col in 0..frame.width {
+ let idx = (row as usize) * (frame.width as usize) + (col as usize);
+ let cell = &frame.cells[idx];
+ let prev_cell = &prev.cells[idx];
+ let changed = !cell.skip
+ && (!cells_visually_equal(
+ &sanitized_hyperlinks,
+ cell,
+ &prev_sanitized_hyperlinks,
+ prev_cell,
+ ) || invalidated > 0)
+ && to_skip == 0;
+ if changed {
+ stats.changed_cells += 1;
+ if !in_run {
+ stats.changed_runs += 1;
+ in_run = true;
+ }
+ } else {
+ in_run = false;
+ }
+ to_skip = cell_width(cell).saturating_sub(1);
+ let affected_width = cmp::max(cell_width(cell), cell_width(prev_cell));
+ invalidated = cmp::max(affected_width, invalidated).saturating_sub(1);
+ }
+ }
+ stats
}
// ---------------------------------------------------------------------------
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index 576c4135..a64a06d5 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -401,7 +401,7 @@ pub enum ServerMessage {
/// - Named colors (Reset, Black, …, White) → `0x00_00_00_XX` where XX is 0..=16
/// - Indexed palette → `0x01_00_00_XX` where XX is the palette index
/// - RGB → `0x02_RR_GG_BB` with components in the lower 3 bytes
-fn color_to_u32(color: ratatui::style::Color) -> u32 {
+pub(crate) fn color_to_u32(color: ratatui::style::Color) -> u32 {
match color {
ratatui::style::Color::Reset => 0x00_00_00_00,
ratatui::style::Color::Black => 0x00_00_00_01,
@@ -463,7 +463,7 @@ fn u32_to_color(val: u32) -> ratatui::style::Color {
}
/// Converts a ratatui `Modifier` bitmask to a u16 for wire transport.
-fn modifier_to_u16(modifier: ratatui::style::Modifier) -> u16 {
+pub(crate) fn modifier_to_u16(modifier: ratatui::style::Modifier) -> u16 {
modifier.bits()
}
diff --git a/src/render_prof.rs b/src/render_prof.rs
new file mode 100644
index 00000000..bf987f12
--- /dev/null
+++ b/src/render_prof.rs
@@ -0,0 +1,135 @@
+//! Lightweight opt-in render profiling for local performance investigations.
+
+use std::collections::BTreeMap;
+use std::sync::{Mutex, OnceLock};
+use std::time::{Duration, Instant};
+
+const ENV_VAR: &str = "HERDR_RENDER_PROF";
+
+static ENABLED: OnceLock<bool> = OnceLock::new();
+static PROFILER: OnceLock<Mutex<RenderProfiler>> = OnceLock::new();
+
+#[derive(Default)]
+struct DurationStats {
+ count: u64,
+ total_ns: u128,
+ max_ns: u128,
+}
+
+struct RenderProfiler {
+ window_started: Instant,
+ counters: BTreeMap<&'static str, u64>,
+ durations: BTreeMap<&'static str, DurationStats>,
+}
+
+impl RenderProfiler {
+ fn new() -> Self {
+ Self {
+ window_started: Instant::now(),
+ counters: BTreeMap::new(),
+ durations: BTreeMap::new(),
+ }
+ }
+
+ fn increment(&mut self, name: &'static str, value: u64) {
+ *self.counters.entry(name).or_default() += value;
+ }
+
+ fn duration(&mut self, name: &'static str, duration: Duration) {
+ let stats = self.durations.entry(name).or_default();
+ let ns = duration.as_nanos();
+ stats.count += 1;
+ stats.total_ns += ns;
+ stats.max_ns = stats.max_ns.max(ns);
+ }
+
+ fn flush_if_due(&mut self) {
+ let elapsed = self.window_started.elapsed();
+ if elapsed < Duration::from_secs(1) {
+ return;
+ }
+
+ let counters = self
+ .counters
+ .iter()
+ .map(|(name, value)| format!("{name}={value}"))
+ .collect::<Vec<_>>()
+ .join(",");
+ let durations = self
+ .durations
+ .iter()
+ .map(|(name, stats)| {
+ let avg_us = if stats.count == 0 {
+ 0
+ } else {
+ stats.total_ns / u128::from(stats.count) / 1_000
+ };
+ let max_us = stats.max_ns / 1_000;
+ format!(
+ "{name}=count:{} avg_us:{} max_us:{}",
+ stats.count, avg_us, max_us
+ )
+ })
+ .collect::<Vec<_>>()
+ .join(",");
+
+ tracing::info!(
+ event = "render.prof",
+ window_ms = elapsed.as_millis() as u64,
+ counters = %counters,
+ durations = %durations,
+ "render profiler window"
+ );
+
+ self.window_started = Instant::now();
+ self.counters.clear();
+ self.durations.clear();
+ }
+}
+
+pub(crate) fn enabled() -> bool {
+ *ENABLED.get_or_init(|| {
+ std::env::var(ENV_VAR)
+ .map(|value| matches!(value.as_str(), "1" | "true" | "yes" | "on"))
+ .unwrap_or(false)
+ })
+}
+
+fn with_profiler(update: impl FnOnce(&mut RenderProfiler)) {
+ if !enabled() {
+ return;
+ }
+ let profiler = PROFILER.get_or_init(|| Mutex::new(RenderProfiler::new()));
+ if let Ok(mut profiler) = profiler.lock() {
+ update(&mut profiler);
+ }
+}
+
+pub(crate) fn counter(name: &'static str, value: u64) {
+ if value == 0 {
+ return;
+ }
+ with_profiler(|profiler| profiler.increment(name, value));
+}
+
+pub(crate) fn event(name: &'static str) {
+ counter(name, 1);
+}
+
+pub(crate) fn duration(name: &'static str, duration: Duration) {
+ with_profiler(|profiler| profiler.duration(name, duration));
+}
+
+pub(crate) fn timer() -> Option<Instant> {
+ enabled().then(Instant::now)
+}
+
+pub(crate) fn duration_since(name: &'static str, started: Option<Instant>) {
+ if let Some(started) = started {
+ duration(name, started.elapsed());
+ }
+}
+
+pub(crate) fn flush_if_due() {
+ with_profiler(RenderProfiler::flush_if_due);
+}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index b405e887..75195441 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -76,6 +76,64 @@ enum LoopEvent {
RenderRequested,
}
+fn rect_fits_frame(rect: Rect, frame: &FrameData) -> bool {
+ rect.x.saturating_add(rect.width) <= frame.width
+ && rect.y.saturating_add(rect.height) <= frame.height
+}
+
+fn apply_terminal_dirty_patch(
+ frame: &mut FrameData,
+ area: Rect,
+ patch: crate::pane::TerminalDirtyPatch,
+) -> bool {
+ if !rect_fits_frame(area, frame) {
+ return false;
+ }
+ let width = usize::from(frame.width);
+ for (local_y, row_cells) in patch.rows {
+ if local_y >= area.height || row_cells.len() != usize::from(area.width) {
+ return false;
+ }
+ let frame_y = area.y + local_y;
+ let start = usize::from(frame_y) * width + usize::from(area.x);
+ let end = start + usize::from(area.width);
+ if end > frame.cells.len() {
+ return false;
+ }
+ frame.cells[start..end].clone_from_slice(&row_cells);
+ }
+ true
+}
+
+fn dirty_patch_intersects_hyperlinks(
+ frame: &FrameData,
+ area: Rect,
+ patch: &crate::pane::TerminalDirtyPatch,
+) -> bool {
+ if frame.hyperlinks.is_empty() || !rect_fits_frame(area, frame) {
+ return false;
+ }
+ let width = usize::from(frame.width);
+ for (local_y, _) in &patch.rows {
+ if *local_y >= area.height {
+ return true;
+ }
+ let frame_y = area.y + *local_y;
+ let start = usize::from(frame_y) * width + usize::from(area.x);
+ let end = start + usize::from(area.width);
+ if end > frame.cells.len() {
+ return true;
+ }
+ if frame.cells[start..end]
+ .iter()
+ .any(|cell| cell.hyperlink.is_some())
+ {
+ return true;
+ }
+ }
+ false
+}
+
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
@@ -293,8 +351,12 @@ impl HeadlessServer {
self.app.input_rx = None;
let mut needs_render = true;
+ let mut needs_full_render = true;
loop {
+ crate::render_prof::event("loop.tick");
+ crate::render_prof::flush_if_due();
+
// If shutdown has been initiated, complete it and exit.
if self.shutting_down {
self.complete_shutdown()?;
@@ -310,17 +372,22 @@ impl HeadlessServer {
// 1. Check render_dirty flag from PTY reader tasks.
if self.app.render_dirty.load(Ordering::Acquire) {
needs_render = true;
+ crate::render_prof::event("render.request.pty_dirty");
}
// 2. Drain a bounded internal-event batch. API handlers perform an
// exhaustive forwarding-aware drain before reading pane/runtime state.
if self.drain_internal_events_with_forwarding() {
needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.internal_events");
}
// 3. Drain API requests.
if self.drain_api_requests_with_shutdown_check() {
needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.api_requests");
}
self.app.sync_focus_events();
@@ -332,12 +399,16 @@ impl HeadlessServer {
// 5. Drain server events from client threads.
if self.drain_server_events() {
needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.server_events");
}
// 6. Handle scheduled tasks.
let now = Instant::now();
if self.handle_scheduled_tasks_headless(now, needs_render) {
needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.scheduled_tasks");
}
// Handle deferred requests.
@@ -345,28 +416,38 @@ impl HeadlessServer {
self.app.state.request_complete_onboarding = false;
self.app.open_settings_from_onboarding();
needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.deferred_onboarding");
}
if self.app.state.request_new_workspace {
self.app.state.request_new_workspace = false;
self.app.create_workspace();
needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.deferred_new_workspace");
}
if self.app.state.request_new_tab {
self.app.state.request_new_tab = false;
self.app.create_tab();
needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.deferred_new_tab");
}
if let Some(ws_idx) = self.app.state.request_new_linked_worktree.take() {
self.app.open_new_linked_worktree_dialog(ws_idx);
needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.deferred_worktree_dialog");
}
if let Some(ws_idx) = self.app.state.request_open_existing_worktree.take() {
self.app.open_existing_worktree_dialog(ws_idx);
needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.deferred_worktree_dialog");
}
if let Some(cwd) = self.app.state.request_new_workspace_cwd.take() {
@@ -375,35 +456,47 @@ impl HeadlessServer {
self.app.state.mode = app::Mode::Navigate;
}
needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.deferred_workspace_cwd");
}
if let Some(ws_idx) = self.app.state.request_remove_linked_worktree.take() {
self.app.open_remove_linked_worktree_confirmation(ws_idx);
needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.deferred_worktree_dialog");
}
if self.app.state.request_submit_worktree_create {
self.app.state.request_submit_worktree_create = false;
self.app.start_worktree_add();
needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.deferred_worktree_submit");
}
if self.app.state.request_submit_worktree_open {
self.app.state.request_submit_worktree_open = false;
self.app.open_selected_existing_worktree();
needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.deferred_worktree_submit");
}
if self.app.state.request_submit_worktree_remove {
self.app.state.request_submit_worktree_remove = false;
self.app.start_worktree_remove();
needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.deferred_worktree_submit");
}
if self.app.state.request_reload_config {
self.app.state.request_reload_config = false;
self.reload_server_config(true);
needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.config_reload");
}
self.drain_client_config_reload_request();
@@ -413,10 +506,25 @@ impl HeadlessServer {
// 7. Render virtually and stream frames.
if needs_render && self.app.can_render_now(now) {
- self.app.render_dirty.swap(false, Ordering::AcqRel);
- self.render_and_stream();
+ crate::render_prof::event("render.attempt");
+ let pty_dirty = self.app.render_dirty.swap(false, Ordering::AcqRel);
+ if pty_dirty {
+ crate::render_prof::event("render.attempt.pty_dirty");
+ }
+ if needs_full_render {
+ crate::render_prof::event("retained_gate.needs_full_render");
+ } else if !pty_dirty {
+ crate::render_prof::event("retained_gate.not_pty_dirty");
+ }
+ let rendered_retained =
+ pty_dirty && !needs_full_render && self.render_retained_pty_update_and_stream();
+ if !rendered_retained {
+ crate::render_prof::event("full_render.invoke");
+ self.render_and_stream();
+ }
self.app.last_render_at = Some(now);
needs_render = false;
+ needs_full_render = false;
continue;
}
@@ -454,16 +562,19 @@ impl HeadlessServer {
LoopEvent::Internal(ev) => {
if self.handle_internal_event_with_forwarding(ev) {
needs_render = true;
+ needs_full_render = true;
}
}
LoopEvent::Api(msg) => {
if self.handle_api_request_with_shutdown_check(msg) {
needs_render = true;
+ needs_full_render = true;
}
}
LoopEvent::ServerEvent(ev) => {
if self.handle_server_event(ev) {
needs_render = true;
+ needs_full_render = true;
}
}
LoopEvent::RenderRequested => {
@@ -2273,13 +2384,224 @@ impl HeadlessServer {
/// Renders the current state to client-sized virtual buffers and streams
/// frames to all connected clients.
+ fn render_retained_pty_update_and_stream(&mut self) -> bool {
+ crate::render_prof::event("retained.attempt");
+ let retained_started = crate::render_prof::timer();
+ macro_rules! retained_fallback {
+ ($reason:literal) => {{
+ crate::render_prof::event(concat!("retained_fallback.", $reason));
+ crate::render_prof::duration_since("retained.total", retained_started);
+ return false;
+ }};
+ }
+ macro_rules! retained_success {
+ ($reason:literal) => {{
+ crate::render_prof::event("retained.success");
+ crate::render_prof::event(concat!("retained_success.", $reason));
+ crate::render_prof::duration_since("retained.total", retained_started);
+ return true;
+ }};
+ }
+
+ if !self.retained_pty_update_allowed_by_app_state() {
+ retained_fallback!("unsafe_app_state");
+ }
+
+ let render_targets = render_targets(&self.clients, self.foreground_client_id);
+ let [(client_id, (cols, rows), cell_size, _is_foreground, mode)] =
+ render_targets.as_slice()
+ else {
+ retained_fallback!("multiple_or_no_target");
+ };
+ if !matches!(mode, ClientConnectionMode::App) {
+ retained_fallback!("not_app_client");
+ }
+ let Some(client) = self.clients.get(client_id) else {
+ retained_fallback!("client_missing");
+ };
+ if client.render_pending {
+ retained_fallback!("render_pending");
+ }
+ if self.app.state.kitty_graphics_enabled && !client.graphics_cache.is_empty() {
+ retained_fallback!("graphics_cache_active");
+ }
+ if client.graphics_surface_reset_pending {
+ retained_fallback!("graphics_surface_reset");
+ }
+ if self.app.state.kitty_graphics_enabled
+ && cell_size.is_known()
+ && crate::kitty_graphics::has_visible_pane_graphics(
+ &self.app.state,
+ &self.app.terminal_runtimes,
+ *cell_size,
+ )
+ {
+ retained_fallback!("visible_kitty_graphics");
+ }
+ let Some(mut frame) = client.render_state.last_frame().cloned() else {
+ retained_fallback!("no_last_frame");
+ };
+ if frame.width != *cols || frame.height != *rows {
+ retained_fallback!("frame_size_mismatch");
+ }
+ frame.graphics.clear();
+
+ let Some(ws_idx) = self.app.state.active else {
+ retained_fallback!("no_active_workspace");
+ };
+ let pane_infos = self.app.state.view.pane_infos.clone();
+ if pane_infos.is_empty() {
+ retained_fallback!("no_pane_info");
+ }
+
+ let mut touched = false;
+ for info in pane_infos {
+ if !rect_fits_frame(info.inner_rect, &frame) {
+ retained_fallback!("pane_rect_outside_frame");
+ }
+ let Some(runtime) = self.app.state.runtime_for_pane_in_workspace(
+ &self.app.terminal_runtimes,
+ ws_idx,
+ info.id,
+ ) else {
+ retained_fallback!("missing_runtime");
+ };
+ match runtime.collect_dirty_patch(info.inner_rect.width, info.inner_rect.height) {
+ crate::pane::TerminalDirtyPatchOutcome::Clean => {
+ crate::render_prof::event("retained.pane_clean");
+ }
+ crate::pane::TerminalDirtyPatchOutcome::Fallback => {
+ retained_fallback!("dirty_patch_fallback");
+ }
+ crate::pane::TerminalDirtyPatchOutcome::Patch(patch) => {
+ crate::render_prof::event("retained.pane_patch");
+ crate::render_prof::counter("retained.patch_rows", patch.rows.len() as u64);
+ if dirty_patch_intersects_hyperlinks(&frame, info.inner_rect, &patch) {
+ retained_fallback!("hyperlink_intersection");
+ }
+ if !apply_terminal_dirty_patch(&mut frame, info.inner_rect, patch) {
+ retained_fallback!("patch_apply_failed");
+ }
+ touched = true;
+ }
+ }
+ }
+
+ let previous_cursor = frame.cursor.clone();
+ frame.cursor = crate::server::render_stream::focused_terminal_cursor(
+ &self.app.state,
+ &self.app.terminal_runtimes,
+ );
+ let cursor_changed = frame.cursor != previous_cursor;
+
+ if !touched && !cursor_changed {
+ retained_success!("clean_no_cursor_change");
+ }
+
+ let mut broken_clients = Vec::new();
+ let sent = self.send_retained_frame_to_client(*client_id, frame, &mut broken_clients);
+ for broken_client in broken_clients {
+ self.remove_client_and_resize_if_needed(broken_client);
+ }
+ if sent {
+ retained_success!("sent");
+ }
+ retained_fallback!("send_failed");
+ }
+
+ fn retained_pty_update_allowed_by_app_state(&self) -> bool {
+ self.app.state.mode == app::Mode::Terminal
+ && self.app.state.selection.is_none()
+ && self.app.state.copy_mode.is_none()
+ && self.app.state.context_menu.is_none()
+ && !self.app.full_redraw_pending
+ }
+
+ fn send_retained_frame_to_client(
+ &mut self,
+ client_id: u64,
+ frame: FrameData,
+ broken_clients: &mut Vec<u64>,
+ ) -> bool {
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ crate::render_prof::event("retained_send_fallback.client_missing");
+ return false;
+ };
+ let Some(writer) = client.writer.as_ref().cloned() else {
+ crate::render_prof::event("retained_send_fallback.writer_missing");
+ return false;
+ };
+ let prepare_started = crate::render_prof::timer();
+ 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);
+ return true;
+ };
+ crate::render_prof::duration_since("retained_send.prepare_frame", prepare_started);
+ let serialize_started = crate::render_prof::timer();
+ let serialized = match Self::frame_server_message(prepared.message()) {
+ Ok(framed) => {
+ crate::render_prof::duration_since("retained_send.serialize", serialize_started);
+ framed
+ }
+ Err(protocol::FramingError::Oversized { claimed, max }) => {
+ warn!(
+ client_id,
+ claimed, max, "skipping oversized retained frame for client"
+ );
+ crate::render_prof::event("retained_send_fallback.serialize_oversized");
+ crate::render_prof::duration_since("retained_send.serialize", serialize_started);
+ return false;
+ }
+ Err(err) => {
+ warn!(client_id, err = %err, "failed to serialize retained frame for client");
+ broken_clients.push(client_id);
+ crate::render_prof::event("retained_send_fallback.serialize_error");
+ crate::render_prof::duration_since("retained_send.serialize", serialize_started);
+ return false;
+ }
+ };
+ crate::render_prof::counter("retained_send.bytes", serialized.len() as u64);
+
+ let send_started = crate::render_prof::timer();
+ match writer.render.try_send(serialized) {
+ Ok(()) => {
+ client.render_pending = false;
+ client.render_state.commit_sent_frame(frame, prepared);
+ crate::render_prof::event("retained_send.sent");
+ crate::render_prof::duration_since("retained_send.try_send", send_started);
+ true
+ }
+ Err(std::sync::mpsc::TrySendError::Full(_)) => {
+ client.render_pending = true;
+ crate::render_prof::event("retained_send_fallback.queue_full");
+ crate::render_prof::duration_since("retained_send.try_send", send_started);
+ debug!(
+ client_id,
+ "render queue full, deferring latest retained frame"
+ );
+ false
+ }
+ Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
+ debug!(client_id, "client writer channel closed, marking as broken");
+ broken_clients.push(client_id);
+ crate::render_prof::event("retained_send_fallback.writer_disconnected");
+ crate::render_prof::duration_since("retained_send.try_send", send_started);
+ false
+ }
+ }
+ }
+
fn render_and_stream(&mut self) {
+ let full_started = crate::render_prof::timer();
let render_targets = render_targets(&self.clients, self.foreground_client_id);
if render_targets.is_empty() {
let (cols, rows) = self.effective_size;
let area = Rect::new(0, 0, cols, rows);
let resize_panes = self.app.state.view.pane_infos.is_empty();
+ let render_started = crate::render_prof::timer();
let _ = crate::server::render_stream::render_virtual_with_runtime_registry(
&mut self.app.state,
&self.app.terminal_runtimes,
@@ -2287,6 +2609,9 @@ impl HeadlessServer {
resize_panes,
crate::kitty_graphics::HostCellSize::default(),
);
+ crate::render_prof::duration_since("full_render.render_virtual", render_started);
+ self.app.full_redraw_pending = false;
+ crate::render_prof::duration_since("full_render.total", full_started);
debug!(
cols,
rows, resize_panes, "rendered virtual frame with no attached clients"
@@ -2295,11 +2620,13 @@ impl HeadlessServer {
}
let mut broken_clients: Vec<u64> = Vec::new();
+ let mut deferred_frame = false;
for (client_id, (cols, rows), cell_size, is_foreground, mode) in render_targets {
let area = Rect::new(0, 0, cols, rows);
let is_app_client = matches!(mode, ClientConnectionMode::App);
let mut frame = match mode {
ClientConnectionMode::App => {
+ let render_started = crate::render_prof::timer();
let (buffer, cursor) =
if self.app.state.kitty_graphics_enabled && cell_size.is_known() {
crate::server::render_stream::render_virtual_with_runtime_registry(
@@ -2318,11 +2645,27 @@ impl HeadlessServer {
crate::kitty_graphics::HostCellSize::default(),
)
};
+ crate::render_prof::duration_since(
+ "full_render.render_virtual",
+ render_started,
+ );
+ let hyperlinks_started = crate::render_prof::timer();
let hyperlinks = crate::server::render_stream::visible_hyperlinks(
&self.app.state,
&self.app.terminal_runtimes,
);
- FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks)
+ crate::render_prof::duration_since(
+ "full_render.visible_hyperlinks",
+ hyperlinks_started,
+ );
+ let frame_started = crate::render_prof::timer();
+ let frame = FrameData::from_ratatui_buffer_with_hyperlinks(
+ &buffer,
+ cursor,
+ &hyperlinks,
+ );
+ crate::render_prof::duration_since("full_render.frame_build", frame_started);
+ frame
}
ClientConnectionMode::TerminalAttach { terminal_id } => {
let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else {
@@ -2337,10 +2680,27 @@ impl HeadlessServer {
broken_clients.push(client_id);
continue;
};
+ let render_started = crate::render_prof::timer();
let (buffer, cursor) =
crate::server::render_stream::render_terminal_virtual(runtime, area);
+ crate::render_prof::duration_since(
+ "full_render.render_terminal_virtual",
+ render_started,
+ );
+ let hyperlinks_started = crate::render_prof::timer();
let hyperlinks = runtime.visible_hyperlinks(area);
- FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks)
+ crate::render_prof::duration_since(
+ "full_render.visible_hyperlinks",
+ hyperlinks_started,
+ );
+ let frame_started = crate::render_prof::timer();
+ let frame = FrameData::from_ratatui_buffer_with_hyperlinks(
+ &buffer,
+ cursor,
+ &hyperlinks,
+ );
+ crate::render_prof::duration_since("full_render.frame_build", frame_started);
+ frame
}
};
@@ -2353,6 +2713,7 @@ impl HeadlessServer {
if graphics_surface_reset_pending {
frame.graphics = next_graphics_cache.clear_bytes();
}
+ let graphics_started = crate::render_prof::timer();
frame
.graphics
.extend(crate::kitty_graphics::encode_local_pane_graphics(
@@ -2361,11 +2722,13 @@ impl HeadlessServer {
cell_size,
&mut next_graphics_cache,
));
+ crate::render_prof::duration_since("full_render.graphics_encode", graphics_started);
} else {
frame.graphics = next_graphics_cache.clear_bytes();
}
let Some(writer) = client.writer.as_ref().cloned() else {
+ crate::render_prof::event("full_render.writer_missing");
continue;
};
@@ -2381,10 +2744,14 @@ impl HeadlessServer {
commit_graphics_cache = false;
}
+ let prepare_started = crate::render_prof::timer();
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() {
@@ -2392,11 +2759,15 @@ impl HeadlessServer {
} else {
MAX_GRAPHICS_FRAME_SIZE
};
+ let serialize_started = crate::render_prof::timer();
let serialized = match Self::frame_server_message_with_max(
prepared.message(),
max_frame_size,
) {
- Ok(framed) => framed,
+ Ok(framed) => {
+ crate::render_prof::duration_since("full_render.serialize", serialize_started);
+ framed
+ }
Err(protocol::FramingError::Oversized { claimed, max })
if !frame.graphics.is_empty() =>
{
@@ -2410,6 +2781,11 @@ impl HeadlessServer {
client.render_state.prepare_frame(&text_only_frame)
else {
client.render_pending = false;
+ crate::render_prof::event("full_render.skip_identical_text_only");
+ crate::render_prof::duration_since(
+ "full_render.serialize",
+ serialize_started,
+ );
continue;
};
let framed = match Self::frame_server_message(text_only_prepared.message()) {
@@ -2417,12 +2793,18 @@ impl HeadlessServer {
Err(err) => {
warn!(client_id, err = %err, "failed to serialize text-only frame for client");
broken_clients.push(client_id);
+ crate::render_prof::event("full_render.serialize_error");
+ crate::render_prof::duration_since(
+ "full_render.serialize",
+ serialize_started,
+ );
continue;
}
};
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
}
Err(protocol::FramingError::Oversized { claimed, max }) => {
@@ -2430,15 +2812,21 @@ impl HeadlessServer {
client_id,
claimed, max, "skipping oversized frame for client"
);
+ crate::render_prof::event("full_render.serialize_oversized");
+ crate::render_prof::duration_since("full_render.serialize", serialize_started);
continue;
}
Err(err) => {
warn!(client_id, err = %err, "failed to serialize frame for client");
broken_clients.push(client_id);
+ crate::render_prof::event("full_render.serialize_error");
+ crate::render_prof::duration_since("full_render.serialize", serialize_started);
continue;
}
};
+ crate::render_prof::counter("full_render.bytes", serialized.len() as u64);
+ let send_started = crate::render_prof::timer();
match writer.render.try_send(serialized) {
Ok(()) => {
client.render_pending = false;
@@ -2449,15 +2837,22 @@ impl HeadlessServer {
client
.render_state
.commit_sent_frame(frame_to_commit, prepared);
+ crate::render_prof::event("full_render.sent");
+ crate::render_prof::duration_since("full_render.try_send", send_started);
}
Err(std::sync::mpsc::TrySendError::Full(_)) => {
client.render_pending = true;
+ deferred_frame = true;
+ crate::render_prof::event("full_render.queue_full");
+ crate::render_prof::duration_since("full_render.try_send", send_started);
debug!(client_id, "render queue full, deferring latest frame");
continue;
}
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
debug!(client_id, "client writer channel closed, marking as broken");
broken_clients.push(client_id);
+ crate::render_prof::event("full_render.writer_disconnected");
+ crate::render_prof::duration_since("full_render.try_send", send_started);
continue;
}
}
@@ -2470,6 +2865,10 @@ impl HeadlessServer {
}
let (cols, rows) = self.effective_size;
+ if !deferred_frame {
+ self.app.full_redraw_pending = false;
+ }
+ crate::render_prof::duration_since("full_render.total", full_started);
debug!(cols, rows, foreground_client_id = ?self.foreground_client_id, "rendered virtual frame(s)");
}
@@ -3054,6 +3453,71 @@ mod tests {
)
}
+ fn retained_test_server(
+ initial_screen: &[u8],
+ ) -> (
+ HeadlessServer,
+ std::sync::mpsc::Receiver<Vec<u8>>,
+ crate::layout::PaneId,
+ ) {
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("test");
+ let pane_id = workspace.focused_pane_id().expect("focused pane");
+ workspace.insert_test_runtime(
+ pane_id,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, initial_screen),
+ );
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ server.app.state.mode = crate::app::Mode::Terminal;
+
+ let (client_tx, _client_control_rx, client_rx) = test_client_writer();
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ None,
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+ server.resize_shared_runtime_to_effective_size();
+
+ (server, client_rx, pane_id)
+ }
+
+ fn assert_frame_data_eq(actual: &FrameData, expected: &FrameData) {
+ assert_eq!(
+ (actual.width, actual.height),
+ (expected.width, expected.height)
+ );
+ assert_eq!(actual.cursor, expected.cursor, "cursor mismatch");
+ assert_eq!(actual.hyperlinks, expected.hyperlinks, "hyperlink mismatch");
+ assert_eq!(actual.graphics, expected.graphics, "graphics mismatch");
+ assert_eq!(
+ actual.cells.len(),
+ expected.cells.len(),
+ "cell length mismatch"
+ );
+ for (idx, (actual_cell, expected_cell)) in
+ actual.cells.iter().zip(expected.cells.iter()).enumerate()
+ {
+ assert_eq!(
+ actual_cell,
+ expected_cell,
+ "cell mismatch at index {idx} (x={}, y={})",
+ idx % usize::from(actual.width),
+ idx / usize::from(actual.width),
+ );
+ }
+ }
+
#[test]
fn foreground_client_applies_client_keybindings() {
let mut server = test_headless_server();
@@ -5064,6 +5528,323 @@ next_tab = ""
);
}
+ #[tokio::test]
+ async fn retained_pty_update_streams_dirty_row_from_last_frame() {
+ let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
+ server.render_and_stream();
+ let first = read_server_frame(
+ client_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("initial frame"),
+ );
+ assert!(first.cells.iter().any(|cell| cell.symbol == "a"));
+
+ let runtime = server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
+ .expect("runtime");
+ runtime.test_process_pty_bytes(b"\rZ");
+
+ assert!(server.render_retained_pty_update_and_stream());
+ let patched = read_server_frame(
+ client_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("retained frame"),
+ );
+ assert!(patched.cells.iter().any(|cell| cell.symbol == "Z"));
+ assert_eq!((patched.width, patched.height), (80, 24));
+ }
+
+ #[tokio::test]
+ async fn retained_pty_update_matches_full_render_frame() {
+ let initial = b"\x1b[6 qleft \xe4\xb8\xad";
+ let update = b"\r\x1b[44mZ\x1b[0m";
+ let (mut retained_server, retained_rx, retained_pane_id) = retained_test_server(initial);
+ let (mut full_server, full_rx, full_pane_id) = retained_test_server(initial);
+
+ retained_server.render_and_stream();
+ let _ = retained_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("initial retained baseline");
+ full_server.render_and_stream();
+ let _ = full_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("initial full baseline");
+
+ retained_server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(
+ &retained_server.app.terminal_runtimes,
+ 0,
+ retained_pane_id,
+ )
+ .expect("retained runtime")
+ .test_process_pty_bytes(update);
+ full_server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(&full_server.app.terminal_runtimes, 0, full_pane_id)
+ .expect("full runtime")
+ .test_process_pty_bytes(update);
+
+ assert!(retained_server.render_retained_pty_update_and_stream());
+ full_server.render_and_stream();
+
+ let retained_frame = read_server_frame(
+ retained_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("retained frame"),
+ );
+ let full_frame = read_server_frame(
+ full_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("full frame"),
+ );
+ assert_frame_data_eq(&retained_frame, &full_frame);
+ }
+
+ #[tokio::test]
+ async fn retained_pty_update_streams_cursor_only_change() {
+ let initial = b"abcd";
+ let update = b"\x1b[D";
+ let (mut retained_server, retained_rx, retained_pane_id) = retained_test_server(initial);
+ let (mut full_server, full_rx, full_pane_id) = retained_test_server(initial);
+
+ retained_server.render_and_stream();
+ let _ = retained_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("initial retained baseline");
+ full_server.render_and_stream();
+ let _ = full_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("initial full baseline");
+
+ retained_server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(
+ &retained_server.app.terminal_runtimes,
+ 0,
+ retained_pane_id,
+ )
+ .expect("retained runtime")
+ .test_process_pty_bytes(update);
+ full_server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(&full_server.app.terminal_runtimes, 0, full_pane_id)
+ .expect("full runtime")
+ .test_process_pty_bytes(update);
+
+ assert!(retained_server.render_retained_pty_update_and_stream());
+ full_server.render_and_stream();
+
+ let retained_frame = read_server_frame(
+ retained_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("retained cursor frame"),
+ );
+ let full_frame = read_server_frame(
+ full_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("full cursor frame"),
+ );
+ assert_frame_data_eq(&retained_frame, &full_frame);
+ }
+
+ #[tokio::test]
+ async fn retained_pty_update_declines_unsafe_mode_without_consuming_dirty_rows() {
+ let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
+ server.render_and_stream();
+ let _ = client_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("initial frame");
+
+ let runtime = server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
+ .expect("runtime");
+ runtime.test_process_pty_bytes(b"\rZ");
+
+ server.app.state.mode = crate::app::Mode::Navigate;
+ assert!(!server.render_retained_pty_update_and_stream());
+ assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
+
+ server.app.state.mode = crate::app::Mode::Terminal;
+ assert!(server.render_retained_pty_update_and_stream());
+ let patched = read_server_frame(
+ client_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("retained frame after safe mode"),
+ );
+ assert!(patched.cells.iter().any(|cell| cell.symbol == "Z"));
+ }
+
+ #[tokio::test]
+ async fn headless_full_render_clears_full_redraw_pending_for_future_retained_updates() {
+ let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
+ server.app.full_redraw_pending = true;
+
+ server.render_and_stream();
+ let _ = client_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("full redraw frame");
+ assert!(!server.app.full_redraw_pending);
+
+ let runtime = server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
+ .expect("runtime");
+ runtime.test_process_pty_bytes(b"\rZ");
+
+ assert!(server.render_retained_pty_update_and_stream());
+ }
+
+ #[tokio::test]
+ async fn retained_pty_update_declines_when_patch_would_stale_hyperlinks() {
+ let (mut server, client_rx, pane_id) = retained_test_server(b"link");
+ server.render_and_stream();
+ let _ = client_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("initial frame");
+ let inner_rect = server.app.state.view.pane_infos[0].inner_rect;
+ let client = server.clients.get_mut(&1).unwrap();
+ let mut frame = client.render_state.last_frame().unwrap().clone();
+ frame.hyperlinks = vec!["https://example.com".to_owned()];
+ let hyperlink_idx =
+ usize::from(inner_rect.y) * usize::from(frame.width) + usize::from(inner_rect.x);
+ frame.cells[hyperlink_idx].hyperlink = Some(0);
+ let prepared = client
+ .render_state
+ .prepare_frame(&frame)
+ .expect("hyperlink frame differs");
+ client.render_state.commit_sent_frame(frame, prepared);
+
+ let runtime = server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
+ .expect("runtime");
+ runtime.test_process_pty_bytes(b"\rplain");
+
+ assert!(!server.render_retained_pty_update_and_stream());
+ assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
+
+ server.render_and_stream();
+ let full = read_server_frame(
+ client_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("full frame after hyperlink overwrite"),
+ );
+ assert!(
+ full.cells.iter().all(|cell| cell.hyperlink.is_none()),
+ "full render should clear overwritten hyperlink cells"
+ );
+ }
+
+ #[tokio::test]
+ async fn retained_pty_update_allows_kitty_enabled_empty_graphics_cache() {
+ let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
+ server.app.state.kitty_graphics_enabled = true;
+ server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize {
+ width_px: 10,
+ height_px: 20,
+ };
+
+ server.render_and_stream();
+ let _ = client_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("initial frame");
+
+ let runtime = server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
+ .expect("runtime");
+ runtime.test_process_pty_bytes(b"\rZ");
+
+ assert!(server.render_retained_pty_update_and_stream());
+ let retained = read_server_frame(
+ client_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("retained frame with kitty enabled"),
+ );
+ assert!(retained.cells.iter().any(|cell| cell.symbol == "Z"));
+ }
+
+ #[tokio::test]
+ async fn retained_pty_update_declines_when_graphics_cache_has_content() {
+ let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
+ server.app.state.kitty_graphics_enabled = true;
+ let client = server.clients.get_mut(&1).unwrap();
+ client.cell_size = crate::kitty_graphics::HostCellSize {
+ width_px: 10,
+ height_px: 20,
+ };
+
+ server.render_and_stream();
+ let _ = client_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("initial frame");
+ server
+ .clients
+ .get_mut(&1)
+ .unwrap()
+ .graphics_cache
+ .test_mark_non_empty();
+
+ let runtime = server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
+ .expect("runtime");
+ runtime.test_process_pty_bytes(b"\rZ");
+
+ assert!(!server.render_retained_pty_update_and_stream());
+ assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
+ }
+
+ #[tokio::test]
+ async fn full_redraw_pending_survives_full_render_queue_full() {
+ let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
+ let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig)
+ .expect("serialize dummy message");
+ server
+ .clients
+ .get(&1)
+ .unwrap()
+ .writer
+ .as_ref()
+ .unwrap()
+ .render
+ .send(queued)
+ .expect("pre-fill render queue");
+ server.app.full_redraw_pending = true;
+
+ server.render_and_stream();
+
+ assert!(server.app.full_redraw_pending);
+ assert!(server.clients.get(&1).unwrap().render_pending);
+ assert!(matches!(
+ read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()),
+ ServerMessage::ReloadSoundConfig
+ ));
+
+ let runtime = server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
+ .expect("runtime");
+ runtime.test_process_pty_bytes(b"\rZ");
+
+ assert!(!server.render_retained_pty_update_and_stream());
+ assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
+ }
+
#[test]
fn client_config_reload_request_refreshes_attached_clients() {
let mut server = test_headless_server();
diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs
index 3548f98a..97baf38d 100644
--- a/src/server/render_stream.rs
+++ b/src/server/render_stream.rs
@@ -45,8 +45,10 @@ impl ClientRenderState {
match self {
Self::Semantic { last_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,
@@ -54,10 +56,22 @@ impl ClientRenderState {
}
Self::TerminalAnsi { blit_encoder, seq } => {
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);
+ crate::render_prof::event("prepare_frame.ansi.changed");
+ crate::render_prof::counter("prepare_frame.ansi.bytes", encoded.bytes.len() as u64);
+ if encoded.full {
+ crate::render_prof::event("prepare_frame.ansi.full");
+ } else {
+ crate::render_prof::event("prepare_frame.ansi.partial");
+ }
insert_graphics_before_sync_end(&mut encoded.bytes, &frame.graphics);
+ crate::render_prof::counter(
+ "prepare_frame.graphics.bytes",
+ frame.graphics.len() as u64,
+ );
Some(PreparedRender {
message: ServerMessage::Terminal(TerminalFrame {
seq: *seq + 1,
@@ -72,6 +86,13 @@ impl ClientRenderState {
}
}
+ pub(crate) fn last_frame(&self) -> Option<&FrameData> {
+ match self {
+ Self::Semantic { last_frame } => last_frame.as_ref(),
+ Self::TerminalAnsi { blit_encoder, .. } => blit_encoder.last_frame(),
+ }
+ }
+
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),
@@ -316,7 +337,7 @@ pub(crate) fn visible_hyperlinks(
links
}
-fn focused_terminal_cursor(
+pub(crate) fn focused_terminal_cursor(
app_state: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
) -> Option<CursorState> {
diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
index 0fefe037..bfc8f3c6 100644
--- a/src/terminal/runtime.rs
+++ b/src/terminal/runtime.rs
@@ -273,6 +273,14 @@ impl TerminalRuntime {
self.0.render(frame, area, show_cursor);
}
+ pub(crate) fn collect_dirty_patch(
+ &self,
+ area_width: u16,
+ area_height: u16,
+ ) -> crate::pane::TerminalDirtyPatchOutcome {
+ self.0.collect_dirty_patch(area_width, area_height)
+ }
+
pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> {
self.0.visible_hyperlinks(area)
}
@@ -378,6 +386,10 @@ impl TerminalRuntime {
))
}
+ pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) {
+ self.0.test_process_pty_bytes(bytes);
+ }
+
pub(crate) fn test_with_scrollback_bytes(
cols: u16,
rows: u16,