brewery
notashelf /
f1c8271d3190f487a76fca95deb72fb565f75b33

beer

public

A terminal worth pouring time into

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

Commit f1c8271d3190

tarball

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

unverified4 files changed+274-102
render: frame-paced presentation with per-row damage and blink

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4e925b4d1d904d9592060e968d84ec906a6a6964
diff --git a/src/grid.rs b/src/grid.rsindex 67c9e4e..7f6cdc6 100644--- a/src/grid.rs+++ b/src/grid.rs@@ -140,6 +140,8 @@ pub struct Grid {     /// How many lines the viewport is scrolled back from the live bottom.     view_offset: usize,     cursor_shape: CursorShape,+    /// Whether the cursor shape is a blinking variant (DECSCUSR odd codes).+    cursor_blink: bool,     cursor_visible: bool,     /// Application cursor-keys mode (DECCKM): arrows send SS3 instead of CSI.     app_cursor: bool,@@ -177,6 +179,7 @@ impl Grid {             scrollback: VecDeque::new(),             view_offset: 0,             cursor_shape: CursorShape::default(),+            cursor_blink: false,             cursor_visible: true,             cursor_color: None,             app_cursor: false,@@ -265,6 +268,14 @@ impl Grid {         self.cursor_shape     } +    pub fn set_cursor_blink(&mut self, blink: bool) {+        self.cursor_blink = blink;+    }++    pub fn cursor_blink(&self) -> bool {+        self.cursor_blink+    }+     pub fn set_cursor_visible(&mut self, visible: bool) {         self.cursor_visible = visible;     }@@ -819,6 +830,22 @@ impl Grid {         col >= lo && col <= hi     } +    /// The inclusive `(lo, hi)` column span selected on absolute row `row`, if+    /// any part of that row is selected.+    pub fn selection_span_on(&self, row: usize) -> Option<(usize, usize)> {+        let (start, end) = self.ordered_selection()?;+        if row < start.row || row > end.row {+            return None;+        }+        let lo = if row == start.row { start.col } else { 0 };+        let hi = if row == end.row {+            end.col+        } else {+            self.abs_row(row).len().saturating_sub(1)+        };+        Some((lo, hi))+    }+     /// The selected text, with trailing blanks trimmed per line and rows joined     /// by newlines. `None` if there is no selection.     pub fn selection_text(&self) -> Option<String> {diff --git a/src/render.rs b/src/render.rsindex 697bcae..552698a 100644--- a/src/render.rs+++ b/src/render.rs@@ -32,15 +32,6 @@ impl Canvas<'_> {         Some((y as usize * self.width + x as usize) * 4)     } -    /// Fill the whole buffer with one colour (fast path, no per-pixel bounds-    /// checks).-    fn clear(&mut self, c: Rgb) {-        let bytes = [c.2, c.1, c.0, 0xff];-        for px in self.pixels.chunks_exact_mut(4) {-            px.copy_from_slice(&bytes);-        }-    }-     fn fill_rect(&mut self, x0: i32, y0: i32, w: u32, h: u32, c: Rgb) {         let x_start = x0.max(0) as usize;         let x_end = ((x0 + w as i32).max(0) as usize).min(self.width);@@ -110,76 +101,79 @@ impl Renderer {         self.fonts.metrics()     } -    /// Compose `grid` into `pixels` (BGRA, `width`×`height` px). `focused`-    /// selects a solid or hollow cursor.-    pub fn render(+    /// Repaint a single grid row `y` into `pixels` (BGRA, `width`×`height` px):+    /// clear the row band, fill backgrounds (and selection), draw glyphs and+    /// decorations, then the cursor if it sits on this row. `blink_on` is the+    /// current blink phase; blinking cells and a blinking cursor vanish when it+    /// is `false`. Painting one row at a time is what lets the caller damage+    /// only the rows that actually changed.+    pub fn render_row(         &mut self,-        grid: &Grid,         pixels: &mut [u8],-        width: usize,-        height: usize,+        dims: (usize, usize),+        grid: &Grid,+        y: usize,         focused: bool,+        blink_on: bool,     ) {+        let (width, height) = dims;         let mut canvas = Canvas {             pixels,             width,             height,         };-        canvas.clear(DEFAULT_BG);-         let m = self.fonts.metrics();         let cols = grid.cols();--        // Cell backgrounds: only paint cells that differ from the cleared-        // default - most of a screen is default background. Rows come through-        // the scrollback viewport and may be shorter than `cols` after a resize.-        for y in 0..grid.rows() {-            let abs = grid.view_to_abs(y);-            for (x, cell) in grid.view_row(y).iter().take(cols).enumerate() {-                let bg = if grid.is_selected(abs, x) {-                    SELECTION_BG-                } else {-                    cell_colors(cell).1-                };-                if bg != DEFAULT_BG {-                    let (px, py) = (x as i32 * m.width as i32, y as i32 * m.height as i32);-                    canvas.fill_rect(px, py, m.width, m.height, bg);-                }+        let row_top = y as i32 * m.height as i32;+        canvas.fill_rect(0, row_top, width as u32, m.height, DEFAULT_BG);++        // Rows come through the scrollback viewport and may be shorter than+        // `cols` after a resize, so clamp with `take`.+        let abs = grid.view_to_abs(y);+        let cells = grid.view_row(y);+        for (x, cell) in cells.iter().take(cols).enumerate() {+            let bg = if grid.is_selected(abs, x) {+                SELECTION_BG+            } else {+                cell_colors(cell).1+            };+            if bg != DEFAULT_BG {+                canvas.fill_rect(x as i32 * m.width as i32, row_top, m.width, m.height, bg);             }         } -        for y in 0..grid.rows() {-            for (x, cell) in grid.view_row(y).iter().take(cols).enumerate() {-                if cell.flags.contains(Flags::WIDE_CONT) {-                    continue;-                }-                let (fg, _) = cell_colors(cell);-                let origin_x = x as i32 * m.width as i32;-                let cell_top = y as i32 * m.height as i32;-                if cell.c != ' ' {-                    self.draw_glyph(-                        &mut canvas,-                        cell.c,-                        cell_style(cell),-                        origin_x,-                        cell_top,-                        fg,-                    );-                }-                draw_decorations(&mut canvas, cell, origin_x, cell_top, m, fg);+        for (x, cell) in cells.iter().take(cols).enumerate() {+            if cell.flags.contains(Flags::WIDE_CONT) {+                continue;+            }+            if cell.flags.contains(Flags::BLINK) && !blink_on {+                continue;             }+            let (fg, _) = cell_colors(cell);+            let origin_x = x as i32 * m.width as i32;+            if cell.c != ' ' {+                self.draw_glyph(&mut canvas, cell.c, cell_style(cell), origin_x, row_top, fg);+            }+            draw_decorations(&mut canvas, cell, origin_x, row_top, m, fg);         }          // The cursor belongs to the live screen; hide it while scrolled back.-        if grid.view_at_bottom() {-            self.draw_cursor(&mut canvas, grid, m, focused);+        if grid.view_at_bottom() && grid.cursor().1 == y {+            self.draw_cursor(&mut canvas, grid, m, focused, blink_on);         }     }      /// Draw the cursor: a solid block/underline/beam when focused, a hollow-    /// outline when not.-    fn draw_cursor(&mut self, canvas: &mut Canvas, grid: &Grid, m: CellMetrics, focused: bool) {-        if !grid.cursor_visible() {+    /// outline when not. A blinking cursor shape is only drawn while `blink_on`.+    fn draw_cursor(+        &mut self,+        canvas: &mut Canvas,+        grid: &Grid,+        m: CellMetrics,+        focused: bool,+        blink_on: bool,+    ) {+        if !grid.cursor_visible() || (grid.cursor_blink() && !blink_on) {             return;         }         let (cx, cy) = grid.cursor();diff --git a/src/vt.rs b/src/vt.rsindex 39514f0..ddd4343 100644--- a/src/vt.rs+++ b/src/vt.rs@@ -473,11 +473,14 @@ impl Perform for Term {             }),             'q' if intermediates.first() == Some(&b'>') => self.report_version(),             'q' if intermediates.first() == Some(&b' ') => {-                self.grid.set_cursor_shape(match raw(params, 0) {+                let code = raw(params, 0);+                self.grid.set_cursor_shape(match code {                     3 | 4 => CursorShape::Underline,                     5 | 6 => CursorShape::Beam,                     _ => CursorShape::Block,                 });+                // Even codes are steady; 0/1 and other odd codes blink.+                self.grid.set_cursor_blink(code == 0 || code % 2 == 1);             }             'p' if intermediates.contains(&b'$') => self.report_mode(params, private),             'n' => self.device_status(params),diff --git a/src/wayland.rs b/src/wayland.rsindex e00a920..90c438b 100644--- a/src/wayland.rs+++ b/src/wayland.rs@@ -9,12 +9,16 @@ use std::os::fd::OwnedFd; use std::os::unix::process::ExitStatusExt; use std::process::ExitCode; +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_wayland_source::WaylandSource;  use crate::font::Fonts;+use crate::grid::{Cell, CursorShape, Grid}; use crate::pty::Pty; use crate::render::Renderer; use crate::vt::Term;@@ -53,7 +57,10 @@ use smithay_client_toolkit::{             window::{Window, WindowConfigure, WindowDecorations, WindowHandler},         },     },-    shm::{Shm, ShmHandler, slot::SlotPool},+    shm::{+        Shm, ShmHandler,+        slot::{Buffer, SlotPool},+    }, }; use wayland_client::{     Connection, QueueHandle,@@ -86,6 +93,55 @@ fn pick_mime(mimes: &[String]) -> Option<String> { /// Max gap between clicks counted as a multi-click (ms). const MULTI_CLICK_MS: u32 = 400; +/// Blink half-period: cells/cursor toggle visibility this often.+const BLINK_MS: u64 = 500;++/// Buffers kept for double/triple buffering before we wait for a release.+const MAX_BUFFERS: usize = 3;++/// 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.+#[derive(Clone, PartialEq, Debug)]+struct RowSnap {+    cells: Vec<Cell>,+    /// `(col, shape, focused)` when the cursor is drawn on this row.+    cursor: Option<(usize, CursorShape, bool)>,+    /// Inclusive selected column span on this row.+    sel: Option<(usize, usize)>,+    /// Blink phase, but only varied when the row actually has blinking ink, so+    /// non-blinking rows stay equal across phase toggles.+    blink: bool,+}++/// One shm buffer plus the per-row snapshot of what it currently displays.+#[derive(Debug)]+struct FrameBuf {+    buffer: Buffer,+    rows: Vec<RowSnap>,+}++/// Snapshot the determinants of viewport row `y`'s pixels.+fn row_snap(grid: &Grid, y: usize, focused: bool, blink_on: bool) -> RowSnap {+    let abs = grid.view_to_abs(y);+    let cells = grid.view_row(y).to_vec();+    let cursor = if grid.view_at_bottom() && grid.cursor().1 == y {+        let visible = grid.cursor_visible() && (!grid.cursor_blink() || blink_on);+        visible.then(|| (grid.cursor().0, grid.cursor_shape(), focused))+    } else {+        None+    };+    let has_blink = cells+        .iter()+        .any(|c| c.flags.contains(crate::grid::Flags::BLINK));+    RowSnap {+        cells,+        cursor,+        sel: grid.selection_span_on(abs),+        blink: if has_blink { blink_on } else { true },+    }+}+ /// Default window size in pixels before the compositor suggests one. const DEFAULT_W: u32 = 800; const DEFAULT_H: u32 = 600;@@ -159,22 +215,37 @@ pub fn run() -> anyhow::Result<ExitCode> {         title: None,         width: DEFAULT_W,         height: DEFAULT_H,-        dirty: false,+        needs_draw: false,+        frame_pending: false,+        frames: Vec::new(),+        buf_dims: (0, 0),+        blink_on: true,         focused: true,         exit: false,         exit_code: ExitCode::SUCCESS,     }; -    // Block until an event (PTY output, input, configure) arrives - every-    // repaint is driven by one, so there is nothing to do on a timer.+    // Toggle the blink phase on a timer so blinking text and cursors animate.+    let blink = Timer::from_duration(Duration::from_millis(BLINK_MS));+    let blink_registered = event_loop+        .handle()+        .insert_source(blink, |_, _, app: &mut App| {+            app.blink_on = !app.blink_on;+            app.needs_draw = true;+            TimeoutAction::ToDuration(Duration::from_millis(BLINK_MS))+        });+    if let Err(err) = blink_registered {+        tracing::warn!("register blink timer: {err}");+    }++    // Each iteration blocks until an event (PTY output, input, configure, frame+    // callback, blink) arrives, then presents at most one frame; bursts of PTY+    // output between frame callbacks coalesce into a single repaint.     while !app.exit {         event_loop             .dispatch(None, &mut app)             .context("dispatch event loop")?;-        if app.dirty {-            app.draw();-            app.dirty = false;-        }+        app.flush();     }     Ok(app.exit_code) }@@ -244,8 +315,16 @@ struct App {     title: Option<String>,     width: u32,     height: u32,-    /// The grid changed and the window needs repainting.-    dirty: bool,+    /// The grid changed and the window wants repainting on the next frame.+    needs_draw: bool,+    /// A `wl_surface.frame` callback is in flight; defer drawing until it fires.+    frame_pending: bool,+    /// Double/triple-buffer ring, each tagged with the rows it currently shows.+    frames: Vec<FrameBuf>,+    /// Pixel size the `frames` buffers were allocated for.+    buf_dims: (u32, u32),+    /// Current blink phase, toggled by a timer; off hides blinking ink.+    blink_on: bool,     /// Whether the toplevel currently has keyboard focus (drives the cursor).     focused: bool,     exit: bool,@@ -341,7 +420,7 @@ impl App {                     -page                 };                 session.term.scroll_view(delta);-                self.dirty = true;+                self.needs_draw = true;             }             return;         }@@ -355,7 +434,7 @@ impl App {         {             session.term.scroll_to_bottom();             session.term.grid_mut().clear_selection();-            self.dirty = true;+            self.needs_draw = true;             if let Err(err) = write_all(session.pty.master(), &bytes) {                 tracing::warn!("write key to pty: {err}");             }@@ -396,7 +475,7 @@ impl App {             _ => grid.start_selection(row, col),         }         self.selecting = true;-        self.dirty = true;+        self.needs_draw = true;     }      /// Pointer motion during a drag: extend the selection head.@@ -409,7 +488,7 @@ impl App {         };         if let Some(session) = self.session.as_mut() {             session.term.grid_mut().extend_selection(row, col);-            self.dirty = true;+            self.needs_draw = true;         }     } @@ -534,7 +613,7 @@ impl App {             return;         };         session.term.scroll_to_bottom();-        self.dirty = true;+        self.needs_draw = true;         let bracketed = session.term.grid().bracketed_paste();         // Strip control bytes a terminal must never receive raw from a paste;         // keep tab and newlines (CR is what the shell expects for Enter).@@ -575,7 +654,7 @@ impl App {             self.window                 .set_title(self.title.clone().unwrap_or_default());         }-        self.dirty = true;+        self.needs_draw = true;     }      /// Recompute the grid size for the current window and tell the grid and the@@ -615,41 +694,106 @@ impl App {         self.exit = true;     } -    /// Render the grid into a fresh buffer and present it.-    fn draw(&mut self) {+    /// 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.+    fn flush(&mut self) {+        if self.needs_draw && !self.frame_pending && self.session.is_some() {+            self.present();+        }+    }++    /// Render only the rows that changed since the chosen buffer last displayed+    /// them, damage just those rows, and commit with a frame-callback request.+    fn present(&mut self) {+        self.needs_draw = false;+        let (w, h) = (self.width, self.height);+        let m = self.renderer.metrics();+        let (focused, blink_on) = (self.focused, self.blink_on);++        // A resize invalidates every buffer's contents and size.+        if self.buf_dims != (w, h) {+            self.frames.clear();+            self.buf_dims = (w, h);+        }+         let Some(session) = self.session.as_ref() else {             return;         };-        let (w, h) = (self.width, self.height);-        let stride = w as i32 * 4;+        let grid = session.term.grid();+        let rows = grid.rows();+        let cur: Vec<RowSnap> = (0..rows)+            .map(|y| row_snap(grid, y, focused, blink_on))+            .collect(); -        let (buffer, canvas) =-            match self-                .pool-                .create_buffer(w as i32, h as i32, stride, wl_shm::Format::Argb8888)-            {-                Ok(buf) => buf,-                Err(err) => {-                    tracing::error!("allocate shm buffer: {err}");-                    return;+        // Reuse a buffer the compositor has released, else grow the ring.+        let stride = w as i32 * 4;+        let mut idx = None;+        for i in 0..self.frames.len() {+            if self.pool.canvas(&self.frames[i].buffer).is_some() {+                idx = Some(i);+                break;+            }+        }+        let idx = match idx {+            Some(i) => i,+            None if self.frames.len() < MAX_BUFFERS => {+                match self+                    .pool+                    .create_buffer(w as i32, h as i32, stride, wl_shm::Format::Argb8888)+                {+                    Ok((buffer, _)) => {+                        self.frames.push(FrameBuf {+                            buffer,+                            rows: Vec::new(),+                        });+                        self.frames.len() - 1+                    }+                    Err(err) => {+                        tracing::error!("allocate shm buffer: {err}");+                        return;+                    }                 }-            };+            }+            // All buffers are still held by the compositor; a release event will+            // wake us and `needs_draw` (re-set below) retries then.+            None => {+                self.needs_draw = true;+                return;+            }+        }; -        self.renderer.render(-            session.term.grid(),-            canvas,-            w as usize,-            h as usize,-            self.focused,-        );+        // Rows that differ from what this buffer last showed (all, if fresh).+        let prev = &self.frames[idx].rows;+        let dirty: Vec<usize> = (0..rows)+            .filter(|&y| prev.get(y) != Some(&cur[y]))+            .collect();+        if dirty.is_empty() {+            return;+        }++        let Some(canvas) = self.pool.canvas(&self.frames[idx].buffer) else {+            return;+        };+        let dims = (w as usize, h as usize);+        for &y in &dirty {+            self.renderer+                .render_row(canvas, dims, grid, y, focused, blink_on);+        }+        self.frames[idx].rows = cur;          let surface = self.window.wl_surface();-        if let Err(err) = buffer.attach_to(surface) {+        if let Err(err) = self.frames[idx].buffer.attach_to(surface) {             tracing::error!("attach buffer: {err}");             return;         }-        surface.damage_buffer(0, 0, w as i32, h as i32);+        for &y in &dirty {+            let top = y as i32 * m.height as i32;+            surface.damage_buffer(0, top, w as i32, m.height as i32);+        }+        surface.frame(&self.qh, surface.clone());         self.window.commit();+        self.frame_pending = true;     } } @@ -672,7 +816,11 @@ impl CompositorHandler for App {     ) {     } -    fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {}+    fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {+        // The compositor is ready for another frame; `flush` will repaint if the+        // grid has changed since the last present.+        self.frame_pending = false;+    }      fn surface_enter(         &mut self,@@ -716,7 +864,7 @@ impl WindowHandler for App {         } else {             self.resize_grid();         }-        self.draw();+        self.needs_draw = true;     } } @@ -811,7 +959,7 @@ impl KeyboardHandler for App {     ) {         self.serial = serial;         self.focused = true;-        self.dirty = true;+        self.needs_draw = true;     }      fn leave(@@ -823,7 +971,7 @@ impl KeyboardHandler for App {         _: u32,     ) {         self.focused = false;-        self.dirty = true;+        self.needs_draw = true;     }      fn press_key(@@ -936,7 +1084,7 @@ impl PointerHandler for App {                     let delta = if raw < 0.0 { lines } else { -lines };                     if let Some(session) = self.session.as_mut() {                         session.term.scroll_view(delta);-                        self.dirty = true;+                        self.needs_draw = true;                     }                 }                 _ => {}