render: frame-paced presentation with per-row damage and blink

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4e925b4d1d904d9592060e968d84ec906a6a6964
This commit is contained in:
raf 2026-06-25 08:17:55 +03:00
commit f1c8271d31
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
4 changed files with 274 additions and 102 deletions

View file

@ -140,6 +140,8 @@ pub struct Grid {
/// How many lines the viewport is scrolled back from the live bottom. /// How many lines the viewport is scrolled back from the live bottom.
view_offset: usize, view_offset: usize,
cursor_shape: CursorShape, cursor_shape: CursorShape,
/// Whether the cursor shape is a blinking variant (DECSCUSR odd codes).
cursor_blink: bool,
cursor_visible: bool, cursor_visible: bool,
/// Application cursor-keys mode (DECCKM): arrows send SS3 instead of CSI. /// Application cursor-keys mode (DECCKM): arrows send SS3 instead of CSI.
app_cursor: bool, app_cursor: bool,
@ -177,6 +179,7 @@ impl Grid {
scrollback: VecDeque::new(), scrollback: VecDeque::new(),
view_offset: 0, view_offset: 0,
cursor_shape: CursorShape::default(), cursor_shape: CursorShape::default(),
cursor_blink: false,
cursor_visible: true, cursor_visible: true,
cursor_color: None, cursor_color: None,
app_cursor: false, app_cursor: false,
@ -265,6 +268,14 @@ impl Grid {
self.cursor_shape 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) { pub fn set_cursor_visible(&mut self, visible: bool) {
self.cursor_visible = visible; self.cursor_visible = visible;
} }
@ -819,6 +830,22 @@ impl Grid {
col >= lo && col <= hi 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 /// The selected text, with trailing blanks trimmed per line and rows joined
/// by newlines. `None` if there is no selection. /// by newlines. `None` if there is no selection.
pub fn selection_text(&self) -> Option<String> { pub fn selection_text(&self) -> Option<String> {

View file

@ -32,15 +32,6 @@ impl Canvas<'_> {
Some((y as usize * self.width + x as usize) * 4) 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) { 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_start = x0.max(0) as usize;
let x_end = ((x0 + w as i32).max(0) as usize).min(self.width); let x_end = ((x0 + w as i32).max(0) as usize).min(self.width);
@ -110,76 +101,79 @@ impl Renderer {
self.fonts.metrics() self.fonts.metrics()
} }
/// Compose `grid` into `pixels` (BGRA, `width`×`height` px). `focused` /// Repaint a single grid row `y` into `pixels` (BGRA, `width`×`height` px):
/// selects a solid or hollow cursor. /// clear the row band, fill backgrounds (and selection), draw glyphs and
pub fn render( /// 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, &mut self,
grid: &Grid,
pixels: &mut [u8], pixels: &mut [u8],
width: usize, dims: (usize, usize),
height: usize, grid: &Grid,
y: usize,
focused: bool, focused: bool,
blink_on: bool,
) { ) {
let (width, height) = dims;
let mut canvas = Canvas { let mut canvas = Canvas {
pixels, pixels,
width, width,
height, height,
}; };
canvas.clear(DEFAULT_BG);
let m = self.fonts.metrics(); let m = self.fonts.metrics();
let cols = grid.cols(); let cols = grid.cols();
let row_top = y as i32 * m.height as i32;
canvas.fill_rect(0, row_top, width as u32, m.height, DEFAULT_BG);
// Cell backgrounds: only paint cells that differ from the cleared // Rows come through the scrollback viewport and may be shorter than
// default - most of a screen is default background. Rows come through // `cols` after a resize, so clamp with `take`.
// 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); let abs = grid.view_to_abs(y);
for (x, cell) in grid.view_row(y).iter().take(cols).enumerate() { let cells = grid.view_row(y);
for (x, cell) in cells.iter().take(cols).enumerate() {
let bg = if grid.is_selected(abs, x) { let bg = if grid.is_selected(abs, x) {
SELECTION_BG SELECTION_BG
} else { } else {
cell_colors(cell).1 cell_colors(cell).1
}; };
if bg != DEFAULT_BG { if bg != DEFAULT_BG {
let (px, py) = (x as i32 * m.width as i32, y as i32 * m.height as i32); canvas.fill_rect(x as i32 * m.width as i32, row_top, m.width, m.height, bg);
canvas.fill_rect(px, py, m.width, m.height, bg);
}
} }
} }
for y in 0..grid.rows() { for (x, cell) in cells.iter().take(cols).enumerate() {
for (x, cell) in grid.view_row(y).iter().take(cols).enumerate() {
if cell.flags.contains(Flags::WIDE_CONT) { if cell.flags.contains(Flags::WIDE_CONT) {
continue; continue;
} }
if cell.flags.contains(Flags::BLINK) && !blink_on {
continue;
}
let (fg, _) = cell_colors(cell); let (fg, _) = cell_colors(cell);
let origin_x = x as i32 * m.width as i32; let origin_x = x as i32 * m.width as i32;
let cell_top = y as i32 * m.height as i32;
if cell.c != ' ' { if cell.c != ' ' {
self.draw_glyph( self.draw_glyph(&mut canvas, cell.c, cell_style(cell), origin_x, row_top, fg);
&mut canvas,
cell.c,
cell_style(cell),
origin_x,
cell_top,
fg,
);
}
draw_decorations(&mut canvas, cell, origin_x, cell_top, m, fg);
} }
draw_decorations(&mut canvas, cell, origin_x, row_top, m, fg);
} }
// The cursor belongs to the live screen; hide it while scrolled back. // The cursor belongs to the live screen; hide it while scrolled back.
if grid.view_at_bottom() { if grid.view_at_bottom() && grid.cursor().1 == y {
self.draw_cursor(&mut canvas, grid, m, focused); self.draw_cursor(&mut canvas, grid, m, focused, blink_on);
} }
} }
/// Draw the cursor: a solid block/underline/beam when focused, a hollow /// Draw the cursor: a solid block/underline/beam when focused, a hollow
/// outline when not. /// 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) { fn draw_cursor(
if !grid.cursor_visible() { &mut self,
canvas: &mut Canvas,
grid: &Grid,
m: CellMetrics,
focused: bool,
blink_on: bool,
) {
if !grid.cursor_visible() || (grid.cursor_blink() && !blink_on) {
return; return;
} }
let (cx, cy) = grid.cursor(); let (cx, cy) = grid.cursor();

View file

@ -473,11 +473,14 @@ impl Perform for Term {
}), }),
'q' if intermediates.first() == Some(&b'>') => self.report_version(), 'q' if intermediates.first() == Some(&b'>') => self.report_version(),
'q' if intermediates.first() == Some(&b' ') => { '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, 3 | 4 => CursorShape::Underline,
5 | 6 => CursorShape::Beam, 5 | 6 => CursorShape::Beam,
_ => CursorShape::Block, _ => 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), 'p' if intermediates.contains(&b'$') => self.report_mode(params, private),
'n' => self.device_status(params), 'n' => self.device_status(params),

