brewery
notashelf /
c8430ae78729f9fb2693b74e091bcb2aa6e898ab

beer

public

A terminal worth pouring time into

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

Commit c8430ae78729

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 20:29 UTC

unverified4 files changed+563-41
beer: animate graphics images and display unicode placeholders

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I20f09b602ea49b0605f019835e8f46546a6a6964
diff --git a/crates/beer/src/graphics/mod.rs b/crates/beer/src/graphics/mod.rsindex 973178e..3addaf4 100644--- a/crates/beer/src/graphics/mod.rs+++ b/crates/beer/src/graphics/mod.rs@@ -22,13 +22,106 @@ const MAX_IMAGE_BYTES: usize = 64 * 1024 * 1024; /// Cap on accumulated direct-transmission base64 across chunks. const MAX_TRANSMIT_BYTES: usize = 96 * 1024 * 1024; -/// A decoded image, normalized to 8-bit RGBA.+/// Freshly decoded pixels before they become an image or animation frame:+/// `width * height * 4` bytes of row-major, non-premultiplied RGBA.+#[derive(Clone, Debug)]+struct Pixels {+    width: u32,+    height: u32,+    rgba: Vec<u8>,+}++/// One animation frame: its RGBA pixels and the gap, in milliseconds, until the+/// terminal advances to the next frame.+#[derive(Clone, Debug)]+struct Frame {+    rgba: Vec<u8>,+    gap_ms: u32,+}++/// A decoded image: one or more frames plus playback state. A still image is a+/// single frame; the protocol's animation commands append, edit, and compose+/// further frames and drive which one is current. #[derive(Clone, Debug)] pub struct Image {     pub width: u32,     pub height: u32,-    /// `width * height * 4` bytes, row-major, non-premultiplied RGBA.-    pub rgba: Vec<u8>,+    /// At least one frame; `frames[0]` is the root (frame 1 in the protocol).+    frames: Vec<Frame>,+    /// Index of the frame currently shown.+    current: usize,+    /// Whether the terminal is advancing frames on their gaps.+    playing: bool,+    /// In loading mode the animation waits at the last frame for more frames+    /// rather than looping (`s=2`).+    loading: bool,+    /// Remaining loops; `None` loops forever, `Some(0)` stops at the last frame.+    loops_left: Option<u32>,+    /// Milliseconds accumulated toward the current frame's gap.+    accum_ms: u32,+}++impl Image {+    fn from_pixels(p: Pixels) -> Self {+        Self {+            width: p.width,+            height: p.height,+            frames: vec![Frame {+                rgba: p.rgba,+                gap_ms: 0,+            }],+            current: 0,+            playing: false,+            loading: false,+            loops_left: None,+            accum_ms: 0,+        }+    }++    /// The pixels of the frame currently shown, for the renderer to composite.+    pub fn current_rgba(&self) -> &[u8] {+        &self.frames[self.current.min(self.frames.len() - 1)].rgba+    }++    /// Whether this image has more than one frame and is actively playing.+    fn is_animating(&self) -> bool {+        self.playing && self.frames.len() > 1+    }++    /// Advance playback by `dt_ms`; returns whether the current frame changed.+    fn advance(&mut self, dt_ms: u32) -> bool {+        if !self.is_animating() {+            return false;+        }+        self.accum_ms += dt_ms;+        // A stored gap of zero (the root frame's default) plays at the standard+        // 40ms; gapless frames are stored as 1ms.+        let gap = match self.frames[self.current].gap_ms {+            0 => 40,+            g => g,+        };+        if self.accum_ms < gap {+            return false;+        }+        self.accum_ms -= gap;+        if self.current + 1 < self.frames.len() {+            self.current += 1;+        } else if self.loading {+            // Wait at the last frame for more frames to arrive.+            return false;+        } else {+            match &mut self.loops_left {+                Some(0) => {+                    self.playing = false;+                    return false;+                }+                Some(n) => *n -= 1,+                None => {}+            }+            self.current = 0;+        }+        true+    } }  /// One on-screen placement of an image: the cell rectangle it occupies and the@@ -127,12 +220,10 @@ impl Graphics {         match cmd.action {             Action::Delete => self.delete(cmd),             Action::Put => self.put(cmd, cell_px),-            Action::Animate | Action::Frame | Action::Compose => {-                // Animation lands in a later pass; accept silently so a client-                // probing for it does not get spurious errors.-                Outcome::default()-            }-            // Transmit / TransmitAndDisplay / Query all assemble pixel data.+            Action::Animate => self.animate(cmd),+            Action::Compose => self.compose(cmd),+            // Frame data is assembled like image data, then routed to add_frame.+            // Transmit / TransmitAndDisplay / Query assemble pixel data too.             _ => self.transmit(cmd, payload, cell_px),         }     }@@ -142,7 +233,7 @@ impl Graphics {     /// displays.     fn transmit(&mut self, cmd: GraphicsCommand, payload: &[u8], cell_px: (u32, u32)) -> Outcome {         // Continuation chunk: append to the in-flight transmission.-        if self.pending.is_some() && cmd.action == Action::Transmit && payload_is_chunk(&cmd) {+        if self.pending.is_some() && is_continuation(&cmd) {             return self.accumulate(cmd, payload, cell_px);         }         if cmd.more {@@ -181,13 +272,17 @@ impl Graphics {     }      fn finalize_b64(&mut self, cmd: GraphicsCommand, b64: &[u8], cell_px: (u32, u32)) -> Outcome {-        match self.load_image(&cmd, b64) {-            Ok(image) => {+        match self.load_pixels(&cmd, b64) {+            Ok(pixels) => {                 if cmd.action == Action::Query {                     // Verify only: report success, store nothing.                     return respond(&cmd, "OK");                 }-                let id = self.store(&cmd, image);+                if cmd.action == Action::Frame {+                    // Animation frame data for an existing image.+                    return self.add_frame(&cmd, pixels);+                }+                let id = self.store(&cmd, pixels);                 if cmd.action == Action::TransmitAndDisplay {                     let mut out = self.display(id, &cmd, cell_px);                     out.response = respond(&cmd, "OK").response;@@ -200,8 +295,8 @@ impl Graphics {         }     } -    /// Decode the transmitted payload into an RGBA [`Image`].-    fn load_image(&self, cmd: &GraphicsCommand, b64: &[u8]) -> Result<Image, String> {+    /// Decode the transmitted payload into RGBA [`Pixels`].+    fn load_pixels(&self, cmd: &GraphicsCommand, b64: &[u8]) -> Result<Pixels, String> {         let raw = base64_decode(b64).ok_or("EINVAL: bad base64 payload")?;         // For non-direct mediums the payload is the path / shared-memory name.         let bytes = match cmd.medium {@@ -216,9 +311,9 @@ impl Graphics {         decode(cmd, bytes)     } -    /// Store an image under its id (or number, or an auto id), replacing any-    /// existing image with that id. Returns the id used.-    fn store(&mut self, cmd: &GraphicsCommand, image: Image) -> u32 {+    /// Store pixels as a new still image under its id (or number, or an auto id),+    /// replacing any existing image with that id. Returns the id used.+    fn store(&mut self, cmd: &GraphicsCommand, pixels: Pixels) -> u32 {         let id = if cmd.id != 0 {             cmd.id         } else if cmd.number != 0 {@@ -229,7 +324,7 @@ impl Graphics {         } else {             self.alloc_id()         };-        self.images.insert(id, image);+        self.images.insert(id, Image::from_pixels(pixels));         id     } @@ -239,6 +334,131 @@ impl Graphics {         id     } +    /// Add or edit an animation frame (`a=f`). The decoded `pixels` are composed+    /// onto a base canvas - a chosen base frame (`c`) or transparent black -+    /// inside the destination rectangle `(x, y)` sized to the data, then stored+    /// as a new frame or, with `r`, used to replace frame `r`.+    fn add_frame(&mut self, cmd: &GraphicsCommand, pixels: Pixels) -> Outcome {+        let Some(id) = self.resolve_id(cmd) else {+            return respond_error(cmd, "ENOENT: no such image");+        };+        let Some(img) = self.images.get_mut(&id) else {+            return respond_error(cmd, "ENOENT: no such image");+        };+        let canvas_len = img.width as usize * img.height as usize * 4;+        // Base canvas: an existing frame's pixels, or transparent black.+        let mut canvas = match img.frames.get(cmd.c.wrapping_sub(1) as usize) {+            Some(f) if cmd.c != 0 => f.rgba.clone(),+            _ => vec![0u8; canvas_len],+        };+        compose_rect(+            &mut canvas,+            img.width,+            img.height,+            &pixels,+            (cmd.x, cmd.y),+            cmd.cap_x == 1,+        );+        let gap = frame_gap(cmd.z);+        if cmd.r != 0 {+            match img.frames.get_mut(cmd.r as usize - 1) {+                Some(f) => {+                    f.rgba = canvas;+                    f.gap_ms = gap;+                }+                None => return respond_error(cmd, "ENOENT: no such frame"),+            }+        } else {+            img.frames.push(Frame {+                rgba: canvas,+                gap_ms: gap,+            });+        }+        respond(cmd, "OK")+    }++    /// Compose a rectangle of one frame onto another (`a=c`): copy a `w` by `h`+    /// region from source frame `r` at `(x, y)` onto destination frame `c` at+    /// `(X, Y)`, alpha-blending unless `C=1` requests a plain overwrite.+    fn compose(&mut self, cmd: GraphicsCommand) -> Outcome {+        let Some(id) = self.resolve_id(&cmd) else {+            return respond_error(&cmd, "ENOENT: no such image");+        };+        let Some(img) = self.images.get_mut(&id) else {+            return respond_error(&cmd, "ENOENT: no such image");+        };+        let (src, dst) = (cmd.r as usize, cmd.c as usize);+        if src == 0 || dst == 0 || src > img.frames.len() || dst > img.frames.len() {+            return respond_error(&cmd, "ENOENT: no such frame");+        }+        let (iw, ih) = (img.width, img.height);+        let w = if cmd.w == 0 { iw } else { cmd.w };+        let h = if cmd.h == 0 { ih } else { cmd.h };+        let source = img.frames[src - 1].rgba.clone();+        let overwrite = cmd.cursor_policy == 1;+        compose_frames(+            &mut img.frames[dst - 1].rgba,+            iw,+            ih,+            &source,+            (cmd.x, cmd.y),+            (cmd.cap_x, cmd.cap_y),+            (w, h),+            overwrite,+        );+        respond(&cmd, "OK")+    }++    /// Control playback (`a=a`): set the current frame (`c`), the run state+    /// (`s`: stop / loading / loop), and the loop count (`v`).+    fn animate(&mut self, cmd: GraphicsCommand) -> Outcome {+        let Some(id) = self.resolve_id(&cmd) else {+            return respond_error(&cmd, "ENOENT: no such image");+        };+        let Some(img) = self.images.get_mut(&id) else {+            return respond_error(&cmd, "ENOENT: no such image");+        };+        if cmd.c != 0 {+            img.current = (cmd.c as usize - 1).min(img.frames.len() - 1);+            img.accum_ms = 0;+        }+        // For a=a the `s` key is the run state and `v` the loop count; the parser+        // stores them under the width/height fields they share.+        match cmd.width {+            1 => img.playing = false,+            2 => {+                img.playing = true;+                img.loading = true;+            }+            3 => {+                img.playing = true;+                img.loading = false;+            }+            _ => {}+        }+        match cmd.height {+            0 => {}+            1 => img.loops_left = None,+            n => img.loops_left = Some(n - 1),+        }+        respond(&cmd, "OK")+    }++    /// Advance every playing animation by `dt_ms`; returns whether any image's+    /// current frame changed (and the screen therefore needs repainting).+    pub fn tick(&mut self, dt_ms: u32) -> bool {+        let mut changed = false;+        for img in self.images.values_mut() {+            changed |= img.advance(dt_ms);+        }+        changed+    }++    /// Whether any stored image is currently playing a multi-frame animation.+    pub fn is_animating(&self) -> bool {+        self.images.values().any(Image::is_animating)+    }+     /// Display an already-stored image (`a=p`).     fn put(&mut self, cmd: GraphicsCommand, cell_px: (u32, u32)) -> Outcome {         let id = self.resolve_id(&cmd);@@ -293,15 +513,18 @@ impl Graphics {                 z: cmd.z,             },         );+        // A virtual placement (`U=1`) reserves geometry for Unicode-placeholder+        // cells the application prints itself; it stamps no cells of its own.+        let grid_op = (!cmd.virtual_placement).then_some(GridOp::Place {+            image: id,+            placement: placement_id,+            cols,+            rows,+            keep_cursor: cmd.cursor_policy == 1,+        });         Outcome {             response: None,-            grid_op: Some(GridOp::Place {-                image: id,-                placement: placement_id,-                cols,-                rows,-                keep_cursor: cmd.cursor_policy == 1,-            }),+            grid_op,         }     } @@ -367,10 +590,17 @@ impl Graphics {     } } -/// Whether a continuation chunk carries only the `m`/`q` keys (no fresh action),-/// i.e. it belongs to the in-flight transmission rather than starting a new one.-fn payload_is_chunk(cmd: &GraphicsCommand) -> bool {-    cmd.action == Action::Transmit && cmd.id == 0 && cmd.number == 0 && cmd.format == Format::Rgba+/// Whether a command is a continuation chunk of the in-flight transmission+/// rather than a fresh command: it carries no id, number, or geometry, only the+/// `m`/`q` (and, for frames, `a=f`) keys. Both image and animation-frame+/// transmissions chunk this way.+fn is_continuation(cmd: &GraphicsCommand) -> bool {+    matches!(cmd.action, Action::Transmit | Action::Frame)+        && cmd.id == 0+        && cmd.number == 0+        && cmd.format == Format::Rgba+        && cmd.width == 0+        && cmd.height == 0 }  /// Choose the cell rectangle for a placement. Explicit `c`/`r` win; a missing@@ -396,15 +626,15 @@ fn cell_rect(c: u32, r: u32, src_w: u32, src_h: u32, cell_w: u32, cell_h: u32) -     (cols as usize, rows as usize) } -/// Decode transmitted bytes into RGBA per the command's format.-fn decode(cmd: &GraphicsCommand, bytes: Vec<u8>) -> Result<Image, String> {+/// Decode transmitted bytes into RGBA [`Pixels`] per the command's format.+fn decode(cmd: &GraphicsCommand, bytes: Vec<u8>) -> Result<Pixels, String> {     match cmd.format {         Format::Png => {             let img = image::load_from_memory(&bytes).map_err(|e| format!("EINVAL: {e}"))?;             let rgba = img.to_rgba8();             let (width, height) = (rgba.width(), rgba.height());             check_size(width, height)?;-            Ok(Image {+            Ok(Pixels {                 width,                 height,                 rgba: rgba.into_raw(),@@ -416,7 +646,7 @@ fn decode(cmd: &GraphicsCommand, bytes: Vec<u8>) -> Result<Image, String> {             if bytes.len() < want {                 return Err("EINVAL: RGBA data smaller than s*v*4".into());             }-            Ok(Image {+            Ok(Pixels {                 width: cmd.width,                 height: cmd.height,                 rgba: bytes[..want].to_vec(),@@ -433,7 +663,7 @@ fn decode(cmd: &GraphicsCommand, bytes: Vec<u8>) -> Result<Image, String> {                 rgba.extend_from_slice(chunk);                 rgba.push(0xff);             }-            Ok(Image {+            Ok(Pixels {                 width: cmd.width,                 height: cmd.height,                 rgba,@@ -460,6 +690,91 @@ fn push_capped(buf: &mut Vec<u8>, data: &[u8], cap: usize) {     buf.extend_from_slice(&data[..data.len().min(room)]); } +/// The stored gap for a frame from its `z` value: zero keeps the default (played+/// as 40ms), a negative value is gapless (stored as 1ms, advanced at once), a+/// positive value is taken as milliseconds.+fn frame_gap(z: i32) -> u32 {+    match z {+        0 => 0,+        n if n < 0 => 1,+        n => n as u32,+    }+}++/// Composite a `(src.width, src.height)` patch onto a `(cw, ch)` canvas with its+/// top-left at `off`, clipped to the canvas, blending unless `overwrite`.+fn compose_rect(+    canvas: &mut [u8],+    cw: u32,+    ch: u32,+    src: &Pixels,+    off: (u32, u32),+    overwrite: bool,+) {+    let (ox, oy) = off;+    for sy in 0..src.height {+        let dy = oy + sy;+        if dy >= ch {+            break;+        }+        for sx in 0..src.width {+            let dx = ox + sx;+            if dx >= cw {+                break;+            }+            let si = ((sy * src.width + sx) * 4) as usize;+            let di = ((dy * cw + dx) * 4) as usize;+            blend_into(&mut canvas[di..di + 4], &src.rgba[si..si + 4], overwrite);+        }+    }+}++/// Copy a `(w, h)` region of `src` (an `iw` by `ih` frame) at `soff` onto `dst`+/// (also `iw` by `ih`) at `doff`, clipped to the image, blending unless+/// `overwrite`.+#[allow(clippy::too_many_arguments)]+fn compose_frames(+    dst: &mut [u8],+    iw: u32,+    ih: u32,+    src: &[u8],+    soff: (u32, u32),+    doff: (u32, u32),+    size: (u32, u32),+    overwrite: bool,+) {+    let ((sx0, sy0), (dx0, dy0), (w, h)) = (soff, doff, size);+    for row in 0..h {+        let (sy, dy) = (sy0 + row, dy0 + row);+        if sy >= ih || dy >= ih {+            break;+        }+        for col in 0..w {+            let (sx, dx) = (sx0 + col, dx0 + col);+            if sx >= iw || dx >= iw {+                break;+            }+            let si = ((sy * iw + sx) * 4) as usize;+            let di = ((dy * iw + dx) * 4) as usize;+            blend_into(&mut dst[di..di + 4], &src[si..si + 4], overwrite);+        }+    }+}++/// Composite one straight-alpha RGBA pixel `src` onto `dst` in place, either+/// replacing it (`overwrite`) or alpha-blending.+fn blend_into(dst: &mut [u8], src: &[u8], overwrite: bool) {+    if overwrite || src[3] == 255 {+        dst.copy_from_slice(src);+        return;+    }+    let (a, inv) = (u32::from(src[3]), u32::from(255 - src[3]));+    for i in 0..3 {+        dst[i] = ((u32::from(src[i]) * a + u32::from(dst[i]) * inv) / 255) as u8;+    }+    dst[3] = (a + u32::from(dst[3]) * inv / 255).min(255) as u8;+}+ /// zlib-inflate `o=z` payloads. fn inflate(bytes: &[u8]) -> Result<Vec<u8>, String> {     let mut out = Vec::new();@@ -597,7 +912,7 @@ mod tests {         assert_eq!(out.response.as_deref(), Some(&b"\x1b_Gi=1;OK\x1b\\"[..]));         let img = g.image(1).expect("stored");         assert_eq!((img.width, img.height), (2, 2));-        assert_eq!(img.rgba.len(), 16);+        assert_eq!(img.current_rgba().len(), 16);     }      #[test]@@ -609,8 +924,8 @@ mod tests {         g.handle(cmd, &b64(&px), (8, 16));         let img = g.image(5).unwrap();         assert_eq!(-            img.rgba,-            vec![0x10, 0x10, 0x10, 0xff, 0x10, 0x10, 0x10, 0xff]+            img.current_rgba(),+            [0x10, 0x10, 0x10, 0xff, 0x10, 0x10, 0x10, 0xff]         );     } @@ -653,7 +968,7 @@ mod tests {         };         let out = g.handle(last, b, (8, 16));         assert_eq!(out.response.as_deref(), Some(&b"\x1b_Gi=9;OK\x1b\\"[..]));-        assert_eq!(g.image(9).unwrap().rgba.len(), 16);+        assert_eq!(g.image(9).unwrap().current_rgba().len(), 16);     }      #[test]@@ -700,4 +1015,77 @@ mod tests {         assert!(matches!(out.grid_op, Some(GridOp::Clear(ClearSpec::All))));         assert!(g.image(1).is_none(), "uppercase delete frees data");     }++    #[test]+    fn animation_frames_advance_on_tick() {+        let mut g = Graphics::new();+        // Root frame: a 1x1 red pixel.+        g.handle(+            rgba_cmd(1, 1, 1, Action::Transmit),+            &b64(&[0xff, 0, 0, 0xff]),+            (8, 16),+        );+        // Append a second frame (a=f): a 1x1 green pixel, default 40ms gap.+        let frame = GraphicsCommand {+            action: Action::Frame,+            format: Format::Rgba,+            width: 1,+            height: 1,+            id: 1,+            ..Default::default()+        };+        g.handle(frame, &b64(&[0, 0xff, 0, 0xff]), (8, 16));+        // Run looping (a=a, s=3).+        let run = GraphicsCommand {+            action: Action::Animate,+            id: 1,+            width: 3,+            ..Default::default()+        };+        g.handle(run, &[], (8, 16));+        assert!(g.is_animating());+        assert_eq!(+            &g.image(1).unwrap().current_rgba()[..4],+            &[0xff, 0, 0, 0xff]+        );+        // A short tick does not cross the 40ms gap; a full one advances a frame.+        assert!(!g.tick(10));+        assert!(g.tick(40));+        assert_eq!(+            &g.image(1).unwrap().current_rgba()[..4],+            &[0, 0xff, 0, 0xff]+        );+    }++    #[test]+    fn animate_selects_current_frame() {+        let mut g = Graphics::new();+        g.handle(+            rgba_cmd(1, 1, 1, Action::Transmit),+            &b64(&[1, 1, 1, 0xff]),+            (8, 16),+        );+        let frame = GraphicsCommand {+            action: Action::Frame,+            format: Format::Rgba,+            width: 1,+            height: 1,+            id: 1,+            ..Default::default()+        };+        g.handle(frame, &b64(&[2, 2, 2, 0xff]), (8, 16));+        // a=a,c=2 makes the second frame current without playing.+        let select = GraphicsCommand {+            action: Action::Animate,+            id: 1,+            c: 2,+            ..Default::default()+        };+        g.handle(select, &[], (8, 16));+        assert_eq!(&g.image(1).unwrap().current_rgba()[..4], &[2, 2, 2, 0xff]);+        assert!(+            !g.is_animating(),+            "selecting a frame does not start playback"+        );+    } }diff --git a/crates/beer/src/render.rs b/crates/beer/src/render.rsindex 34a64ae..e7850f4 100644--- a/crates/beer/src/render.rs+++ b/crates/beer/src/render.rs@@ -7,10 +7,11 @@  use std::num::NonZeroU16; +use beer_protocols::graphics::{PLACEHOLDER, diacritic_value}; use beer_protocols::text_size::{HAlign, VAlign};  use crate::font::{CellMetrics, Fonts, Glyph, GlyphData, Style};-use crate::grid::{Cell, CursorShape, Flags, Grid, Underline};+use crate::grid::{Cell, Color, CursorShape, Flags, Grid, Underline}; use crate::theme::{Plane, Rgb, Theme};  /// A mutable view over a BGRA pixel buffer.@@ -246,6 +247,11 @@ impl Renderer {             if cell.flags.contains(Flags::BLINK) && !blink_on {                 continue;             }+            // A Unicode placeholder cell shows an image slice, drawn in its own+            // pass below; never paint the placeholder code point as a glyph.+            if cell.c == PLACEHOLDER {+                continue;+            }             let (fg, _) = cell_colors(cell, theme);             let origin_x = pad_x + x as i32 * m.width as i32;             let style = cell_style(cell);@@ -309,6 +315,8 @@ impl Renderer {             m,             |z| z >= 0,         );+        // Unicode-placeholder image cells.+        draw_placeholders(&mut canvas, frame.images, cells, cols, pad_x, row_top, m);          // The cursor belongs to the live screen; hide it while scrolled back.         if grid.view_at_bottom() && grid.cursor().1 == y {@@ -659,6 +667,71 @@ fn draw_image_cells(     } } +/// Composite the Unicode-placeholder cells of one row. A placeholder cell holds+/// `U+10EEEE`, its image id in the foreground colour, and its row/column as+/// combining diacritics; a missing row/column/id-byte is inherited from the+/// placeholder to the left, the way the protocol specifies.+fn draw_placeholders(+    canvas: &mut Canvas,+    images: &crate::graphics::Graphics,+    cells: &[Cell],+    cols: usize,+    pad_x: i32,+    row_top: i32,+    m: CellMetrics,+) {+    // The left neighbour's (row, column, id high byte, foreground), for cells+    // that omit diacritics and continue the run.+    let mut prev: Option<(u32, u32, u32, Color)> = None;+    for (x, cell) in cells.iter().take(cols).enumerate() {+        if cell.c != PLACEHOLDER {+            prev = None;+            continue;+        }+        let Some(base_id) = placeholder_id(cell.fg) else {+            prev = None;+            continue;+        };+        let marks: Vec<char> = cell.combining.as_deref().unwrap_or("").chars().collect();+        let d0 = marks.first().copied().and_then(diacritic_value);+        let d1 = marks.get(1).copied().and_then(diacritic_value);+        let d2 = marks.get(2).copied().and_then(diacritic_value);+        let same_fg = prev.is_some_and(|p| p.3 == cell.fg);+        let (row, col, msb) = match (d0, d1, d2, prev) {+            // No diacritics: continue the previous cell's row, next column.+            (None, None, None, Some(p)) if same_fg => (p.0, p.1 + 1, p.2),+            // Only the row: same row continues, next column.+            (Some(r), None, None, Some(p)) if same_fg && p.0 == r => (r, p.1 + 1, p.2),+            // Row and column given, id byte inherited from an adjacent run.+            (Some(r), Some(c), None, Some(p)) if same_fg && p.0 == r && p.1 + 1 == c => (r, c, p.2),+            // Otherwise take whatever was given, defaulting the rest to zero.+            (r, c, msb, _) => (r.unwrap_or(0), c.unwrap_or(0), msb.unwrap_or(0)),+        };+        prev = Some((row, col, msb, cell.fg));++        let id = base_id | (msb << 24);+        let Some(p) = images.placement(id, 0) else {+            continue;+        };+        let Some(img) = images.image(p.image) else {+            continue;+        };+        let origin_x = pad_x + x as i32 * m.width as i32;+        blit_image_cell(canvas, img, p, col as i32, row as i32, origin_x, row_top, m);+    }+}++/// The image id a placeholder cell's foreground colour encodes: an indexed+/// colour is the id directly, a truecolor is its packed 24-bit value. A default+/// foreground carries no id.+fn placeholder_id(fg: Color) -> Option<u32> {+    match fg {+        Color::Indexed(n) => Some(u32::from(n)),+        Color::Rgb(r, g, b) => Some(u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b)),+        Color::Default => None,+    }+}+ /// Composite one cell's slice of an image placement. The placement's source /// rectangle is scaled to its full cell-pixel area; this cell shows the /// sub-rectangle for its `(dx, dy)`, sampled nearest-neighbour and alpha-blended.@@ -701,7 +774,7 @@ fn blit_image_cell(                 continue;             }             let i = ((sy * iw + sx) * 4) as usize;-            let px = &img.rgba[i..i + 4];+            let px = &img.current_rgba()[i..i + 4];             canvas.blend_rgba(origin_x + cx, row_top + cy, [px[0], px[1], px[2], px[3]]);         }     }diff --git a/crates/beer/src/vt/mod.rs b/crates/beer/src/vt/mod.rsindex 581f42f..a64563c 100644--- a/crates/beer/src/vt/mod.rs+++ b/crates/beer/src/vt/mod.rs@@ -278,6 +278,18 @@ impl Term {         &self.graphics     } +    /// Advance any playing graphics-protocol animations by `dt_ms`; returns+    /// whether a frame changed and the screen needs repainting.+    pub fn animation_tick(&mut self, dt_ms: u32) -> bool {+        self.graphics.tick(dt_ms)+    }++    /// Whether any image is currently playing a multi-frame animation, so the+    /// front-end knows to keep ticking quickly.+    pub fn is_animating(&self) -> bool {+        self.graphics.is_animating()+    }+     /// The working directory last reported by the shell (OSC 7), if any.     pub fn cwd(&self) -> Option<&str> {         self.cwd.as_deref()@@ -671,6 +683,24 @@ mod tests {     }      #[test]+    fn kitty_unicode_placeholder_virtual_placement() {+        // Transmit + a virtual placement (U=1): no cells are stamped, but the+        // placement is registered for placeholder cells to reference.+        let mut t = Term::new(20, 4);+        let px = beer_protocols::codec::base64_encode(&[0xff; 4]);+        let seq = format!("\x1b_Ga=T,U=1,i=7,c=1,r=1,f=32,s=1,v=1;{px}\x1b\\");+        feed(&mut t, seq.as_bytes());+        assert!(+            t.grid().cell(0, 0).image.is_none(),+            "virtual placement stamps nothing"+        );+        assert!(t.graphics().placement(7, 0).is_some());+        // The app prints a placeholder carrying image id 7 in its fg colour.+        feed(&mut t, "\x1b[38;5;7m\u{10EEEE}\u{0305}\u{0305}".as_bytes());+        assert_eq!(t.grid().cell(0, 0).c, '\u{10EEEE}');+    }++    #[test]     fn apc_does_not_disturb_surrounding_text() {         // Text, then a graphics query APC, then more text: the text is intact and         // the APC did not leak bytes into the grid.diff --git a/crates/beer/src/wayland/mod.rs b/crates/beer/src/wayland/mod.rsindex e896314..8ffe255 100644--- a/crates/beer/src/wayland/mod.rs+++ b/crates/beer/src/wayland/mod.rs@@ -145,6 +145,11 @@ const AUTOSCROLL_MS: u64 = 40; /// How long the visual bell inverts the screen. const FLASH_MS: u64 = 80; +/// Frame interval while a graphics-protocol animation is playing; when none is,+/// the timer idles at a slower beat so it does not wake the loop needlessly.+const ANIM_MS: u64 = 40;+const ANIM_IDLE_MS: u64 = 250;+ /// Fallback window size in pixels if the configured geometry yields nothing. const DEFAULT_W: u32 = 800; const DEFAULT_H: u32 = 600;@@ -312,6 +317,32 @@ pub fn run(config: Config, config_path: Option<std::path::PathBuf>) -> anyhow::R         tracing::warn!("register blink timer: {err}");     } +    // Advance graphics-protocol animations. A frame change does not alter the+    // grid cells (only the image pixels), so the buffer ring is dropped to force+    // a repaint of the image rows. The timer slows to an idle beat when nothing+    // is animating.+    let anim = Timer::from_duration(Duration::from_millis(ANIM_IDLE_MS));+    let anim_registered = event_loop+        .handle()+        .insert_source(anim, |_, _, app: &mut App| {+            let (changed, animating) = match app.session.as_mut() {+                Some(session) => (+                    session.term.animation_tick(ANIM_MS as u32),+                    session.term.is_animating(),+                ),+                None => (false, false),+            };+            if changed {+                app.frames.clear();+                app.needs_draw = true;+            }+            let next = if animating { ANIM_MS } else { ANIM_IDLE_MS };+            TimeoutAction::ToDuration(Duration::from_millis(next))+        });+    if let Err(err) = anim_registered {+        tracing::warn!("register animation timer: {err}");+    }+     // SIGUSR1 reloads the config in place.     match calloop::signals::Signals::new(&[calloop::signals::Signal::SIGUSR1]) {         Ok(signals) => {