use std::collections::HashMap;
use bevy::prelude::*;
use bevy::sprite::Anchor;
use bevy::text::{ComputedTextBlock, TextBounds, TextColor, TextLayoutInfo};
use crate::pixels::{apply_tint, crop_image_rgba, is_white, scale_rgba};
use crate::sprite::{Geom, TEXT_Z_BIAS, Z_SPREAD};
use crate::term::{FitBox, TermSize};
use crate::{proto, write_stdout, KittyCamera, KittyConfig, KittySet};
const GLYPH_ID_BASE: u32 = 100_000;
struct GlyphImage {
img_id: u32,
}
struct GlyphSlot {
placement_id: u32,
cur_img_id: u32,
last_geom: Option<Geom>,
}
#[derive(Resource, Default)]
pub struct GlyphScene {
glyphs: HashMap<String, GlyphImage>,
slots: HashMap<(Entity, usize), GlyphSlot>,
next_img_id: u32,
next_placement_id: u32,
tick: u64,
term: Option<TermSize>,
}
impl GlyphScene {
fn alloc_img_id(&mut self) -> u32 {
self.next_img_id = self.next_img_id.max(GLYPH_ID_BASE) + 1;
self.next_img_id
}
fn alloc_placement_id(&mut self) -> u32 {
self.next_placement_id = self.next_placement_id.max(GLYPH_ID_BASE) + 1;
self.next_placement_id
}
pub fn glyph_count(&self) -> usize {
self.glyphs.len()
}
pub fn slot_count(&self) -> usize {
self.slots.len()
}
}
pub(crate) fn build(app: &mut App) {
app.init_resource::<GlyphScene>();
let system = render_glyphs
.in_set(KittySet::Render)
.after(bevy::sprite::update_text2d_layout);
#[cfg(feature = "ui")]
let system = system.after(bevy::ui::widget::text_system);
app.add_systems(PostUpdate, system);
}
#[allow(clippy::too_many_arguments)]
fn draw_glyph(
scene: &mut GlyphScene,
buf: &mut Vec<u8>,
images: &Assets<Image>,
fit: &FitBox,
glyph: &bevy::text::PositionedGlyph,
colour: &bevy::color::Srgba,
inv_sf: f32,
top_left: Vec2,
z: i32,
slot_key: (Entity, usize),
uploaded: &mut u32,
) -> bool {
let rect = glyph.atlas_info.rect;
let gw = rect.width().round().max(1.0) as u32;
let gh = rect.height().round().max(1.0) as u32;
let tw = (gw as f32 * inv_sf * fit.scale).round().max(1.0) as u32;
let th = (gh as f32 * inv_sf * fit.scale).round().max(1.0) as u32;
let key = glyph_key(
&format!("{:?}", glyph.atlas_info.texture),
rect.min.x,
rect.min.y,
gw,
gh,
colour,
tw,
th,
);
if !scene.glyphs.contains_key(&key) {
let Some(atlas) = images.get(glyph.atlas_info.texture) else {
return false; };
let x0 = rect.min.x.round().max(0.0) as u32;
let y0 = rect.min.y.round().max(0.0) as u32;
let Some(bitmap) = crop_image_rgba(atlas, Some(URect::new(x0, y0, x0 + gw, y0 + gh)))
else {
return false;
};
let mut rgba = bitmap.rgba;
if glyph.atlas_info.is_alpha_mask || !is_white(colour) {
apply_tint(&mut rgba, colour);
}
let (rgba, w, h) = if (bitmap.w, bitmap.h) == (tw, th) {
(rgba, bitmap.w, bitmap.h)
} else {
(scale_rgba(&rgba, bitmap.w, bitmap.h, tw, th), tw, th)
};
let img_id = scene.alloc_img_id();
proto::transmit_rgba(buf, img_id, w, h, &rgba);
*uploaded += 1;
scene.glyphs.insert(key.clone(), GlyphImage { img_id });
}
let glyph_img_id = scene.glyphs[&key].img_id;
let (row, col, xoff, yoff) = fit.map(top_left.x, top_left.y);
let geom = Geom {
row,
col,
xoff,
yoff,
z,
cols: 0,
rows: 0,
};
if !scene.slots.contains_key(&slot_key) {
let placement_id = scene.alloc_placement_id();
scene.slots.insert(
slot_key,
GlyphSlot {
placement_id,
cur_img_id: 0,
last_geom: None,
},
);
}
let (placement_id, prev_img_id, img_changed, geom_changed) = {
let s = &scene.slots[&slot_key];
(
s.placement_id,
s.cur_img_id,
s.cur_img_id != glyph_img_id,
s.last_geom != Some(geom),
)
};
if img_changed && prev_img_id != 0 {
proto::delete_placement(buf, prev_img_id, placement_id);
}
let emitted = img_changed || geom_changed;
if emitted {
proto::cursor_to(buf, row, col);
proto::place_scaled(buf, glyph_img_id, placement_id, z, 0, 0, xoff, yoff);
}
if let Some(s) = scene.slots.get_mut(&slot_key) {
s.cur_img_id = glyph_img_id;
s.last_geom = Some(geom);
}
emitted
}
fn section_colour(
block: &ComputedTextBlock,
section_index: usize,
text_colors: &Query<&TextColor>,
) -> bevy::color::Srgba {
let raw = block
.entities()
.get(section_index)
.map(|te| te.entity)
.and_then(|e| text_colors.get(e).ok())
.map(|tc| tc.0.to_srgba())
.unwrap_or(bevy::color::Srgba::WHITE);
let q = |v: f32| ((v.clamp(0.0, 1.0) * 15.0).round()) / 15.0;
bevy::color::Srgba::new(q(raw.red), q(raw.green), q(raw.blue), q(raw.alpha))
}
#[allow(clippy::type_complexity)]
pub fn render_glyphs(
mut scene: ResMut<GlyphScene>,
config: Res<KittyConfig>,
images: Res<Assets<Image>>,
camera_q: Query<(&Camera, &GlobalTransform), With<KittyCamera>>,
texts: Query<(
Entity,
&TextLayoutInfo,
&ComputedTextBlock,
&TextBounds,
&Anchor,
&GlobalTransform,
// NOT ViewVisibility. See the module docs: culling never marks Text2d
// visible headless, so every string would silently vanish.
&InheritedVisibility,
)>,
#[cfg(feature = "ui")] ui_texts: Query<(
Entity,
&TextLayoutInfo,
&ComputedTextBlock,
&bevy::ui::ComputedNode,
&bevy::ui::UiGlobalTransform,
&InheritedVisibility,
Option<&bevy::ui::CalculatedClip>,
)>,
text_colors: Query<&TextColor>,
) {
scene.tick += 1;
if scene.term.is_none() || scene.tick.is_multiple_of(120) {
scene.term = Some(TermSize::query(config.terminal_size));
}
let fit = FitBox::compute(&scene.term.unwrap(), config.virtual_size);
let Ok((camera, cam_xf)) = camera_q.single() else {
return;
};
let mut buf: Vec<u8> = Vec::new();
let mut seen: Vec<(Entity, usize)> = Vec::new();
let mut placed = 0u32;
let mut n_text = 0u32;
let mut n_invisible = 0u32;
let mut n_no_glyphs = 0u32;
let mut n_glyphs = 0u32;
let mut uploaded = 0u32;
for (entity, layout, block, bounds, anchor, xf, vis) in texts.iter() {
n_text += 1;
if !vis.get() {
n_invisible += 1;
continue;
}
if layout.glyphs.is_empty() {
n_no_glyphs += 1;
continue;
}
n_glyphs += layout.glyphs.len() as u32;
let inv_sf = layout.scale_factor.recip();
let size = Vec2::new(
bounds.width.unwrap_or(layout.size.x),
bounds.height.unwrap_or(layout.size.y),
);
let top_left = (Anchor::TOP_LEFT.0 - anchor.as_vec()) * size;
let base = *xf
* GlobalTransform::from_translation(top_left.extend(0.0))
* GlobalTransform::from_scale(Vec3::new(inv_sf, -inv_sf, 1.0));
let base_z = (xf.translation().z * Z_SPREAD) as i32 + TEXT_Z_BIAS;
let mut cur_section = usize::MAX;
let mut cur_color = bevy::color::Srgba::WHITE;
for (gi, glyph) in layout.glyphs.iter().enumerate() {
if glyph.section_index != cur_section {
cur_color = section_colour(block, glyph.section_index, &text_colors);
cur_section = glyph.section_index;
}
let world = base * GlobalTransform::from_translation(glyph.position.extend(0.0));
let Ok(vp) = camera.world_to_viewport(cam_xf, world.translation()) else {
continue;
};
let rect = glyph.atlas_info.rect;
let vw = rect.width().round().max(1.0) * inv_sf;
let vh = rect.height().round().max(1.0) * inv_sf;
let top_left = Vec2::new(vp.x - vw * 0.5, vp.y - vh * 0.5);
let slot_key = (entity, gi);
seen.push(slot_key);
if draw_glyph(
&mut scene,
&mut buf,
&images,
&fit,
glyph,
&cur_color,
inv_sf,
top_left,
base_z,
slot_key,
&mut uploaded,
) {
placed += 1;
}
}
}
#[cfg(feature = "ui")]
for (entity, layout, block, node, xf, vis, clip) in ui_texts.iter() {
n_text += 1;
if !vis.get() {
n_invisible += 1;
continue;
}
if layout.glyphs.is_empty() || node.is_empty() {
n_no_glyphs += 1;
continue;
}
n_glyphs += layout.glyphs.len() as u32;
let inv_sf = node.inverse_scale_factor;
let centre = xf.translation;
let content = node.content_box();
let origin = centre + content.min;
let ui_z = crate::ui::UI_Z_BASE + crate::ui::UI_TEXT_Z_BIAS;
let mut cur_section = usize::MAX;
let mut cur_color = bevy::color::Srgba::WHITE;
for (gi, glyph) in layout.glyphs.iter().enumerate() {
if glyph.section_index != cur_section {
cur_color = section_colour(block, glyph.section_index, &text_colors);
cur_section = glyph.section_index;
}
let rect = glyph.atlas_info.rect;
let gw = rect.width().round().max(1.0);
let gh = rect.height().round().max(1.0);
let centre_px = origin + glyph.position;
if let Some(clip) = clip {
if !clip.clip.contains(centre_px) {
continue;
}
}
let top_left = (centre_px - Vec2::new(gw * 0.5, gh * 0.5)) * inv_sf;
let slot_key = (entity, gi);
seen.push(slot_key);
if draw_glyph(
&mut scene,
&mut buf,
&images,
&fit,
glyph,
&cur_color,
inv_sf,
top_left,
ui_z,
slot_key,
&mut uploaded,
) {
placed += 1;
}
}
}
let gone: Vec<(Entity, usize)> = scene
.slots
.keys()
.copied()
.filter(|k| !seen.contains(k))
.collect();
for k in gone {
if let Some(s) = scene.slots.remove(&k) {
if s.cur_img_id != 0 {
proto::delete_placement(&mut buf, s.cur_img_id, s.placement_id);
}
}
}
if scene.tick <= 3 || scene.tick.is_multiple_of(120) {
info!(
"[kitty] glyph scan #{}: {} text entities ({} invisible, {} without laid-out \
glyphs), {} glyphs total",
scene.tick, n_text, n_invisible, n_no_glyphs, n_glyphs
);
}
let bytes = buf.len();
if !buf.is_empty() && !write_stdout(&buf, "glyph") {
return;
}
if scene.tick <= 3 || scene.tick.is_multiple_of(120) {
info!(
"[kitty] glyph tick #{}: {} (re)placements, {} new glyph uploads, {} escape \
bytes, {} glyph images cached, {} live slots",
scene.tick,
placed,
uploaded,
bytes,
scene.glyphs.len(),
scene.slots.len()
);
}
}
#[allow(clippy::too_many_arguments)]
fn glyph_key(
texture: &str,
rx: f32,
ry: f32,
gw: u32,
gh: u32,
color: &bevy::color::Srgba,
tw: u32,
th: u32,
) -> String {
format!(
"{texture}|{rx},{ry},{gw},{gh}|{:.3},{:.3},{:.3},{:.3}|{tw}x{th}",
color.red, color.green, color.blue, color.alpha
)
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::color::Srgba;
#[test]
fn glyph_ids_never_collide_with_the_sprite_or_frame_bands() {
let mut scene = GlyphScene::default();
let id = scene.alloc_img_id();
assert!(id > GLYPH_ID_BASE, "{id}");
assert!(id > 1000, "glyph id {id} would collide with sprite ids");
}
#[test]
fn the_same_letter_in_the_same_colour_and_size_shares_one_key() {
let a = glyph_key("atlas0", 4.0, 8.0, 5, 7, &Srgba::WHITE, 30, 42);
let b = glyph_key("atlas0", 4.0, 8.0, 5, 7, &Srgba::WHITE, 30, 42);
assert_eq!(a, b);
}
#[test]
fn colour_and_target_size_both_split_the_cache() {
let base = glyph_key("atlas0", 4.0, 8.0, 5, 7, &Srgba::WHITE, 30, 42);
let recoloured = glyph_key("atlas0", 4.0, 8.0, 5, 7, &Srgba::RED, 30, 42);
let resized = glyph_key("atlas0", 4.0, 8.0, 5, 7, &Srgba::WHITE, 60, 84);
assert_ne!(base, recoloured);
assert_ne!(base, resized);
}
#[test]
fn different_atlas_rects_are_different_glyphs() {
let a = glyph_key("atlas0", 4.0, 8.0, 5, 7, &Srgba::WHITE, 30, 42);
let b = glyph_key("atlas0", 12.0, 8.0, 5, 7, &Srgba::WHITE, 30, 42);
assert_ne!(a, b);
}
#[test]
fn glyph_placements_carry_no_cell_span() {
let geom = Geom {
row: 2,
col: 66,
xoff: 1,
yoff: 20,
z: 5_100_000,
cols: 0,
rows: 0,
};
let mut buf = Vec::new();
proto::place_scaled(
&mut buf, 100_001, 100_001, geom.z, geom.cols, geom.rows, geom.xoff, geom.yoff,
);
let s = String::from_utf8(buf).unwrap();
assert!(
!s.contains(",c="),
"glyph placement must not size by cells: {s}"
);
assert!(
!s.contains(",r="),
"glyph placement must not size by cells: {s}"
);
assert!(s.contains("X=1"));
assert!(s.contains("Y=20"));
}
#[test]
fn text_sorts_above_y_sorted_world_sprites() {
let y_sort_max = (1.02_f32 * Z_SPREAD) as i32;
let text_at_zero = (0.0_f32 * Z_SPREAD) as i32 + TEXT_Z_BIAS;
assert!(
text_at_zero > y_sort_max,
"text at z=0 ({text_at_zero}) must beat the y-sort band ({y_sort_max})"
);
}
#[test]
fn a_high_z_overlay_still_draws_over_text_which_is_correct() {
let grade = (100.0_f32 * Z_SPREAD) as i32;
let text_at_zero = TEXT_Z_BIAS;
assert!(
grade > text_at_zero,
"a z=100 overlay ({grade}) is expected to sit above text ({text_at_zero})"
);
}
}