From 72044c21fde38f7eae2677e1ef9f1b88b59fc518 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 25 Jun 2026 08:36:32 +0300 Subject: [PATCH] vt: honour synchronized output (DECSET 2026) with a present timeout Signed-off-by: NotAShelf Change-Id: I173dc842d89d96ea39154e1fde95be816a6a6964 --- src/grid.rs | 12 ++++++++++++ src/vt.rs | 17 +++++++++++++++++ src/wayland.rs | 37 +++++++++++++++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/grid.rs b/src/grid.rs index 7f6cdc6..764b73d 100644 --- a/src/grid.rs +++ b/src/grid.rs @@ -151,6 +151,9 @@ pub struct Grid { selection: Option<(Point, Point)>, /// Bracketed paste mode (DECSET 2004): wrap pasted text in `ESC[200~`/`201~`. bracketed_paste: bool, + /// Synchronized output (DECSET 2026): hold presentation while a frame is + /// being assembled, so the screen never shows a half-drawn update. + sync: bool, } fn default_tabs(cols: usize) -> Vec { @@ -185,6 +188,7 @@ impl Grid { app_cursor: false, selection: None, bracketed_paste: false, + sync: false, } } @@ -882,6 +886,14 @@ impl Grid { self.bracketed_paste } + pub fn set_sync(&mut self, on: bool) { + self.sync = on; + } + + pub fn sync_active(&self) -> bool { + self.sync + } + // --- inspection (logging + tests) --- /// The visible text of one row, trailing blanks trimmed. diff --git a/src/vt.rs b/src/vt.rs index ddd4343..7b35a04 100644 --- a/src/vt.rs +++ b/src/vt.rs @@ -180,6 +180,7 @@ impl Term { (true, 1) => self.grid.set_app_cursor(on), (true, 25) => self.grid.set_cursor_visible(on), (true, 2004) => self.grid.set_bracketed_paste(on), + (true, 2026) => self.grid.set_sync(on), // App-cursor/bracketed-paste/mouse/sync modes affect input and // rendering, which arrive with the keyboard and renderer. _ => tracing::trace!("unhandled mode {code} private={private} on={on}"), @@ -281,6 +282,8 @@ impl Term { (true, 6) => set_reset(self.grid.origin()), (true, 7) => set_reset(self.grid.autowrap()), (true, 47 | 1047 | 1049) => set_reset(self.grid.alt_active()), + (true, 2004) => set_reset(self.grid.bracketed_paste()), + (true, 2026) => set_reset(self.grid.sync_active()), (false, 4) => set_reset(self.grid.insert()), _ => 0, }; @@ -713,6 +716,20 @@ mod tests { assert_eq!(t.take_response(), b"\x1bP0+r6162\x1b\\"); } + #[test] + fn bracketed_paste_and_sync_modes() { + let mut t = Term::new(20, 2); + feed(&mut t, b"\x1b[?2004h"); + assert!(t.grid().bracketed_paste()); + feed(&mut t, b"\x1b[?2004$p"); + assert_eq!(t.take_response(), b"\x1b[?2004;1$y"); + feed(&mut t, b"\x1b[?2026h"); + assert!(t.grid().sync_active()); + feed(&mut t, b"\x1b[?2026l\x1b[?2026$p"); + assert!(!t.grid().sync_active()); + assert_eq!(t.take_response(), b"\x1b[?2026;2$y"); + } + #[test] fn title_stack_push_pop() { let mut t = Term::new(20, 4); diff --git a/src/wayland.rs b/src/wayland.rs index 90c438b..e94faaf 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -14,7 +14,7 @@ use std::time::Duration; use anyhow::Context; use calloop::generic::Generic; use calloop::timer::{TimeoutAction, Timer}; -use calloop::{EventLoop, Interest, LoopHandle, Mode, PostAction}; +use calloop::{EventLoop, Interest, LoopHandle, Mode, PostAction, RegistrationToken}; use calloop_wayland_source::WaylandSource; use crate::font::Fonts; @@ -99,6 +99,10 @@ const BLINK_MS: u64 = 500; /// Buffers kept for double/triple buffering before we wait for a release. const MAX_BUFFERS: usize = 3; +/// How long synchronized output (DECSET 2026) may hold the screen before we +/// present anyway, so a misbehaving app cannot freeze the window. +const SYNC_TIMEOUT_MS: u64 = 150; + /// What determines one rendered row's pixels: its cells, the cursor on it, the /// selection span over it, and the blink phase. Two equal `RowSnap`s render /// identically, so a buffer holding an equal snapshot needs no repaint. @@ -220,6 +224,7 @@ pub fn run() -> anyhow::Result { frames: Vec::new(), buf_dims: (0, 0), blink_on: true, + sync_timeout: None, focused: true, exit: false, exit_code: ExitCode::SUCCESS, @@ -325,6 +330,8 @@ struct App { buf_dims: (u32, u32), /// Current blink phase, toggled by a timer; off hides blinking ink. blink_on: bool, + /// Armed while synchronized output holds the screen, to force it open. + sync_timeout: Option, /// Whether the toplevel currently has keyboard focus (drives the cursor). focused: bool, exit: bool, @@ -696,8 +703,34 @@ impl App { /// Present a frame if one is wanted and the compositor is ready for it. /// Called after every event-loop wake; the frame-callback gate keeps draws - /// paced to the display instead of one per PTY read. + /// paced to the display instead of one per PTY read. While the app holds + /// synchronized output (DECSET 2026) we withhold the frame, but arm a + /// timeout so a stuck `2026h` cannot freeze the window. fn flush(&mut self) { + let sync = self + .session + .as_ref() + .is_some_and(|s| s.term.grid().sync_active()); + if sync { + if self.sync_timeout.is_none() { + let timer = Timer::from_duration(Duration::from_millis(SYNC_TIMEOUT_MS)); + self.sync_timeout = self + .loop_handle + .insert_source(timer, |_, _, app: &mut App| { + if let Some(session) = app.session.as_mut() { + session.term.grid_mut().set_sync(false); + } + app.sync_timeout = None; + app.needs_draw = true; + TimeoutAction::Drop + }) + .ok(); + } + return; + } + if let Some(token) = self.sync_timeout.take() { + self.loop_handle.remove(token); + } if self.needs_draw && !self.frame_pending && self.session.is_some() { self.present(); }