brewery
notashelf /
72044c21fde38f7eae2677e1ef9f1b88b59fc518

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit 72044c21fde3

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 07:21 UTC

unverified3 files changed+64-2
vt: honour synchronized output (DECSET 2026) with a present timeout

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I173dc842d89d96ea39154e1fde95be816a6a6964
diff --git a/src/grid.rs b/src/grid.rsindex 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<bool> {@@ -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.rsindex 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,         };@@ -714,6 +717,20 @@ mod tests {     }      #[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);         feed(&mut t, b"\x1b]0;first\x07");diff --git a/src/wayland.rs b/src/wayland.rsindex 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<ExitCode> {         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<RegistrationToken>,     /// 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();         }