diff --git a/src/grid.rs b/src/grid.rs index 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 { diff --git a/src/render.rs b/src/render.rs index 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(); + 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 - // 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); - } + // 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.rs index 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.rs index 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 { /// 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, + /// `(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, +} + +/// 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 { 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, 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, + /// 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 grid = session.term.grid(); + let rows = grid.rows(); + let cur: Vec = (0..rows) + .map(|y| row_snap(grid, y, focused, blink_on)) + .collect(); + + // Reuse a buffer the compositor has released, else grow the ring. let stride = w as i32 * 4; - - 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; + 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 = (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, _: &wl_surface::WlSurface, _: u32) {} + fn frame(&mut self, _: &Connection, _: &QueueHandle, _: &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; } } _ => {}