View file

@ -9,12 +9,16 @@ use std::os::fd::OwnedFd;
use std::os::unix::process::ExitStatusExt; use std::os::unix::process::ExitStatusExt;
use std::process::ExitCode; use std::process::ExitCode;
use std::time::Duration;
use anyhow::Context; use anyhow::Context;
use calloop::generic::Generic; use calloop::generic::Generic;
use calloop::timer::{TimeoutAction, Timer};
use calloop::{EventLoop, Interest, LoopHandle, Mode, PostAction}; use calloop::{EventLoop, Interest, LoopHandle, Mode, PostAction};
use calloop_wayland_source::WaylandSource; use calloop_wayland_source::WaylandSource;
use crate::font::Fonts; use crate::font::Fonts;
use crate::grid::{Cell, CursorShape, Grid};
use crate::pty::Pty; use crate::pty::Pty;
use crate::render::Renderer; use crate::render::Renderer;
use crate::vt::Term; use crate::vt::Term;
@ -53,7 +57,10 @@ use smithay_client_toolkit::{
window::{Window, WindowConfigure, WindowDecorations, WindowHandler}, window::{Window, WindowConfigure, WindowDecorations, WindowHandler},
}, },
}, },
shm::{Shm, ShmHandler, slot::SlotPool}, shm::{
Shm, ShmHandler,
slot::{Buffer, SlotPool},
},
}; };
use wayland_client::{ use wayland_client::{
Connection, QueueHandle, Connection, QueueHandle,
@ -86,6 +93,55 @@ fn pick_mime(mimes: &[String]) -> Option<String> {
/// Max gap between clicks counted as a multi-click (ms). /// Max gap between clicks counted as a multi-click (ms).
const MULTI_CLICK_MS: u32 = 400; 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. /// Default window size in pixels before the compositor suggests one.
const DEFAULT_W: u32 = 800; const DEFAULT_W: u32 = 800;
const DEFAULT_H: u32 = 600; const DEFAULT_H: u32 = 600;
@ -159,22 +215,37 @@ pub fn run() -> anyhow::Result<ExitCode> {
title: None, title: None,
width: DEFAULT_W, width: DEFAULT_W,
height: DEFAULT_H, height: DEFAULT_H,
dirty: false, needs_draw: false,
frame_pending: false,
frames: Vec::new(),
buf_dims: (0, 0),
blink_on: true,
focused: true, focused: true,
exit: false, exit: false,
exit_code: ExitCode::SUCCESS, exit_code: ExitCode::SUCCESS,
}; };
// Block until an event (PTY output, input, configure) arrives - every // Toggle the blink phase on a timer so blinking text and cursors animate.
// repaint is driven by one, so there is nothing to do on a timer. 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 { while !app.exit {
event_loop event_loop
.dispatch(None, &mut app) .dispatch(None, &mut app)
.context("dispatch event loop")?; .context("dispatch event loop")?;
if app.dirty { app.flush();
app.draw();
app.dirty = false;
}
} }
Ok(app.exit_code) Ok(app.exit_code)
} }
@ -244,8 +315,16 @@ struct App {
title: Option<String>, title: Option<String>,
width: u32, width: u32,
height: u32, height: u32,
/// The grid changed and the window needs repainting. /// The grid changed and the window wants repainting on the next frame.
dirty: bool, 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). /// Whether the toplevel currently has keyboard focus (drives the cursor).
focused: bool, focused: bool,
exit: bool, exit: bool,
@ -341,7 +420,7 @@ impl App {
-page -page
}; };
session.term.scroll_view(delta); session.term.scroll_view(delta);
self.dirty = true; self.needs_draw = true;
} }
return; return;
} }
@ -355,7 +434,7 @@ impl App {
{ {
session.term.scroll_to_bottom(); session.term.scroll_to_bottom();
session.term.grid_mut().clear_selection(); session.term.grid_mut().clear_selection();
self.dirty = true; self.needs_draw = true;
if let Err(err) = write_all(session.pty.master(), &bytes) { if let Err(err) = write_all(session.pty.master(), &bytes) {
tracing::warn!("write key to pty: {err}"); tracing::warn!("write key to pty: {err}");
} }
@ -396,7 +475,7 @@ impl App {
_ => grid.start_selection(row, col), _ => grid.start_selection(row, col),
} }
self.selecting = true; self.selecting = true;
self.dirty = true; self.needs_draw = true;
} }
/// Pointer motion during a drag: extend the selection head. /// Pointer motion during a drag: extend the selection head.
@ -409,7 +488,7 @@ impl App {
}; };
if let Some(session) = self.session.as_mut() { if let Some(session) = self.session.as_mut() {
session.term.grid_mut().extend_selection(row, col); session.term.grid_mut().extend_selection(row, col);
self.dirty = true; self.needs_draw = true;
} }
} }
@ -534,7 +613,7 @@ impl App {
return; return;
}; };
session.term.scroll_to_bottom(); session.term.scroll_to_bottom();
self.dirty = true; self.needs_draw = true;
let bracketed = session.term.grid().bracketed_paste(); let bracketed = session.term.grid().bracketed_paste();
// Strip control bytes a terminal must never receive raw from a 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). // keep tab and newlines (CR is what the shell expects for Enter).
@ -575,7 +654,7 @@ impl App {
self.window self.window
.set_title(self.title.clone().unwrap_or_default()); .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 /// Recompute the grid size for the current window and tell the grid and the
@ -615,41 +694,106 @@ impl App {
self.exit = true; self.exit = true;
} }
/// Render the grid into a fresh buffer and present it. /// Present a frame if one is wanted and the compositor is ready for it.
fn draw(&mut self) { /// 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 { let Some(session) = self.session.as_ref() else {
return; return;
}; };
let (w, h) = (self.width, self.height); let grid = session.term.grid();
let stride = w as i32 * 4; let rows = grid.rows();
let cur: Vec<RowSnap> = (0..rows)
.map(|y| row_snap(grid, y, focused, blink_on))
.collect();
let (buffer, canvas) = // 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 match self
.pool .pool
.create_buffer(w as i32, h as i32, stride, wl_shm::Format::Argb8888) .create_buffer(w as i32, h as i32, stride, wl_shm::Format::Argb8888)
{ {
Ok(buf) => buf, Ok((buffer, _)) => {
self.frames.push(FrameBuf {
buffer,
rows: Vec::new(),
});
self.frames.len() - 1
}
Err(err) => { Err(err) => {
tracing::error!("allocate shm buffer: {err}"); tracing::error!("allocate shm buffer: {err}");
return; 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( // Rows that differ from what this buffer last showed (all, if fresh).
session.term.grid(), let prev = &self.frames[idx].rows;
canvas, let dirty: Vec<usize> = (0..rows)
w as usize, .filter(|&y| prev.get(y) != Some(&cur[y]))
h as usize, .collect();
self.focused, 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(); 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}"); tracing::error!("attach buffer: {err}");
return; 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.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( fn surface_enter(
&mut self, &mut self,
@ -716,7 +864,7 @@ impl WindowHandler for App {
} else { } else {
self.resize_grid(); self.resize_grid();
} }
self.draw(); self.needs_draw = true;
} }
} }
@ -811,7 +959,7 @@ impl KeyboardHandler for App {
) { ) {
self.serial = serial; self.serial = serial;
self.focused = true; self.focused = true;
self.dirty = true; self.needs_draw = true;
} }
fn leave( fn leave(
@ -823,7 +971,7 @@ impl KeyboardHandler for App {
_: u32, _: u32,
) { ) {
self.focused = false; self.focused = false;
self.dirty = true; self.needs_draw = true;
} }
fn press_key( fn press_key(
@ -936,7 +1084,7 @@ impl PointerHandler for App {
let delta = if raw < 0.0 { lines } else { -lines }; let delta = if raw < 0.0 { lines } else { -lines };
if let Some(session) = self.session.as_mut() { if let Some(session) = self.session.as_mut() {
session.term.scroll_view(delta); session.term.scroll_view(delta);
self.dirty = true; self.needs_draw = true;
} }
} }
_ => {} _ => {}