forked from NotAShelf/beer
beer: animate graphics images and display unicode placeholders
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I20f09b602ea49b0605f019835e8f46546a6a6964
This commit is contained in:
parent
ec591fe105
commit
c8430ae787
4 changed files with 563 additions and 41 deletions
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
Outcome {
|
||||
response: None,
|
||||
grid_op: Some(GridOp::Place {
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
@ -670,6 +682,24 @@ mod tests {
|
|||
assert!(resp.windows(2).any(|w| w == b"OK"), "expected OK response");
|
||||
}
|
||||
|
||||
#[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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue