mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
The Recents section shows recent files as cards: the picture each file
carries (thumbnail.png in .figcraft and .fig, an image file itself) fitted
on its first page's colour, a divider, and a footer with the file's icon,
name and "Edited ... ago" (or "File not found"); the full path on hover.
Pictures are read off the frame's thread, only for cards in view, cached
by path and modification time, capped in bytes and pixels; missing,
damaged or oversized ones give a placeholder. They fade in, and cards
show a skeleton until their file has been looked at.
A sort menu (Alphabetical, Date created, Last modified; either order) and
a grid/list toggle sit above them. The list is a Name, Last modified and
Created table whose headers sort. View (per section) and sort are kept in
the preferences and set with ui.set {homeRecentsView, homeRecentsSort,
homeRecentsOrder}. A click selects, a double-click or Enter opens, arrow
keys move the selection; a right-click offers Open, Show in folder and
Remove from recents (file.openRecent {remove}).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
171 lines
7.2 KiB
Rust
171 lines
7.2 KiB
Rust
//! The picture a saved file carries of itself: `thumbnail.png` inside a `.figcraft` or `.fig` zip
|
|
//! (the home screen's recent files show it), and the colour of the page it sits on. Image files are
|
|
//! their own picture. Everything is read within caps: a missing, damaged or oversized picture gives
|
|
//! `None`, never an error or a panic.
|
|
|
|
use std::io::{Read, Seek};
|
|
use std::path::Path;
|
|
|
|
/// Largest `thumbnail.png` read from an archive.
|
|
pub const MAX_THUMBNAIL_BYTES: u64 = 8 * 1024 * 1024;
|
|
/// Largest image file read as its own picture.
|
|
pub const MAX_IMAGE_FILE_BYTES: u64 = 32 * 1024 * 1024;
|
|
/// Widest or tallest picture decoded (pixels); a larger one gives none.
|
|
pub const MAX_THUMBNAIL_SIDE: u32 = 8192;
|
|
/// Largest `document.json` read for its page colour, and `.fig` `meta.json`.
|
|
const MAX_DOCUMENT_BYTES: u64 = 64 * 1024 * 1024;
|
|
const MAX_META_BYTES: u64 = 1024 * 1024;
|
|
/// Most memory one decode may take.
|
|
const MAX_DECODE_ALLOC: u64 = 256 * 1024 * 1024;
|
|
|
|
/// A decoded picture: straight (unpremultiplied) RGBA, `width * height * 4` bytes.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Thumbnail {
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub rgba: Vec<u8>,
|
|
}
|
|
|
|
/// The bytes of `thumbnail.png` in a zip archive (`.figcraft`, `.fig`); `None` when it isn't a zip,
|
|
/// has no thumbnail, or the thumbnail is larger than [`MAX_THUMBNAIL_BYTES`].
|
|
pub fn thumbnail_png<R: Read + Seek>(archive: R) -> Option<Vec<u8>> {
|
|
let mut z = zip::ZipArchive::new(archive).ok()?;
|
|
crate::read_member(&mut z, "thumbnail.png", MAX_THUMBNAIL_BYTES).ok()
|
|
}
|
|
|
|
/// Decode a picture (PNG, JPEG, WebP, GIF, BMP or TIFF) no larger than [`MAX_THUMBNAIL_SIDE`] on
|
|
/// either side, scaled down to at most `fit` pixels on its longer side (never up).
|
|
pub fn decode_thumbnail(bytes: &[u8], fit: u32) -> Option<Thumbnail> {
|
|
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
|
|
let mut limits = image::Limits::default();
|
|
limits.max_image_width = Some(MAX_THUMBNAIL_SIDE);
|
|
limits.max_image_height = Some(MAX_THUMBNAIL_SIDE);
|
|
limits.max_alloc = Some(MAX_DECODE_ALLOC);
|
|
reader.limits(limits);
|
|
let mut picture = reader.decode().ok()?;
|
|
let fit = fit.clamp(1, MAX_THUMBNAIL_SIDE);
|
|
if picture.width() > fit || picture.height() > fit {
|
|
picture = picture.thumbnail(fit, fit);
|
|
}
|
|
let rgba = picture.to_rgba8();
|
|
let (width, height) = rgba.dimensions();
|
|
(width > 0 && height > 0).then(|| Thumbnail { width, height, rgba: rgba.into_raw() })
|
|
}
|
|
|
|
/// What kind of file `path` is, for its picture and its icon.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum FileKind {
|
|
/// `.figcraft`
|
|
FigCraft,
|
|
/// `.fig`
|
|
Fig,
|
|
/// `.svg`
|
|
Svg,
|
|
/// A raster image (`.png`, `.jpg`, `.webp`, …).
|
|
Image,
|
|
Other,
|
|
}
|
|
|
|
impl FileKind {
|
|
pub fn of(path: &Path) -> FileKind {
|
|
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
|
match ext.as_str() {
|
|
"figcraft" => FileKind::FigCraft,
|
|
"fig" => FileKind::Fig,
|
|
"svg" => FileKind::Svg,
|
|
"png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tif" | "tiff" => FileKind::Image,
|
|
_ => FileKind::Other,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The picture of the file at `path`, at most `fit` pixels on its longer side: the thumbnail a
|
|
/// `.figcraft` or `.fig` file carries, or an image file itself (up to [`MAX_IMAGE_FILE_BYTES`]).
|
|
/// `None` for anything else, and for a file that is missing, unreadable or damaged.
|
|
pub fn file_thumbnail(path: &Path, fit: u32) -> Option<Thumbnail> {
|
|
match FileKind::of(path) {
|
|
FileKind::FigCraft | FileKind::Fig => {
|
|
let file = std::fs::File::open(path).ok()?;
|
|
let png = thumbnail_png(std::io::BufReader::new(file))?;
|
|
decode_thumbnail(&png, fit)
|
|
}
|
|
FileKind::Image => {
|
|
let file = std::fs::File::open(path).ok()?;
|
|
let mut bytes = Vec::new();
|
|
file.take(MAX_IMAGE_FILE_BYTES.saturating_add(1)).read_to_end(&mut bytes).ok()?;
|
|
if bytes.len() as u64 > MAX_IMAGE_FILE_BYTES {
|
|
return None;
|
|
}
|
|
decode_thumbnail(&bytes, fit)
|
|
}
|
|
FileKind::Svg | FileKind::Other => None,
|
|
}
|
|
}
|
|
|
|
/// How a file looks on the home screen: the colour of its first page (straight RGBA, 0 to 1) and
|
|
/// the picture it carries. Either may be missing: a file with nothing on it has a page colour and no
|
|
/// picture; an image file has a picture and no page.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct Preview {
|
|
pub page: Option<[f32; 4]>,
|
|
pub picture: Option<Thumbnail>,
|
|
}
|
|
|
|
/// The [`Preview`] of the file at `path`, its picture at most `fit` pixels on its longer side.
|
|
/// Empty for a file that is missing, unreadable, damaged or of another kind.
|
|
pub fn file_preview(path: &Path, fit: u32) -> Preview {
|
|
match FileKind::of(path) {
|
|
FileKind::FigCraft | FileKind::Fig => {
|
|
let Ok(file) = std::fs::File::open(path) else { return Preview::default() };
|
|
archive_preview(std::io::BufReader::new(file), fit)
|
|
}
|
|
FileKind::Image => Preview { page: None, picture: file_thumbnail(path, fit) },
|
|
FileKind::Svg | FileKind::Other => Preview::default(),
|
|
}
|
|
}
|
|
|
|
/// The [`Preview`] of a `.figcraft` or `.fig` archive: `thumbnail.png`, and the first page's colour
|
|
/// from `document.json` (`.figcraft`) or `meta.json` (`.fig`).
|
|
pub fn archive_preview<R: Read + Seek>(archive: R, fit: u32) -> Preview {
|
|
let Ok(mut z) = zip::ZipArchive::new(archive) else { return Preview::default() };
|
|
let picture = crate::read_member(&mut z, "thumbnail.png", MAX_THUMBNAIL_BYTES).ok().and_then(|png| decode_thumbnail(&png, fit));
|
|
let page = if z.file_names().any(|n| n == "document.json") {
|
|
crate::read_member(&mut z, "document.json", MAX_DOCUMENT_BYTES).ok().and_then(|json| figcraft_page(&json))
|
|
} else {
|
|
crate::read_member(&mut z, "meta.json", MAX_META_BYTES).ok().and_then(|json| fig_page(&json))
|
|
};
|
|
Preview { page, picture }
|
|
}
|
|
|
|
/// The first page's background in a `.figcraft` `document.json`.
|
|
fn figcraft_page(json: &[u8]) -> Option<[f32; 4]> {
|
|
#[derive(serde::Deserialize)]
|
|
struct Head {
|
|
#[serde(default)]
|
|
pages: Vec<u64>,
|
|
#[serde(default)]
|
|
nodes: std::collections::BTreeMap<String, Node>,
|
|
}
|
|
#[derive(serde::Deserialize)]
|
|
struct Node {
|
|
#[serde(default)]
|
|
background: Option<serde_json::Value>,
|
|
}
|
|
let head: Head = serde_json::from_slice(json).ok()?;
|
|
let first = head.pages.first()?;
|
|
rgba(head.nodes.get(&first.to_string())?.background.as_ref()?)
|
|
}
|
|
|
|
/// The canvas colour a `.fig` file's `meta.json` gives (`client_meta.background_color`).
|
|
fn fig_page(json: &[u8]) -> Option<[f32; 4]> {
|
|
let meta: serde_json::Value = serde_json::from_slice(json).ok()?;
|
|
rgba(meta.get("client_meta")?.get("background_color")?)
|
|
}
|
|
|
|
/// `{r, g, b, a?}` (0 to 1) as a colour; `None` unless every channel is a finite number.
|
|
fn rgba(v: &serde_json::Value) -> Option<[f32; 4]> {
|
|
let channel = |k: &str| v.get(k).and_then(serde_json::Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0) as f32);
|
|
let a = if v.get("a").is_some() { channel("a")? } else { 1.0 };
|
|
Some([channel("r")?, channel("g")?, channel("b")?, a])
|
|
}
|