From ae8862f7f20983eef0c12111a157bb7b3979fe4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Ram=C3=ADrez=20Norambuena?= Date: Sun, 12 Jul 2026 18:32:46 -0400 Subject: [PATCH] feat(terminal,renderer): add synchronized output (DEC mode ?2026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track ?2026 on the grid and skip presenting frames while it is set, so a program writing a complex update (vim, lazygit) never shows a partial screen — the window keeps the last frame until the program resets the mode, eliminating tearing. Reset by RIS. Collapse the DECSET/DECRST arms of handle_dec_private_modes into `h | l` patterns while adding ?2026. --- CHANGELOG.md | 1 + src/renderer/render_ops.rs | 15 +++++++++++++++ src/terminal/grid.rs | 4 ++++ src/terminal/parser.rs | 29 +++++++++++------------------ src/terminal/parser_test.rs | 9 +++++++++ 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 048f545..ea679f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- synchronized output (DEC mode `?2026`): frames are held while a program is mid-update, eliminating tearing - add `--maximized` and `--fullscreen` flags to start the window in that mode ### Changed diff --git a/src/renderer/render_ops.rs b/src/renderer/render_ops.rs index 3ea50ee..52e9689 100644 --- a/src/renderer/render_ops.rs +++ b/src/renderer/render_ops.rs @@ -86,12 +86,27 @@ pub(super) fn draw_overlays( } impl App { + /// True while the active pane's program holds synchronized output (?2026h). + fn active_grid_synchronized(&self) -> bool { + let tab = &self.state.tabs[self.state.active_tab]; + tab.panes + .get(&tab.active) + .and_then(|e| e.pane.grid_read().map(|g| g.synchronized_output)) + .unwrap_or(false) + } + pub(crate) fn redraw(&mut self) { // No tabs means startup failed to spawn any pane and an exit is pending; // skip the frame rather than indexing an empty `tabs`. if self.state.tabs.is_empty() { return; } + // Synchronized output (?2026): the running program is mid-update, so skip + // the frame entirely — the window keeps the last presented buffer and never + // shows a partial screen. The next PTY byte (the ?2026l reset) redraws. + if self.active_grid_synchronized() { + return; + } if self.state.blink_last.elapsed() >= Duration::from_millis(self.state.config.window.cursor_blink_ms as u64) { diff --git a/src/terminal/grid.rs b/src/terminal/grid.rs index a669ce4..fc95174 100644 --- a/src/terminal/grid.rs +++ b/src/terminal/grid.rs @@ -197,6 +197,8 @@ pub struct Grid { pub cursor_shape: CursorShape, // Bracketed paste mode (?2004) pub bracketed_paste: bool, + // Synchronized output (?2026): while true, the renderer skips presenting frames + pub synchronized_output: bool, // Mouse reporting mode: 0=off, 1000=click, 1002=button-motion, 1003=any-motion pub mouse_mode: u16, // SGR extended mouse encoding (?1006) @@ -281,6 +283,7 @@ impl Grid { cursor_visible: true, cursor_shape: CursorShape::Block, bracketed_paste: false, + synchronized_output: false, mouse_mode: 0, mouse_sgr: false, alternate_saved: None, @@ -1033,6 +1036,7 @@ impl Grid { self.cursor_visible = true; self.cursor_shape = CursorShape::Block; self.bracketed_paste = false; + self.synchronized_output = false; self.mouse_mode = 0; self.mouse_sgr = false; self.application_cursor_keys = false; diff --git a/src/terminal/parser.rs b/src/terminal/parser.rs index 2046d95..fcbf393 100644 --- a/src/terminal/parser.rs +++ b/src/terminal/parser.rs @@ -66,27 +66,20 @@ struct Performer<'a> { impl Performer<'_> { fn handle_dec_private_modes(&mut self, action: char, p0: u16) { + // Only DECSET (h) / DECRST (l) toggle modes; the `('h' | 'l', _)` patterns + // keep other actions out of these arms, so `on` is only used when relevant. + let on = action == 'h'; match (action, p0) { - ('h', 1) => self.grid.application_cursor_keys = true, - ('l', 1) => self.grid.application_cursor_keys = false, - ('h', 7) => self.grid.autowrap = true, - ('l', 7) => self.grid.autowrap = false, - ('h', 25) => self.grid.cursor_visible = true, - ('l', 25) => self.grid.cursor_visible = false, - ('h', 1000) => self.grid.mouse_mode = 1000, - ('l', 1000) => self.grid.mouse_mode = 0, - ('h', 1002) => self.grid.mouse_mode = 1002, - ('l', 1002) => self.grid.mouse_mode = 0, - ('h', 1003) => self.grid.mouse_mode = 1003, - ('l', 1003) => self.grid.mouse_mode = 0, - ('h', 1004) => self.grid.focus_report = true, - ('l', 1004) => self.grid.focus_report = false, - ('h', 1006) => self.grid.mouse_sgr = true, - ('l', 1006) => self.grid.mouse_sgr = false, + ('h' | 'l', 1) => self.grid.application_cursor_keys = on, + ('h' | 'l', 7) => self.grid.autowrap = on, + ('h' | 'l', 25) => self.grid.cursor_visible = on, + ('h' | 'l', 1000 | 1002 | 1003) => self.grid.mouse_mode = if on { p0 } else { 0 }, + ('h' | 'l', 1004) => self.grid.focus_report = on, + ('h' | 'l', 1006) => self.grid.mouse_sgr = on, + ('h' | 'l', 2004) => self.grid.bracketed_paste = on, + ('h' | 'l', 2026) => self.grid.synchronized_output = on, ('h', 1049) => self.grid.enter_alternate_screen(), ('l', 1049) => self.grid.exit_alternate_screen(), - ('h', 2004) => self.grid.bracketed_paste = true, - ('l', 2004) => self.grid.bracketed_paste = false, _ => {} } } diff --git a/src/terminal/parser_test.rs b/src/terminal/parser_test.rs index 42251b9..600d24a 100644 --- a/src/terminal/parser_test.rs +++ b/src/terminal/parser_test.rs @@ -290,6 +290,15 @@ fn bracketed_paste_mode() { assert!(!p.grid.bracketed_paste); } +#[test] +fn synchronized_output_mode() { + let mut p = make_parser(10, 5); + p.process(b"\x1b[?2026h"); + assert!(p.grid.synchronized_output); + p.process(b"\x1b[?2026l"); + assert!(!p.grid.synchronized_output); +} + #[test] fn scroll_region_sets_top_and_bottom() { let mut p = make_parser(10, 10);