use std::collections::HashMap;
use bevy::image::{TextureAtlas, TextureAtlasLayout};
use bevy::prelude::*;
use bevy::sprite::{Anchor, Sprite};
use crate::pixels::{apply_tint, flip_rgba, is_white, solid_rgba, Bitmap, PixelRequest};
use crate::term::{FitBox, TermSize};
use crate::{proto, write_stdout, KittyCamera, KittyConfig, KittySet};
pub const Z_SPREAD: f32 = 100_000.0;
pub const TEXT_Z_BIAS: i32 = 1_000_000;
const SPRITE_IMG_ID_BASE: u32 = 1000;
const SOLID_TILE_PX: u32 = 1;
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) struct Geom {
pub(crate) row: u16,
pub(crate) col: u16,
pub(crate) xoff: u32,
pub(crate) yoff: u32,
pub(crate) z: i32,
pub(crate) cols: u32,
pub(crate) rows: u32,
}
struct CachedBitmap {
w: u32,
h: u32,
img_id: u32,
}
struct EntityRender {
placement_id: u32,
cur_img_id: u32,
last_geom: Option<Geom>,
}
#[derive(Resource, Default)]
pub struct SpriteScene {
bitmaps: HashMap<String, CachedBitmap>,
entities: HashMap<Entity, EntityRender>,
next_img_id: u32,
next_placement_id: u32,
term: Option<TermSize>,
tick: u64,
}
impl SpriteScene {
fn alloc_img_id(&mut self) -> u32 {
self.next_img_id = self.next_img_id.max(SPRITE_IMG_ID_BASE) + 1;
self.next_img_id
}
fn alloc_placement_id(&mut self) -> u32 {
self.next_placement_id += 1;
self.next_placement_id
}
pub fn bitmap_count(&self) -> usize {
self.bitmaps.len()
}
pub fn entity_count(&self) -> usize {
self.entities.len()
}
pub fn placement_ids_are_unique(&self) -> bool {
let mut seen = std::collections::HashSet::new();
self.entities
.values()
.all(|er| seen.insert(er.placement_id))
}
}
pub(crate) fn build(app: &mut App) {
app.init_resource::<SpriteScene>();
app.add_systems(PostUpdate, render_sprites.in_set(KittySet::Render));
}
fn quantise_tint(c: bevy::color::Srgba) -> (bevy::color::Srgba, String) {
let q = |v: f32| (v.clamp(0.0, 1.0) * 15.0).round() as u8;
let (qr, qg, qb, qa) = (q(c.red), q(c.green), q(c.blue), q(c.alpha));
let tint = bevy::color::Srgba::new(
qr as f32 / 15.0,
qg as f32 / 15.0,
qb as f32 / 15.0,
qa as f32 / 15.0,
);
let key = if is_white(&tint) {
String::new()
} else {
format!("@{qr:x}{qg:x}{qb:x}{qa:x}")
};
(tint, key)
}
pub(crate) fn image_label(assets: &AssetServer, id: bevy::asset::AssetId<Image>) -> String {
match assets.get_path(id) {
Some(p) => p
.path()
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_else(|| p.path().display().to_string()),
None => format!("gen:{}", id_index(id)),
}
}
fn id_index(id: bevy::asset::AssetId<Image>) -> String {
let s = format!("{id:?}");
let after = |marker: &str| {
s.split(marker).nth(1).map(|rest| {
rest.trim()
.trim_end_matches('}')
.split(&[',', ' ', '}'][..])
.next()
.unwrap_or(rest)
.to_string()
})
};
if let Some(index) = after("index: ") {
return index;
}
if let Some(uuid) = after("uuid: ") {
return uuid.split('-').next().unwrap_or(&uuid).to_string();
}
s.chars()
.rev()
.take(12)
.collect::<String>()
.chars()
.rev()
.collect()
}
fn solid_draw_size(custom_size: Vec2) -> Vec2 {
Vec2::new(
custom_size.x.abs().round().max(1.0),
custom_size.y.abs().round().max(1.0),
)
}
#[allow(clippy::type_complexity)]
pub fn render_sprites(
mut scene: ResMut<SpriteScene>,
config: Res<KittyConfig>,
assets: Res<AssetServer>,
atlas_layouts: Res<Assets<TextureAtlasLayout>>,
images: Res<Assets<Image>>,
camera_q: Query<(&Camera, &GlobalTransform), With<KittyCamera>>,
sprites: Query<
(
Entity,
&Sprite,
&GlobalTransform,
&ViewVisibility,
Option<&Anchor>,
),
Without<KittyCamera>,
>,
) {
scene.tick += 1;
if scene.term.is_none() || scene.tick.is_multiple_of(120) {
scene.term = Some(TermSize::query(config.terminal_size));
}
let term = scene.term.unwrap();
let fit = FitBox::compute(&term, config.virtual_size);
let Ok((camera, cam_xf)) = camera_q.single() else {
if scene.tick <= 3 {
warn!(
"[kitty] no KittyCamera found (tick {}), so nothing can be placed. \
Put the KittyCamera marker on your Camera2d.",
scene.tick
);
}
return;
};
let mut buf: Vec<u8> = Vec::new();
let mut seen: Vec<Entity> = Vec::new();
let mut emitted = 0u32;
let mut uploaded = 0u32;
for (entity, sprite, xf, vis, anchor) in sprites.iter() {
if !vis.get() {
continue;
}
let raw_tint = sprite.color.to_srgba();
if raw_tint.alpha <= 0.001 {
continue;
}
let (tint, tint_key) = quantise_tint(raw_tint);
if tint.alpha <= 0.001 {
continue;
}
let flip_key = match (sprite.flip_x, sprite.flip_y) {
(false, false) => "",
(true, false) => "|fx",
(false, true) => "|fy",
(true, true) => "|fxy",
};
let (cell_rect, cell_suffix) = match &sprite.texture_atlas {
Some(TextureAtlas { layout, index }) => {
let Some(layout) = atlas_layouts.get(layout) else {
continue; };
let Some(rect) = layout.textures.get(*index) else {
continue;
};
(Some(*rect), format!("#{index}"))
}
None => (None, String::new()),
};
let is_default_handle = sprite.image.id() == Handle::<Image>::default().id();
let has_image = !is_default_handle
&& (assets.get_path(sprite.image.id()).is_some()
|| images.get(&sprite.image).is_some_and(|i| i.data.is_some()));
let solid_draw = if has_image {
None
} else {
let Some(size) = sprite.custom_size else {
continue; };
Some(solid_draw_size(size))
};
let cache_key = if solid_draw.is_some() {
format!("solid{tint_key}")
} else {
format!(
"{}{}{}{}",
image_label(&assets, sprite.image.id()),
cell_suffix,
tint_key,
flip_key
)
};
if !scene.bitmaps.contains_key(&cache_key) {
let resolved: Option<Bitmap> = if solid_draw.is_some() {
let n = SOLID_TILE_PX;
Bitmap::new(solid_rgba(&tint, n, n), n, n)
} else {
let req = PixelRequest {
image: sprite.image.id(),
atlas_cell: cell_rect,
images: &images,
assets: &assets,
};
config.pixel_source.pixels(&req)
};
let Some(Bitmap { mut rgba, w, h }) = resolved else {
continue;
};
if solid_draw.is_none() && !is_white(&tint) {
apply_tint(&mut rgba, &tint);
}
if sprite.flip_x || sprite.flip_y {
rgba = flip_rgba(&rgba, w, h, sprite.flip_x, sprite.flip_y);
}
let img_id = scene.alloc_img_id();
proto::transmit_rgba(&mut buf, img_id, w, h, &rgba);
uploaded += 1;
debug!("[kitty] uploaded bitmap '{cache_key}' ({w}x{h}) as img {img_id}");
scene
.bitmaps
.insert(cache_key.clone(), CachedBitmap { w, h, img_id });
}
let (bw, bh, img_id) = {
let b = &scene.bitmaps[&cache_key];
(b.w, b.h, b.img_id)
};
if !scene.entities.contains_key(&entity) {
let placement_id = scene.alloc_placement_id();
scene.entities.insert(
entity,
EntityRender {
placement_id,
cur_img_id: 0,
last_geom: None,
},
);
}
let (placement_id, prev_img_id, img_changed) = {
let er = &scene.entities[&entity];
(er.placement_id, er.cur_img_id, er.cur_img_id != img_id)
};
let world_pos = xf.translation();
let Ok(vp) = camera.world_to_viewport(cam_xf, world_pos) else {
continue; };
let scale = xf.scale().truncate();
let base_size = match solid_draw {
Some(size) => size,
None => sprite
.custom_size
.unwrap_or(Vec2::new(bw as f32, bh as f32)),
};
let draw_size = base_size * Vec2::new(scale.x.abs(), scale.y.abs());
let a = anchor.map(|x| x.as_vec()).unwrap_or(Vec2::ZERO);
let tl_vx = vp.x - draw_size.x * 0.5 - a.x * draw_size.x;
let tl_vy = vp.y - draw_size.y * 0.5 + a.y * draw_size.y;
let (row, col, xoff, yoff) = fit.map(tl_vx, tl_vy);
let (cols, rows) = fit.span_cells(
draw_size.x.round().max(1.0) as u32,
draw_size.y.round().max(1.0) as u32,
);
let z = (world_pos.z * Z_SPREAD) as i32;
let geom = Geom {
row,
col,
xoff,
yoff,
z,
cols,
rows,
};
seen.push(entity);
if img_changed && prev_img_id != 0 {
proto::delete_placement(&mut buf, prev_img_id, placement_id);
}
let geom_changed = scene.entities[&entity].last_geom != Some(geom);
if img_changed || geom_changed {
proto::cursor_to(&mut buf, row, col);
proto::place_scaled(&mut buf, img_id, placement_id, z, cols, rows, xoff, yoff);
emitted += 1;
}
if let Some(er) = scene.entities.get_mut(&entity) {
er.cur_img_id = img_id;
er.last_geom = Some(geom);
}
}
let gone: Vec<Entity> = scene
.entities
.keys()
.copied()
.filter(|e| !seen.contains(e))
.collect();
for e in gone {
if let Some(er) = scene.entities.remove(&e) {
if er.cur_img_id != 0 {
proto::delete_placement(&mut buf, er.cur_img_id, er.placement_id);
}
}
}
let bytes = buf.len();
if !buf.is_empty() && !write_stdout(&buf, "sprite") {
return;
}
if scene.tick <= 3 || scene.tick.is_multiple_of(120) {
info!(
"[kitty] sprite tick #{}: {} (re)placements, {} new uploads, {} escape bytes, \
{} bitmaps cached, {} live entities",
scene.tick,
emitted,
uploaded,
bytes,
scene.bitmaps.len(),
scene.entities.len()
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn image_ids_start_above_frame_modes_reserved_id() {
let mut scene = SpriteScene::default();
let first = scene.alloc_img_id();
assert!(
first > 1,
"first sprite img id {first} must clear frame mode's id 1"
);
assert_eq!(first, SPRITE_IMG_ID_BASE + 1);
assert_eq!(scene.alloc_img_id(), SPRITE_IMG_ID_BASE + 2);
}
#[test]
fn placement_ids_are_never_reused() {
let mut scene = SpriteScene::default();
let ids: Vec<u32> = (0..100).map(|_| scene.alloc_placement_id()).collect();
let unique: std::collections::HashSet<u32> = ids.iter().copied().collect();
assert_eq!(unique.len(), ids.len(), "placement ids must be unique");
}
#[test]
fn a_fade_collapses_to_a_handful_of_cache_keys() {
use bevy::color::Srgba;
let keys: std::collections::HashSet<String> = (0..=100)
.map(|i| {
let a = i as f32 / 100.0;
quantise_tint(Srgba::new(1.0, 1.0, 1.0, a)).1
})
.collect();
assert!(
keys.len() <= 17,
"101 fade steps should collapse to at most 17 keys, got {}",
keys.len()
);
}
#[test]
fn quantised_colour_matches_the_key_that_names_it() {
use bevy::color::Srgba;
let (tint_a, key_a) = quantise_tint(Srgba::new(0.51, 0.2, 0.9, 1.0));
let (tint_b, key_b) = quantise_tint(Srgba::new(0.515, 0.2, 0.9, 1.0));
assert_eq!(key_a, key_b, "near-identical colours should share a key");
assert_eq!(tint_a, tint_b, "and therefore share exact pixels");
}
#[test]
fn a_generated_image_key_is_short_not_a_type_name() {
let label = id_index(bevy::asset::AssetId::<Image>::default());
assert!(
label.len() <= 12,
"expected a short id, got {} chars: {label}",
label.len()
);
assert!(
!label.contains("bevy_image"),
"the type name leaked into the key: {label}"
);
}
#[test]
fn white_tint_produces_an_empty_key_suffix() {
use bevy::color::Srgba;
assert_eq!(quantise_tint(Srgba::WHITE).1, "");
assert_ne!(quantise_tint(Srgba::new(1.0, 0.0, 0.0, 1.0)).1, "");
}
#[test]
fn a_solid_tile_costs_one_pixel_however_big_it_draws() {
assert_eq!(SOLID_TILE_PX, 1);
let bytes = (SOLID_TILE_PX as usize).pow(2) * 4;
assert_eq!(bytes, 4, "a solid upload should be 4 bytes, got {bytes}");
assert_eq!(
solid_draw_size(Vec2::new(3200.0, 1800.0)),
Vec2::new(3200.0, 1800.0)
);
}
#[test]
fn solid_tiles_are_cached_by_colour_alone_not_by_size() {
use bevy::color::Srgba;
let key_of = |c: Srgba| format!("solid{}", quantise_tint(c).1);
let red = Srgba::new(1.0, 0.0, 0.0, 1.0);
assert_eq!(key_of(red), key_of(red));
assert_ne!(key_of(red), key_of(Srgba::new(0.0, 0.0, 1.0, 1.0)));
}
#[test]
fn a_negative_or_zero_custom_size_still_draws_at_least_one_pixel() {
assert_eq!(solid_draw_size(Vec2::ZERO), Vec2::ONE);
assert_eq!(
solid_draw_size(Vec2::new(-64.0, -32.0)),
Vec2::new(64.0, 32.0)
);
}
#[test]
fn z_spread_keeps_a_narrow_band_distinguishable() {
let near = (1.0_f32 * Z_SPREAD) as i32;
let far = (1.018_f32 * Z_SPREAD) as i32;
assert!(far - near > 1000, "band spans only {} z values", far - near);
assert!(
TEXT_Z_BIAS > far,
"text bias {TEXT_Z_BIAS} must clear world max {far}"
);
}
}