clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit c8430ae78729
tarballunverified4 files changed+563-41
@@ -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"+ );+ } }@@ -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]]); } }@@ -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.@@ -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) => {