use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use crabshell_ui::{IconPixels, IconSource};
use resvg::usvg;
use tiny_skia::{FilterQuality, Pixmap, PixmapPaint, Transform};
use crate::theme::Themes;
const CAPACITY: usize = 128;
const RECENT: u64 = 16;
const MAX_SVG_BYTES: u64 = 1024 * 1024;
const MAX_PNG_BYTES: u64 = 4 * 1024 * 1024;
const MAX_PNG_SIDE: u32 = 1024;
const MAX_PIXELS_SIDE: u32 = 512;
pub const DEFAULT_THEME: &str = "hicolor";
pub(crate) struct Icons {
theme: String,
themes: Themes,
cache: HashMap<Key, Entry>,
frame: u64,
failed: HashSet<Key>,
rasterized: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum Key {
Name {
theme: String,
name: String,
size: u32,
scale: u32,
side: u32,
},
File {
path: PathBuf,
side: u32,
},
Pixels {
data: Shared,
width: u32,
height: u32,
side: u32,
},
}
#[derive(Clone)]
struct Shared(Rc<[u8]>);
impl PartialEq for Shared {
fn eq(&self, other: &Shared) -> bool {
Rc::ptr_eq(&self.0, &other.0)
}
}
impl Eq for Shared {}
impl Hash for Shared {
fn hash<H: Hasher>(&self, state: &mut H) {
(Rc::as_ptr(&self.0) as *const u8 as usize, self.0.len()).hash(state);
}
}
impl std::fmt::Debug for Shared {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} bytes", self.0.len())
}
}
struct Entry {
pixmap: Option<Pixmap>,
used: u64,
}
impl Icons {
pub(crate) fn new() -> Icons {
Icons::with_themes(Themes::from_env())
}
pub(crate) fn with_themes(themes: Themes) -> Icons {
Icons {
theme: DEFAULT_THEME.to_owned(),
themes,
cache: HashMap::new(),
frame: 0,
failed: HashSet::new(),
rasterized: 0,
}
}
pub(crate) fn set_theme(&mut self, theme: String) {
self.theme = theme;
}
#[cfg(test)]
pub(crate) fn cached(&self) -> usize {
self.cache.len()
}
#[cfg(test)]
pub(crate) fn rasterized(&self) -> usize {
self.rasterized
}
pub(crate) fn get(
&mut self,
source: &IconSource,
side: u32,
size: u32,
scale: u32,
) -> Option<&Pixmap> {
let key = match source {
IconSource::Name(name) => Key::Name {
theme: self.theme.clone(),
name: name.clone(),
size,
scale,
side,
},
IconSource::File(path) => Key::File {
path: path.clone(),
side,
},
IconSource::Pixels(pixels) => Key::Pixels {
data: Shared(Rc::clone(&pixels.data)),
width: pixels.width,
height: pixels.height,
side,
},
};
let frame = self.frame;
if !self.cache.contains_key(&key) {
self.rasterized += 1;
let rasterized = match source {
IconSource::Name(name) => match self.themes.find(&self.theme, name, size, scale) {
Some(path) => rasterize_file(&path, side),
None => Err(format!("no icon named {name:?} in theme {:?}", self.theme)),
},
IconSource::File(path) => rasterize_file(path, side),
IconSource::Pixels(pixels) => from_argb(pixels).and_then(|image| fit(&image, side)),
};
let pixmap = match rasterized {
Ok(pixmap) => Some(pixmap),
Err(error) => {
if self.failed.len() > CAPACITY {
self.failed.clear();
}
if self.failed.insert(key.clone()) {
tracing::warn!(icon = %describe(source), %error, "couldn't draw an icon");
}
None
}
};
self.cache.insert(
key.clone(),
Entry {
pixmap,
used: frame,
},
);
}
let entry = self.cache.get_mut(&key)?;
entry.used = frame;
entry.pixmap.as_ref()
}
pub(crate) fn end_frame(&mut self) {
if self.cache.len() > CAPACITY {
let oldest = self.frame.saturating_sub(RECENT - 1);
self.cache.retain(|_, entry| entry.used >= oldest);
}
self.frame += 1;
}
}
fn describe(source: &IconSource) -> String {
match source {
IconSource::Name(name) => format!("name {name:?}"),
IconSource::File(path) => format!("file {}", path.display()),
IconSource::Pixels(pixels) => format!("{}x{} pixels", pixels.width, pixels.height),
}
}
fn rasterize_file(path: &Path, side: u32) -> Result<Pixmap, String> {
let extension = path
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase);
match extension.as_deref() {
Some("svg") => rasterize_svg(&read_capped(path, MAX_SVG_BYTES)?, side),
Some("png") => fit(&decode_png(&read_capped(path, MAX_PNG_BYTES)?)?, side),
_ => Err(String::from("only .svg and .png files can be icons")),
}
}
pub(crate) fn read_capped(path: &Path, cap: u64) -> Result<Vec<u8>, String> {
let metadata = std::fs::metadata(path).map_err(|error| error.to_string())?;
if !metadata.is_file() {
return Err(String::from("not a regular file"));
}
if metadata.len() > cap {
return Err(format!(
"the file is {} bytes, more than {cap}",
metadata.len()
));
}
let file = std::fs::File::open(path).map_err(|error| error.to_string())?;
read_limited(file, cap)
}
pub(crate) fn read_limited(reader: impl Read, cap: u64) -> Result<Vec<u8>, String> {
let mut data = Vec::new();
reader
.take(cap + 1)
.read_to_end(&mut data)
.map_err(|error| error.to_string())?;
if data.len() as u64 > cap {
return Err(format!("more than {cap} bytes"));
}
Ok(data)
}
fn rasterize_svg(data: &[u8], side: u32) -> Result<Pixmap, String> {
let options = usvg::Options {
image_href_resolver: usvg::ImageHrefResolver {
resolve_data: usvg::ImageHrefResolver::default_data_resolver(),
resolve_string: Box::new(|_, _| None),
},
..usvg::Options::default()
};
let tree = usvg::Tree::from_data(data, &options).map_err(|error| error.to_string())?;
if !tree.filters().is_empty() {
return Err(String::from("the SVG uses a filter, which icons may not"));
}
let size = tree.size();
let mut pixmap = empty(side)?;
let scale = side as f32 / size.width().max(size.height());
let transform = Transform::from_scale(scale, scale).post_translate(
(side as f32 - size.width() * scale) / 2.0,
(side as f32 - size.height() * scale) / 2.0,
);
resvg::render(&tree, transform, &mut pixmap.as_mut());
Ok(pixmap)
}
fn decode_png(data: &[u8]) -> Result<Pixmap, String> {
let limits = png::Limits {
bytes: (MAX_PNG_SIDE * MAX_PNG_SIDE * 8) as usize,
};
let mut decoder = png::Decoder::new_with_limits(Cursor::new(data), limits);
decoder.set_transformations(png::Transformations::normalize_to_color8());
let mut reader = decoder.read_info().map_err(|error| error.to_string())?;
let (width, height) = (reader.info().width, reader.info().height);
if width > MAX_PNG_SIDE || height > MAX_PNG_SIDE {
return Err(format!(
"a {width}x{height} PNG is larger than {MAX_PNG_SIDE} pixels a side"
));
}
let size = reader
.output_buffer_size()
.ok_or("the PNG is too large to decode")?;
let mut buffer = vec![0; size];
let info = reader
.next_frame(&mut buffer)
.map_err(|error| error.to_string())?;
let rows = buffer
.chunks_exact(info.line_size)
.take(info.height as usize);
let mut rgba = Vec::with_capacity(info.width as usize * info.height as usize * 4);
for row in rows {
match info.color_type {
png::ColorType::Rgba => rgba.extend_from_slice(&row[..info.width as usize * 4]),
png::ColorType::Rgb => {
for pixel in row.as_chunks::<3>().0.iter().take(info.width as usize) {
rgba.extend_from_slice(&[pixel[0], pixel[1], pixel[2], 255]);
}
}
png::ColorType::GrayscaleAlpha => {
for pixel in row.as_chunks::<2>().0.iter().take(info.width as usize) {
rgba.extend_from_slice(&[pixel[0], pixel[0], pixel[0], pixel[1]]);
}
}
png::ColorType::Grayscale => {
for &gray in row.iter().take(info.width as usize) {
rgba.extend_from_slice(&[gray, gray, gray, 255]);
}
}
png::ColorType::Indexed => return Err(String::from("unexpanded palette PNG")),
}
}
premultiplied(rgba, info.width, info.height)
}
fn from_argb(pixels: &IconPixels) -> Result<Pixmap, String> {
if pixels.width > MAX_PIXELS_SIDE || pixels.height > MAX_PIXELS_SIDE {
return Err(format!(
"{}x{} pixels are larger than {MAX_PIXELS_SIDE} a side",
pixels.width, pixels.height
));
}
let expected = u64::from(pixels.width) * u64::from(pixels.height) * 4;
if pixels.data.len() as u64 != expected {
return Err(format!(
"{}x{} pixels need {expected} bytes, got {}",
pixels.width,
pixels.height,
pixels.data.len()
));
}
let rgba = pixels
.data
.as_chunks::<4>()
.0
.iter()
.flat_map(|argb| [argb[1], argb[2], argb[3], argb[0]])
.collect();
premultiplied(rgba, pixels.width, pixels.height)
}
fn premultiplied(mut rgba: Vec<u8>, width: u32, height: u32) -> Result<Pixmap, String> {
for pixel in rgba.as_chunks_mut::<4>().0 {
let alpha = u16::from(pixel[3]);
for channel in &mut pixel[..3] {
*channel = ((u16::from(*channel) * alpha + 127) / 255) as u8;
}
}
let size = tiny_skia::IntSize::from_wh(width, height).ok_or("an icon with no pixels")?;
Pixmap::from_vec(rgba, size).ok_or_else(|| String::from("icon pixels of the wrong length"))
}
fn fit(image: &Pixmap, side: u32) -> Result<Pixmap, String> {
let mut halved;
let mut image = image;
while image.width().max(image.height()) > side * 2 {
halved = halve(image)?;
image = &halved;
}
let mut pixmap = empty(side)?;
let scale = side as f32 / image.width().max(image.height()) as f32;
let transform = Transform::from_scale(scale, scale).post_translate(
(side as f32 - image.width() as f32 * scale) / 2.0,
(side as f32 - image.height() as f32 * scale) / 2.0,
);
let paint = PixmapPaint {
quality: FilterQuality::Bicubic,
..PixmapPaint::default()
};
pixmap.draw_pixmap(0, 0, image.as_ref(), &paint, transform, None);
Ok(pixmap)
}
fn halve(image: &Pixmap) -> Result<Pixmap, String> {
let (width, height) = (image.width(), image.height());
let (half_width, half_height) = (width.div_ceil(2), height.div_ceil(2));
let mut half = Pixmap::new(half_width, half_height).ok_or("an icon with no pixels")?;
let source = image.data();
let target = half.data_mut();
for y in 0..half_height {
for x in 0..half_width {
let mut sum = [0_u32; 4];
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
let sx = (x * 2 + dx).min(width - 1);
let sy = (y * 2 + dy).min(height - 1);
let index = (sy * width + sx) as usize * 4;
for (total, &value) in sum.iter_mut().zip(&source[index..index + 4]) {
*total += u32::from(value);
}
}
let index = (y * half_width + x) as usize * 4;
for (value, total) in target[index..index + 4].iter_mut().zip(sum) {
*value = ((total + 2) / 4) as u8;
}
}
}
Ok(half)
}
fn empty(side: u32) -> Result<Pixmap, String> {
Pixmap::new(side, side).ok_or_else(|| String::from("an icon with no pixels"))
}