Files
ghostty-rs/crates/ghostty-terminal/src/kitty/graphics_storage.rs
T
ddidderr 126da77a37 style: apply nightly rustfmt import grouping across the workspace
rustfmt.toml sets group_imports, imports_granularity and imports_layout,
which are nightly-only; agents formatted with stable rustfmt, which
ignored them. This is a formatting-only pass with `cargo +nightly fmt`.

Test Plan:
- cargo clippy --workspace --all-targets -- -D warnings
- cargo test --workspace
2026-10-12 05:03:37 +02:00

1377 lines
44 KiB
Rust

//! Kitty graphics image + placement storage
//! (`ZIG/src/terminal/kitty/graphics_storage.zig`).
//!
//! # Deviations from Zig
//!
//! * Zig's `ImageStorage` methods take a `*Screen`/`*Terminal`. In Rust the
//! storage lives *inside* the `Screen` (`screen.kitty_images`), so taking
//! `&mut Screen` would alias. The methods instead take the
//! [`PageList`] (`&mut screen.pages`, disjoint field borrow) and, where the
//! Zig code reads terminal state (`t.cols`, `t.width_px`, cursor, ...), a
//! small [`TerminalInfo`] value.
//! * `add_image`, `add_placement` and `set_limit` also take the `PageList`
//! so that placements that are evicted / replaced have their tracked pin
//! released (the Zig code leaks those tracked pins).
//! * `Image` lookups return references instead of copies.
//! * Placements are stored in a `BTreeMap` so iteration order is
//! deterministic.
use std::collections::{BTreeMap, HashMap};
use ghostty_vt::kitty::graphics_command::Delete;
use super::graphics_image::{Image, LoadingImage, Rect};
use crate::{
page_list::{Overflow, PageList, TrackedPin},
point::{Coordinate, Point},
size::CellCountInt,
};
/// The subset of terminal state that the storage needs to read.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TerminalInfo {
/// `terminal.cols` / `terminal.rows`.
pub cols: CellCountInt,
pub rows: CellCountInt,
/// `terminal.width_px` / `terminal.height_px`.
pub width_px: u32,
pub height_px: u32,
/// `terminal.screens.active.cursor.x` / `.y`.
pub cursor_x: CellCountInt,
pub cursor_y: CellCountInt,
}
impl TerminalInfo {
/// Snapshot the relevant state of a terminal.
pub fn from_terminal(t: &crate::terminal::Terminal) -> Self {
let cursor = &t.screens.active().cursor;
TerminalInfo {
cols: t.cols,
rows: t.rows,
width_px: t.width_px,
height_px: t.height_px,
cursor_x: cursor.x,
cursor_y: cursor.y,
}
}
}
/// Errors from storage operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum Error {
#[error("out of memory")]
OutOfMemory,
}
/// The tag of a placement ID.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum PlacementIdTag {
Internal,
External,
}
/// A placement ID with its tag (`packed struct { tag, id }` in Zig).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PlacementId {
pub tag: PlacementIdTag,
pub id: u32,
}
/// Every placement is uniquely identified by the image ID and the
/// placement ID. If an image ID isn't specified it is assumed to be 0.
/// Likewise, if a placement ID isn't specified it is assumed to be 0.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PlacementKey {
pub image_id: u32,
pub placement_id: PlacementId,
}
impl PlacementKey {
pub fn external(image_id: u32, id: u32) -> Self {
PlacementKey {
image_id,
placement_id: PlacementId {
tag: PlacementIdTag::External,
id,
},
}
}
pub fn internal(image_id: u32, id: u32) -> Self {
PlacementKey {
image_id,
placement_id: PlacementId {
tag: PlacementIdTag::Internal,
id,
},
}
}
}
/// Where a placement lives.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Location {
/// Exactly placed on a screen pin (tracked in the screen's PageList).
Pin(TrackedPin),
/// Virtual placement (U=1) for unicode placeholders.
Virtual,
}
/// The pixel size of a placement.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CalculatedSize {
pub width: u32,
pub height: u32,
}
/// The size of a placement in grid cells.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GridSize {
pub cols: u32,
pub rows: u32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Placement {
/// The location where this placement should be drawn.
pub location: Location,
/// Offset of the x/y from the top-left of the cell.
pub x_offset: u32,
pub y_offset: u32,
/// Source rectangle for the image to pull from
pub source_x: u32,
pub source_y: u32,
pub source_width: u32,
pub source_height: u32,
/// The columns/rows this image occupies.
pub columns: u32,
pub rows: u32,
/// The z-index for this placement.
pub z: i32,
}
impl Placement {
pub fn new(location: Location) -> Self {
Placement {
location,
x_offset: 0,
y_offset: 0,
source_x: 0,
source_y: 0,
source_width: 0,
source_height: 0,
columns: 0,
rows: 0,
z: 0,
}
}
/// Release resources (the tracked pin) held by this placement.
pub fn deinit(&self, pages: &mut PageList) {
match self.location {
Location::Pin(p) => pages.untrack_pin(p),
Location::Virtual => {}
}
}
/// Calculates the size of this placement's image in pixels,
/// taking in to account the specified rows and columns.
pub fn calculated_size(&self, image: &Image, t: &TerminalInfo) -> CalculatedSize {
// Height / width of the image in px.
let width = if self.source_width > 0 {
self.source_width
} else {
image.width
};
let height = if self.source_height > 0 {
self.source_height
} else {
image.height
};
// If we don't have any specified cols or rows then the placement
// should be the native size of the image, and doesn't need to be
// re-scaled.
if self.columns == 0 && self.rows == 0 {
return CalculatedSize { width, height };
}
// We calculate the size of a cell so that we can multiply
// it by the specified cols/rows to get the correct px size.
//
// We assume that the width is divided evenly by the column
// count and the height by the row count, because it should be.
let cell_width: u32 = t.width_px / u32::from(t.cols);
let cell_height: u32 = t.height_px / u32::from(t.rows);
let width_f64 = f64::from(width);
let height_f64 = f64::from(height);
// If we have a specified cols AND rows then we calculate
// the width and height from them directly, we don't need
// to adjust for aspect ratio.
if self.columns > 0 && self.rows > 0 {
return CalculatedSize {
width: cell_width * self.columns,
height: cell_height * self.rows,
};
}
// Either the columns or the rows were specified, but not both,
// so we need to calculate the other one based on the aspect ratio.
// If only the columns were specified, we determine
// the height of the image based on the aspect ratio.
if self.columns > 0 {
let aspect = height_f64 / width_f64;
let calc_width: u32 = cell_width * self.columns;
let calc_height = (f64::from(calc_width) * aspect).round() as u32;
return CalculatedSize {
width: calc_width,
height: calc_height,
};
}
// Otherwise, only the rows were specified, so we
// determine the width based on the aspect ratio.
let aspect = width_f64 / height_f64;
let calc_height: u32 = cell_height * self.rows;
let calc_width = (f64::from(calc_height) * aspect).round() as u32;
CalculatedSize {
width: calc_width,
height: calc_height,
}
}
/// Returns the size in grid cells that this placement takes up.
pub fn grid_size(&self, image: &Image, t: &TerminalInfo) -> GridSize {
// If we have a specified columns and rows then this is trivial.
if self.columns > 0 && self.rows > 0 {
return GridSize {
cols: self.columns,
rows: self.rows,
};
}
// Otherwise we calculate the pixel size, divide by
// cell size, and round up to the nearest integer.
let calc_size = self.calculated_size(image, t);
// NOTE: `div_ceil` can only fail if the cell size is 0, in such a
// case it seems safe to return 0 for this.
GridSize {
cols: div_ceil(
calc_size.width + self.x_offset,
t.width_px / u32::from(t.cols),
),
rows: div_ceil(
calc_size.height + self.y_offset,
t.height_px / u32::from(t.rows),
),
}
}
/// Returns a selection of the entire rectangle this placement
/// occupies within the screen. This can return None if the placement
/// doesn't have an associated rect (i.e. a virtual placement).
pub fn rect(&self, image: &Image, t: &TerminalInfo, pages: &PageList) -> Option<Rect> {
let grid_size = self.grid_size(image, t);
let handle = match self.location {
Location::Pin(p) => p,
Location::Virtual => return None,
};
let pin = pages.tracked_pin(handle)?;
// NOTE: Zig does `grid_size.rows - 1` which would underflow for a
// zero-row grid; we saturate instead.
let mut br = match pin.down_overflow(pages, grid_size.rows.saturating_sub(1) as usize)? {
Overflow::Offset(v) => v,
Overflow::Overflow { end, .. } => end,
};
br.x = std::cmp::min(
// We need to sub one here because the x value is
// one width already. So if the image is width "1"
// then we add zero to X because X itself is width 1.
u32::from(pin.x) + grid_size.cols.saturating_sub(1),
u32::from(t.cols) - 1,
) as CellCountInt;
Some(Rect {
top_left: pin,
bottom_right: br,
})
}
}
fn div_ceil(a: u32, b: u32) -> u32 {
if b == 0 { 0 } else { a.div_ceil(b) }
}
/// An image storage is associated with a terminal screen (i.e. main
/// screen, alt screen) and contains all the transmitted images and
/// placements.
#[derive(Debug)]
pub struct ImageStorage {
/// Dirty is set to true if placements or images change. This is
/// purely informational for the renderer and doesn't affect the
/// correctness of the program. The renderer must set this to false
/// if it cares about this value.
pub dirty: bool,
/// This is the next automatically assigned image ID. We start mid-way
/// through the u32 range to avoid collisions with buggy programs.
/// TODO: This isn't good enough, it's perfectly legal for programs
/// to use IDs in the latter half of the range and collisions
/// are not gracefully handled.
pub next_image_id: u32,
/// This is the next automatically assigned placement ID. This is never
/// user-facing so we can start at 0. This is 32-bits because we use
/// the same space for external placement IDs. We can start at zero
/// because any number is valid.
pub next_internal_placement_id: u32,
/// The set of images that are currently known.
pub images: HashMap<u32, Image>,
/// The set of placements for loaded images.
pub placements: BTreeMap<PlacementKey, Placement>,
/// Non-null if there is an in-progress loading image.
pub loading: Option<Box<LoadingImage>>,
/// The total bytes of image data that have been loaded and the limit.
/// If the limit is reached, the oldest images will be evicted to make
/// space. Unused images take priority.
pub total_bytes: usize,
pub total_limit: usize,
}
impl Default for ImageStorage {
fn default() -> Self {
ImageStorage {
dirty: false,
next_image_id: 2147483647,
next_internal_placement_id: 0,
images: HashMap::new(),
placements: BTreeMap::new(),
loading: None,
total_bytes: 0,
total_limit: 320 * 1000 * 1000, // 320MB
}
}
}
impl ImageStorage {
/// Release all images/placements. Placements' tracked pins are
/// released in `pages` (the screen's page list).
pub fn deinit(&mut self, pages: &mut PageList) {
self.loading = None;
self.images.clear();
self.clear_placements(pages);
}
/// Kitty image protocol is enabled if we have a non-zero limit.
pub fn enabled(&self) -> bool {
self.total_limit != 0
}
/// Sets the limit in bytes for the total amount of image data that
/// can be loaded. If this limit is lower, this will do an eviction
/// if necessary. If the value is zero, then Kitty image protocol will
/// be disabled.
pub fn set_limit(&mut self, pages: &mut PageList, limit: usize) -> Result<(), Error> {
// Special case disabling by quickly deleting all
if limit == 0 {
self.deinit(pages);
*self = ImageStorage::default();
}
// If we re lowering our limit, check if we need to evict.
if limit < self.total_bytes {
let req_bytes = self.total_bytes - limit;
log::info!("evicting images to lower limit, evicting={req_bytes}");
if !self.evict_image(pages, req_bytes) {
log::warn!("failed to evict enough images for required bytes");
}
}
self.total_limit = limit;
Ok(())
}
/// Add an already-loaded image to the storage. This will automatically
/// free any existing image with the same ID.
pub fn add_image(&mut self, pages: &mut PageList, img: Image) -> Result<(), Error> {
// If the image itself is over the limit, then error immediately
if img.data.len() > self.total_limit {
return Err(Error::OutOfMemory);
}
// If this would put us over the limit, then evict.
let total_bytes = self.total_bytes + img.data.len();
if total_bytes > self.total_limit {
let req_bytes = total_bytes - self.total_limit;
log::info!("evicting images to make space for {req_bytes} bytes");
if !self.evict_image(pages, req_bytes) {
log::warn!("failed to evict enough images for required bytes");
return Err(Error::OutOfMemory);
}
}
log::debug!("addImage image={:?}", img.without_data());
// Write our new image
let len = img.data.len();
if let Some(old) = self.images.insert(img.id, img) {
self.total_bytes -= old.data.len();
}
self.total_bytes += len;
self.dirty = true;
Ok(())
}
/// Add a placement for a given image. The caller must verify in advance
/// the image exists.
pub fn add_placement(
&mut self,
pages: &mut PageList,
image_id: u32,
placement_id: u32,
p: Placement,
) -> Result<(), Error> {
debug_assert!(self.images.contains_key(&image_id));
log::debug!("placement image_id={image_id} placement_id={placement_id} placement={p:?}");
// The important piece here is that the placement ID needs to
// be marked internal if it is zero. This allows multiple placements
// to be added for the same image. If it is non-zero, then it is
// an external placement ID and we can only have one placement
// per (image id, placement id) pair.
let key = if placement_id == 0 {
let id = self.next_internal_placement_id;
self.next_internal_placement_id = id.wrapping_add(1);
PlacementKey::internal(image_id, id)
} else {
PlacementKey::external(image_id, placement_id)
};
// (Zig leaks the old tracked pin if the key already exists.)
if let Some(old) = self.placements.insert(key, p) {
old.deinit(pages);
}
self.dirty = true;
Ok(())
}
fn clear_placements(&mut self, pages: &mut PageList) {
for p in self.placements.values() {
p.deinit(pages);
}
self.placements.clear();
}
/// Get an image by its ID. If the image doesn't exist, None is returned.
pub fn image_by_id(&self, image_id: u32) -> Option<&Image> {
self.images.get(&image_id)
}
/// Get an image by its number. If the image doesn't exist, return None.
pub fn image_by_number(&self, image_number: u32) -> Option<&Image> {
let mut newest: Option<&Image> = None;
for img in self.images.values() {
if img.number == image_number
&& newest.is_none_or(|n| img.transmit_time > n.transmit_time)
{
newest = Some(img);
}
}
newest
}
/// Remove (and release) the placement with the given key.
fn remove_placement(&mut self, pages: &mut PageList, key: &PlacementKey) {
if let Some(p) = self.placements.remove(key) {
p.deinit(pages);
}
}
/// Delete placements, images.
pub fn delete(&mut self, pages: &mut PageList, t: &TerminalInfo, cmd: Delete) {
match cmd {
Delete::All(delete_images) => {
let keys: Vec<PlacementKey> = self
.placements
.iter()
.filter(|(_, p)| matches!(p.location, Location::Pin(_)))
.map(|(k, _)| *k)
.collect();
for key in keys {
self.remove_placement(pages, &key);
if delete_images {
self.delete_if_unused(key.image_id);
}
}
if delete_images {
let ids: Vec<u32> = self.images.keys().copied().collect();
for id in ids {
self.delete_if_unused(id);
}
}
self.dirty = true;
}
Delete::Id {
delete,
image_id,
placement_id,
} => self.delete_by_id(pages, image_id, placement_id, delete),
Delete::Newest {
delete,
image_number,
placement_id,
} => {
let Some(img_id) = self.image_by_number(image_number).map(|i| i.id) else {
return;
};
self.delete_by_id(pages, img_id, placement_id, delete);
}
Delete::IntersectCursor(delete_images) => {
self.delete_intersecting(
pages,
t,
Point::Active(Coordinate {
x: t.cursor_x,
y: u32::from(t.cursor_y),
}),
delete_images,
None,
);
}
Delete::IntersectCell { delete, x, y } => {
if x == 0 || y == 0 {
log::warn!("delete intersect cell coords must be at least 1");
return;
}
let (Some(x), Some(y)) = (cast_cell(x - 1), cast_cell(y - 1)) else {
return;
};
self.delete_intersecting(
pages,
t,
Point::Active(Coordinate { x, y: u32::from(y) }),
delete,
None,
);
}
Delete::IntersectCellZ { delete, x, y, z } => {
if x == 0 || y == 0 {
log::warn!("delete intersect cell coords must be at least 1");
return;
}
let (Some(x), Some(y)) = (cast_cell(x - 1), cast_cell(y - 1)) else {
return;
};
self.delete_intersecting(
pages,
t,
Point::Active(Coordinate { x, y: u32::from(y) }),
delete,
Some(z),
);
}
Delete::Column { delete, x } => {
if x == 0 {
log::warn!("delete column must be greater than zero");
return;
}
let x = x - 1;
let keys: Vec<PlacementKey> = self.placements.keys().copied().collect();
for key in keys {
let Some(img) = self.images.get(&key.image_id) else {
continue;
};
let Some(rect) = self.placements[&key].rect(img, t, pages) else {
continue;
};
if u32::from(rect.top_left.x) <= x && u32::from(rect.bottom_right.x) >= x {
let img_id = img.id;
self.remove_placement(pages, &key);
if delete {
self.delete_if_unused(img_id);
}
}
}
// Mark dirty to force redraw
self.dirty = true;
}
Delete::Row { delete, y } => {
if y == 0 {
log::warn!("delete row must be greater than zero");
return;
}
// v.y is in active coords so we want to convert it to a pin
// so we can compare by page offsets.
let Some(y) = cast_cell(y - 1) else {
return;
};
let Some(target_pin) = pages.pin(Point::Active(Coordinate {
x: 0,
y: u32::from(y),
})) else {
return;
};
let keys: Vec<PlacementKey> = self.placements.keys().copied().collect();
for key in keys {
let Some(img) = self.images.get(&key.image_id) else {
continue;
};
let Some(rect) = self.placements[&key].rect(img, t, pages) else {
continue;
};
// We need to copy our pin to ensure we are at least at
// the top-left x.
let mut target_pin_copy = target_pin;
target_pin_copy.x = rect.top_left.x;
if target_pin_copy.is_between(pages, rect.top_left, rect.bottom_right) {
let img_id = img.id;
self.remove_placement(pages, &key);
if delete {
self.delete_if_unused(img_id);
}
}
}
// Mark dirty to force redraw
self.dirty = true;
}
Delete::Z { delete, z } => {
let keys: Vec<PlacementKey> = self
.placements
.iter()
// Virtual placeholders cannot delete by z according
// to the spec.
.filter(|(_, p)| matches!(p.location, Location::Pin(_)) && p.z == z)
.map(|(k, _)| *k)
.collect();
for key in keys {
self.remove_placement(pages, &key);
if delete {
self.delete_if_unused(key.image_id);
}
}
// Mark dirty to force redraw
self.dirty = true;
}
Delete::Range {
delete,
first,
last,
} => {
if first == 0 || last == 0 {
log::warn!("delete range values must be greater than zero");
return;
}
if first > last {
log::warn!(
"delete range 'x' ({first}) must be less than or equal to 'y' ({last})"
);
return;
}
let keys: Vec<PlacementKey> = self.placements.keys().copied().collect();
for key in keys {
// NOTE: this is `or` (not `and`) in the Zig source, which
// makes it match every placement. Preserved faithfully.
if key.image_id >= first || key.image_id <= last {
self.remove_placement(pages, &key);
if delete {
self.delete_if_unused(key.image_id);
}
}
}
// Mark dirty to force redraw
self.dirty = true;
}
// We don't support animation frames yet so they are successfully
// deleted!
Delete::AnimationFrames(_) => {}
}
}
fn delete_by_id(
&mut self,
pages: &mut PageList,
image_id: u32,
placement_id: u32,
delete_unused: bool,
) {
// If no placement, we delete all placements with the ID
if placement_id == 0 {
let keys: Vec<PlacementKey> = self
.placements
.keys()
.filter(|k| k.image_id == image_id)
.copied()
.collect();
for key in keys {
self.remove_placement(pages, &key);
}
} else {
self.remove_placement(pages, &PlacementKey::external(image_id, placement_id));
}
// If this is specified, then we also delete the image
// if it is no longer in use.
if delete_unused {
self.delete_if_unused(image_id);
}
// Mark dirty to force redraw
self.dirty = true;
}
/// Delete an image if it is unused.
fn delete_if_unused(&mut self, image_id: u32) {
if self.placements.keys().any(|k| k.image_id == image_id) {
return;
}
// If we get here, we can delete the image.
if let Some(img) = self.images.remove(&image_id) {
self.total_bytes -= img.data.len();
}
}
/// Deletes all placements intersecting a screen point. `filter_z`
/// restricts deletion to placements with that z-index.
fn delete_intersecting(
&mut self,
pages: &mut PageList,
t: &TerminalInfo,
p: Point,
delete_unused: bool,
filter_z: Option<i32>,
) {
// Convert our target point to a pin for comparison.
let Some(target_pin) = pages.pin(p) else {
return;
};
let keys: Vec<PlacementKey> = self.placements.keys().copied().collect();
for key in keys {
let Some(img) = self.images.get(&key.image_id) else {
continue;
};
let placement = self.placements[&key];
let Some(rect) = placement.rect(img, t, pages) else {
continue;
};
if target_pin.is_between(pages, rect.top_left, rect.bottom_right) {
if let Some(z) = filter_z
&& placement.z != z
{
continue;
}
let img_id = img.id;
self.remove_placement(pages, &key);
if delete_unused {
self.delete_if_unused(img_id);
}
}
}
// Mark dirty to force redraw
self.dirty = true;
}
/// Evict image to make space. This will evict the oldest image,
/// prioritizing unused images first, as recommended by the published
/// Kitty spec.
///
/// This will evict as many images as necessary to make space for
/// req bytes.
fn evict_image(&mut self, pages: &mut PageList, req: usize) -> bool {
debug_assert!(req <= self.total_limit);
let mut candidates: Vec<Candidate> = self
.images
.values()
.map(|img| Candidate {
id: img.id,
time: img.transmit_time,
used: self.placements.keys().any(|k| k.image_id == img.id),
})
.collect();
// Sort: unused first, then oldest first, then lowest id.
candidates.sort_unstable_by(|lhs, rhs| {
lhs.used
.cmp(&rhs.used)
.then(lhs.time.cmp(&rhs.time))
.then(lhs.id.cmp(&rhs.id))
});
// They're in order of best to evict.
let mut evicted: usize = 0;
for c in candidates {
// Delete all the placements for this image and the image.
let keys: Vec<PlacementKey> = self
.placements
.keys()
.filter(|k| k.image_id == c.id)
.copied()
.collect();
for key in keys {
self.remove_placement(pages, &key);
}
if let Some(img) = self.images.remove(&c.id) {
log::info!("evicting image id={} bytes={}", c.id, img.data.len());
evicted += img.data.len();
self.total_bytes -= img.data.len();
if evicted > req {
return true;
}
}
}
false
}
}
struct Candidate {
id: u32,
time: std::time::Instant,
used: bool,
}
fn cast_cell(v: u32) -> Option<CellCountInt> {
CellCountInt::try_from(v).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::{Options, Terminal};
fn term(cols: u16, rows: u16) -> Terminal {
Terminal::new(Options::new(cols, rows)).unwrap()
}
fn pages(t: &mut Terminal) -> &mut PageList {
&mut t.screens.active_mut().pages
}
fn tracked(t: &Terminal) -> usize {
t.screens.active().pages.count_tracked_pins()
}
fn track_pin(t: &mut Terminal, pt: Coordinate) -> Location {
let pages = pages(t);
let p = pages.pin(Point::Active(pt)).unwrap();
Location::Pin(pages.track_pin(p).unwrap())
}
fn img(id: u32, w: u32, h: u32) -> Image {
Image {
id,
width: w,
height: h,
..Image::default()
}
}
fn coord(x: u16, y: u32) -> Coordinate {
Coordinate { x, y }
}
fn add_p(s: &mut ImageStorage, t: &mut Terminal, image: u32, pid: u32, c: Coordinate) {
let loc = track_pin(t, c);
s.add_placement(pages(t), image, pid, Placement::new(loc))
.unwrap();
}
fn add_i(s: &mut ImageStorage, t: &mut Terminal, i: Image) {
s.add_image(pages(t), i).unwrap();
}
fn del(s: &mut ImageStorage, t: &mut Terminal, cmd: Delete) {
let info = TerminalInfo::from_terminal(t);
s.delete(pages(t), &info, cmd);
}
#[test]
fn storage_add_placement_with_zero_placement_id() {
let mut t = term(100, 100);
t.width_px = 100;
t.height_px = 100;
let mut s = ImageStorage::default();
add_i(&mut s, &mut t, img(1, 50, 50));
add_i(&mut s, &mut t, img(2, 25, 25));
add_p(&mut s, &mut t, 1, 0, coord(25, 25));
add_p(&mut s, &mut t, 1, 0, coord(25, 25));
assert_eq!(s.placements.len(), 2);
assert_eq!(s.images.len(), 2);
// verify the placement is what we expect
assert!(s.placements.contains_key(&PlacementKey::internal(1, 0)));
assert!(s.placements.contains_key(&PlacementKey::internal(1, 1)));
s.deinit(pages(&mut t));
}
fn setup3() -> (Terminal, ImageStorage, usize) {
let mut t = term(3, 3);
let tr = tracked(&t);
let mut s = ImageStorage::default();
add_i(&mut s, &mut t, img(1, 0, 0));
add_i(&mut s, &mut t, img(2, 0, 0));
add_i(&mut s, &mut t, img(3, 0, 0));
add_p(&mut s, &mut t, 1, 1, coord(1, 1));
add_p(&mut s, &mut t, 2, 1, coord(1, 1));
(t, s, tr)
}
#[test]
fn storage_delete_all_placements_and_images() {
let (mut t, mut s, tr) = setup3();
s.dirty = false;
del(&mut s, &mut t, Delete::All(true));
assert!(s.dirty);
assert_eq!(s.images.len(), 0);
assert_eq!(s.placements.len(), 0);
assert_eq!(tr, tracked(&t));
}
#[test]
fn storage_delete_all_placements_and_images_preserves_limit() {
let mut t = term(3, 3);
let tr = tracked(&t);
let mut s = ImageStorage {
total_limit: 5000,
..ImageStorage::default()
};
add_i(&mut s, &mut t, img(1, 0, 0));
add_i(&mut s, &mut t, img(2, 0, 0));
add_i(&mut s, &mut t, img(3, 0, 0));
add_p(&mut s, &mut t, 1, 1, coord(1, 1));
add_p(&mut s, &mut t, 2, 1, coord(1, 1));
s.dirty = false;
del(&mut s, &mut t, Delete::All(true));
assert!(s.dirty);
assert_eq!(s.images.len(), 0);
assert_eq!(s.placements.len(), 0);
assert_eq!(s.total_limit, 5000);
assert_eq!(tr, tracked(&t));
}
#[test]
fn storage_delete_all_placements() {
let (mut t, mut s, tr) = setup3();
s.dirty = false;
del(&mut s, &mut t, Delete::All(false));
assert!(s.dirty);
assert_eq!(s.placements.len(), 0);
assert_eq!(s.images.len(), 3);
assert_eq!(tr, tracked(&t));
}
#[test]
fn storage_delete_all_placements_by_image_id() {
let (mut t, mut s, tr) = setup3();
s.dirty = false;
del(
&mut s,
&mut t,
Delete::Id {
delete: false,
image_id: 2,
placement_id: 0,
},
);
assert!(s.dirty);
assert_eq!(s.placements.len(), 1);
assert_eq!(s.images.len(), 3);
assert_eq!(tr + 1, tracked(&t));
}
#[test]
fn storage_delete_all_placements_by_image_id_and_unused_images() {
let (mut t, mut s, tr) = setup3();
s.dirty = false;
del(
&mut s,
&mut t,
Delete::Id {
delete: true,
image_id: 2,
placement_id: 0,
},
);
assert!(s.dirty);
assert_eq!(s.placements.len(), 1);
assert_eq!(s.images.len(), 2);
assert_eq!(tr + 1, tracked(&t));
}
#[test]
fn storage_delete_placement_by_specific_id() {
let mut t = term(3, 3);
let tr = tracked(&t);
let mut s = ImageStorage::default();
add_i(&mut s, &mut t, img(1, 0, 0));
add_i(&mut s, &mut t, img(2, 0, 0));
add_i(&mut s, &mut t, img(3, 0, 0));
add_p(&mut s, &mut t, 1, 1, coord(1, 1));
add_p(&mut s, &mut t, 1, 2, coord(1, 1));
add_p(&mut s, &mut t, 2, 1, coord(1, 1));
s.dirty = false;
del(
&mut s,
&mut t,
Delete::Id {
delete: true,
image_id: 1,
placement_id: 2,
},
);
assert!(s.dirty);
assert_eq!(s.placements.len(), 2);
assert_eq!(s.images.len(), 3);
assert_eq!(tr + 2, tracked(&t));
}
fn setup100() -> (Terminal, ImageStorage, usize) {
let mut t = term(100, 100);
t.width_px = 100;
t.height_px = 100;
let tr = tracked(&t);
let mut s = ImageStorage::default();
add_i(&mut s, &mut t, img(1, 50, 50));
add_i(&mut s, &mut t, img(2, 25, 25));
add_p(&mut s, &mut t, 1, 1, coord(0, 0));
add_p(&mut s, &mut t, 1, 2, coord(25, 25));
(t, s, tr)
}
#[test]
fn storage_delete_intersecting_cursor() {
let (mut t, mut s, tr) = setup100();
t.screens.active_mut().cursor_absolute(12, 12);
s.dirty = false;
del(&mut s, &mut t, Delete::IntersectCursor(false));
assert!(s.dirty);
assert_eq!(s.placements.len(), 1);
assert_eq!(s.images.len(), 2);
assert_eq!(tr + 1, tracked(&t));
assert!(s.placements.contains_key(&PlacementKey::external(1, 2)));
}
#[test]
fn storage_delete_intersecting_cursor_plus_unused() {
let (mut t, mut s, tr) = setup100();
t.screens.active_mut().cursor_absolute(12, 12);
s.dirty = false;
del(&mut s, &mut t, Delete::IntersectCursor(true));
assert!(s.dirty);
assert_eq!(s.placements.len(), 1);
assert_eq!(s.images.len(), 2);
assert_eq!(tr + 1, tracked(&t));
assert!(s.placements.contains_key(&PlacementKey::external(1, 2)));
}
#[test]
fn storage_delete_intersecting_cursor_hits_multiple() {
let (mut t, mut s, tr) = setup100();
t.screens.active_mut().cursor_absolute(26, 26);
s.dirty = false;
del(&mut s, &mut t, Delete::IntersectCursor(true));
assert!(s.dirty);
assert_eq!(s.placements.len(), 0);
assert_eq!(s.images.len(), 1);
assert_eq!(tr, tracked(&t));
}
#[test]
fn storage_delete_by_column() {
let (mut t, mut s, tr) = setup100();
s.dirty = false;
del(
&mut s,
&mut t,
Delete::Column {
delete: false,
x: 60,
},
);
assert!(s.dirty);
assert_eq!(s.placements.len(), 1);
assert_eq!(s.images.len(), 2);
assert_eq!(tr + 1, tracked(&t));
assert!(s.placements.contains_key(&PlacementKey::external(1, 1)));
}
#[test]
fn storage_delete_by_column_1x1() {
let mut t = term(100, 100);
t.width_px = 100;
t.height_px = 100;
let mut s = ImageStorage::default();
add_i(&mut s, &mut t, img(1, 1, 1));
add_p(&mut s, &mut t, 1, 1, coord(0, 0));
add_p(&mut s, &mut t, 1, 2, coord(1, 0));
add_p(&mut s, &mut t, 1, 3, coord(2, 0));
del(
&mut s,
&mut t,
Delete::Column {
delete: false,
x: 2,
},
);
assert_eq!(s.placements.len(), 2);
assert_eq!(s.images.len(), 1);
assert!(s.placements.contains_key(&PlacementKey::external(1, 1)));
assert!(s.placements.contains_key(&PlacementKey::external(1, 3)));
}
#[test]
fn storage_delete_by_row() {
let (mut t, mut s, tr) = setup100();
s.dirty = false;
del(
&mut s,
&mut t,
Delete::Row {
delete: false,
y: 60,
},
);
assert!(s.dirty);
assert_eq!(s.placements.len(), 1);
assert_eq!(s.images.len(), 2);
assert_eq!(tr + 1, tracked(&t));
assert!(s.placements.contains_key(&PlacementKey::external(1, 1)));
}
#[test]
fn storage_delete_by_row_1x1() {
let mut t = term(100, 100);
t.width_px = 100;
t.height_px = 100;
let mut s = ImageStorage::default();
add_i(&mut s, &mut t, img(1, 1, 1));
add_p(&mut s, &mut t, 1, 1, coord(0, 0));
add_p(&mut s, &mut t, 1, 2, coord(0, 1));
add_p(&mut s, &mut t, 1, 3, coord(0, 2));
del(
&mut s,
&mut t,
Delete::Row {
delete: false,
y: 2,
},
);
assert_eq!(s.placements.len(), 2);
assert_eq!(s.images.len(), 1);
assert!(s.placements.contains_key(&PlacementKey::external(1, 1)));
assert!(s.placements.contains_key(&PlacementKey::external(1, 3)));
}
fn range_test(delete: bool, first: u32, last: u32, images_left: usize) {
let (mut t, mut s, tr) = setup3();
assert_eq!(s.images.len(), 3);
assert_eq!(s.placements.len(), 2);
s.dirty = false;
del(
&mut s,
&mut t,
Delete::Range {
delete,
first,
last,
},
);
assert!(s.dirty);
assert_eq!(s.images.len(), images_left);
assert_eq!(s.placements.len(), 0);
assert_eq!(tr, tracked(&t));
}
#[test]
fn storage_delete_images_by_range_1() {
range_test(false, 1, 2, 3);
}
#[test]
fn storage_delete_images_by_range_2() {
range_test(true, 1, 2, 1);
}
#[test]
fn storage_delete_images_by_range_3() {
range_test(false, 1, 1, 3);
}
#[test]
fn storage_delete_images_by_range_4() {
range_test(true, 1, 1, 1);
}
#[test]
fn storage_aspect_ratio_calculation_when_only_columns_or_rows_specified() {
let mut t = term(100, 100);
t.width_px = 1000; // 10 px per col
t.height_px = 2000; // 20 px per row
let info = TerminalInfo::from_terminal(&t);
// Case 1: Only columns specified
{
let image = img(1, 16, 9);
let mut placement = Placement::new(Location::Virtual);
placement.columns = 10;
placement.rows = 0;
// Image is 16x9, set to a width of 10 columns, at 10px per column
// that's 100px width. 100px * (9 / 16) = 56.25, which should round
// to a height of 56px.
let calc = placement.calculated_size(&image, &info);
assert_eq!(calc.width, 100);
assert_eq!(calc.height, 56);
}
// Case 2: Only rows specified
{
let image = img(2, 16, 9);
let mut placement = Placement::new(Location::Virtual);
placement.columns = 0;
placement.rows = 5;
// Image is 16x9, set to a height of 5 rows, at 20px per row that's
// 100px height. 100px * (16 / 9) = 177.77..., which should round to
// a width of 178px.
let calc = placement.calculated_size(&image, &info);
assert_eq!(calc.width, 178);
assert_eq!(calc.height, 100);
}
}
#[test]
fn storage_add_placement_replaces_and_releases_pin() {
let mut t = term(3, 3);
let tr = tracked(&t);
let mut s = ImageStorage::default();
add_i(&mut s, &mut t, img(1, 0, 0));
add_p(&mut s, &mut t, 1, 5, coord(0, 0));
add_p(&mut s, &mut t, 1, 5, coord(1, 1));
assert_eq!(s.placements.len(), 1);
assert_eq!(tr + 1, tracked(&t));
s.deinit(pages(&mut t));
assert_eq!(tr, tracked(&t));
}
#[test]
fn storage_eviction_prefers_unused_then_oldest() {
let mut t = term(3, 3);
let tr = tracked(&t);
let mut s = ImageStorage {
total_limit: 30,
..ImageStorage::default()
};
let mk = |id: u32, n: usize| Image {
id,
data: vec![0; n],
..Image::default()
};
add_i(&mut s, &mut t, mk(1, 10));
add_i(&mut s, &mut t, mk(2, 10));
add_i(&mut s, &mut t, mk(3, 10));
// Image 1 is oldest but in use; image 2 is the oldest unused.
add_p(&mut s, &mut t, 1, 1, coord(0, 0));
assert_eq!(s.total_bytes, 30);
add_i(&mut s, &mut t, mk(4, 10));
assert!(s.images.contains_key(&1));
assert!(!s.images.contains_key(&2));
// Eviction continues until strictly more than the requested bytes
// were freed (`evicted > req` in Zig), so image 3 goes too.
assert!(!s.images.contains_key(&3));
assert!(s.images.contains_key(&4));
assert_eq!(s.total_bytes, 20);
// Too large for the limit outright.
assert_eq!(
s.add_image(pages(&mut t), mk(5, 31)),
Err(Error::OutOfMemory)
);
// Lowering the limit evicts (the used image goes last) and releases
// the evicted placement's pin.
s.set_limit(pages(&mut t), 5).unwrap();
assert_eq!(s.images.len(), 0);
assert_eq!(s.placements.len(), 0);
assert_eq!(tr, tracked(&t));
// Zero disables.
s.set_limit(pages(&mut t), 0).unwrap();
assert!(!s.enabled());
}
#[test]
fn storage_image_by_number_newest() {
let mut t = term(3, 3);
let mut s = ImageStorage::default();
let a = Image {
id: 1,
number: 7,
..Image::default()
};
std::thread::sleep(std::time::Duration::from_millis(2));
let b = Image {
id: 2,
number: 7,
..Image::default()
};
add_i(&mut s, &mut t, a);
add_i(&mut s, &mut t, b);
assert_eq!(s.image_by_number(7).unwrap().id, 2);
assert!(s.image_by_number(8).is_none());
}
}