mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
M7: recent files as thumbnail cards on the home screen (#249)
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
bflatastic
parent
b34c5de2ab
commit
bcedfeba10
Generated
+1
@@ -1695,6 +1695,7 @@ dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
"figcraft-format",
|
||||
"figcraft-geom",
|
||||
"figcraft-layout",
|
||||
"figcraft-pathops",
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
|
||||
- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged.
|
||||
- **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged.
|
||||
- **2026-10-13** · MCP/control transport hardening (#201): 32 MiB incoming frames are bounded while reading; oversized frames return an error and close without draining. The opt-in loopback control listener caps active workers at 8, applies 60 s socket read/write inactivity timeouts and caps both request queues at 8, discarding timed-out requests including those deferred to hidden windows. Reader, queue, idle-client and blocked-writer regressions cover the limits. Numbers unchanged.
|
||||
@@ -311,6 +312,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged |
|
||||
| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `<image>` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Leptos code (#223): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: leptos`: a `#[component]` with `#[prop]` defaults, variant enums and `view!` markup, instances as calls, Code Connect `use` lines; the Dev Mode language list and rem units include it. Numbers unchanged |
|
||||
|
||||
@@ -131,6 +131,34 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
|
||||
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
|
||||
}
|
||||
|
||||
/// Show `path` in the file manager: selected in Finder and Explorer, its folder elsewhere.
|
||||
fn reveal(path: &Path) -> std::io::Result<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut command = {
|
||||
let mut c = std::process::Command::new("open");
|
||||
c.arg("-R").arg(path);
|
||||
c
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let mut command = {
|
||||
let mut arg = std::ffi::OsString::from("/select,");
|
||||
arg.push(path);
|
||||
let mut c = std::process::Command::new("explorer");
|
||||
c.arg(arg);
|
||||
c
|
||||
};
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
let mut command = {
|
||||
let mut c = std::process::Command::new("xdg-open");
|
||||
c.arg(path.parent().unwrap_or(path));
|
||||
c
|
||||
};
|
||||
// The file manager keeps running; reap it off this thread so it leaves no zombie.
|
||||
let mut child = command.spawn()?;
|
||||
let _ = std::thread::Builder::new().name("figcraft-reveal".into()).spawn(move || child.wait());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The preferences' file in the config folder.
|
||||
const STATE: &str = "state.json";
|
||||
|
||||
@@ -201,6 +229,11 @@ impl Services for Desktop {
|
||||
fn open_url(&mut self, url: &str) {
|
||||
let _ = webbrowser::open(url);
|
||||
}
|
||||
fn reveal_path(&mut self, path: &Path) {
|
||||
if let Err(e) = reveal(path) {
|
||||
log::warn!("show in folder: {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
fn save_prefs(&mut self, prefs: &Prefs) {
|
||||
if let Some(dir) = &self.dir {
|
||||
write_prefs(dir, prefs);
|
||||
|
||||
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.openRecent",
|
||||
"Open recent",
|
||||
"{path? | index? (into the list), clear? (forget them all)} opens a recent file; a file that no longer exists is dropped from the list. Returns the list, newest first: {files: [{path, name, exists}]} (opening returns what `file.open` does)",
|
||||
"{path? | index? (into the list), clear? (forget them all), remove? (a path: forget that one)} opens a recent file; a file that no longer exists is dropped from the list. Returns the list, newest first: {files: [{path, name, exists}]} (opening returns what `file.open` does)",
|
||||
false,
|
||||
open_recent
|
||||
),
|
||||
@@ -625,6 +625,9 @@ fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("clear").and_then(Value::as_bool) == Some(true) {
|
||||
s.prefs.recent_files.clear();
|
||||
}
|
||||
if let Some(gone) = string(p, "remove") {
|
||||
s.prefs.recent_files.retain(|r| *r != gone);
|
||||
}
|
||||
let path = match (string(p, "path"), num(p, "index")) {
|
||||
(Some(path), _) => Some(path),
|
||||
(None, Some(i)) => Some(s.prefs.recent_files.get(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?),
|
||||
|
||||
@@ -591,6 +591,14 @@ pub struct Prefs {
|
||||
/// The Getting started tips on the home screen were closed (Help ▸ Getting started shows them
|
||||
/// again).
|
||||
pub getting_started_dismissed: bool,
|
||||
/// How each home screen section shows its files, by section id (`"recents"`): `"grid"` (cards
|
||||
/// with the picture each file carries) or `"list"` (a table). Missing: the grid.
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub home_views: BTreeMap<String, String>,
|
||||
/// What the home screen's recent files are ordered by: `"modified"`, `"name"` or `"created"`.
|
||||
pub home_sort: String,
|
||||
/// Which way: `"desc"` (newest first, Z to A) or `"asc"`.
|
||||
pub home_order: String,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -616,6 +624,9 @@ impl Default for Prefs {
|
||||
recent_files: Vec::new(),
|
||||
online_files: Vec::new(),
|
||||
getting_started_dismissed: false,
|
||||
home_views: BTreeMap::new(),
|
||||
home_sort: "modified".into(),
|
||||
home_order: "desc".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,19 @@ fn open_recent_lists_opens_prunes_and_clears() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_recent_file_is_forgotten_and_nothing_opens() {
|
||||
let mut s = Session::empty();
|
||||
s.prefs.recent_files = vec!["/tmp/a.figcraft".into(), "/tmp/b.fig".into(), "/tmp/c.svg".into()];
|
||||
let list = s.execute("file.openRecent", &json!({"remove": "/tmp/b.fig"})).unwrap();
|
||||
assert_eq!(s.prefs.recent_files, vec!["/tmp/a.figcraft".to_string(), "/tmp/c.svg".to_string()]);
|
||||
assert_eq!(list["files"].as_array().map(Vec::len), Some(2), "the list as it is now");
|
||||
assert!(s.docs.is_empty(), "nothing opens");
|
||||
// A path that isn't in the list changes nothing.
|
||||
s.execute("file.openRecent", &json!({"remove": "/tmp/zzz.fig"})).unwrap();
|
||||
assert_eq!(s.prefs.recent_files.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_files_are_capped() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-recent-cap-{}", std::process::id()));
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
pub mod fig;
|
||||
pub mod library;
|
||||
pub mod rest;
|
||||
pub mod thumbnail;
|
||||
|
||||
pub use library::{is_valid_library_id, read_library, read_library_within};
|
||||
|
||||
@@ -676,6 +677,8 @@ mod image_tests;
|
||||
#[cfg(test)]
|
||||
mod large_tests;
|
||||
#[cfg(test)]
|
||||
mod thumbnail_tests;
|
||||
#[cfg(test)]
|
||||
mod version_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! 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])
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Reading the picture a saved file carries: from `.figcraft` and `.fig` archives and image files,
|
||||
//! and `None` (never a panic) for missing, damaged, oversized and non-zip input.
|
||||
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
use figcraft_doc::Document;
|
||||
|
||||
use crate::thumbnail::{FileKind, MAX_THUMBNAIL_BYTES, MAX_THUMBNAIL_SIDE, decode_thumbnail, file_thumbnail, thumbnail_png};
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(w, h, image::Rgba([10, 20, 30, 255]));
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// A zip holding `members` (stored).
|
||||
fn zip(members: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
let mut z = zip::ZipWriter::new(&mut buf);
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, bytes) in members {
|
||||
z.start_file(*name, stored).unwrap();
|
||||
z.write_all(bytes).unwrap();
|
||||
}
|
||||
z.finish().unwrap();
|
||||
buf.into_inner()
|
||||
}
|
||||
|
||||
fn dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-thumbnail-tests-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_figcraft_file_gives_its_thumbnail() {
|
||||
let thumb = png(40, 20);
|
||||
let bytes = crate::save_bytes(&Document::new("File"), Some(&thumb)).unwrap();
|
||||
assert_eq!(thumbnail_png(Cursor::new(&bytes)).as_deref(), Some(thumb.as_slice()));
|
||||
let t = decode_thumbnail(&thumb, 640).unwrap();
|
||||
assert_eq!((t.width, t.height, t.rgba.len()), (40, 20, 40 * 20 * 4));
|
||||
assert_eq!(t.rgba.get(..4), Some([10, 20, 30, 255].as_slice()));
|
||||
// And from disk.
|
||||
let path = dir("figcraft").join("a.figcraft");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
assert_eq!(file_thumbnail(&path, 640), Some(t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fig_file_gives_its_thumbnail() {
|
||||
let thumb = png(30, 60);
|
||||
let (bytes, _) = crate::fig::export(&Document::new("File"), Some(&thumb)).unwrap();
|
||||
assert_eq!(thumbnail_png(Cursor::new(&bytes)).as_deref(), Some(thumb.as_slice()));
|
||||
let path = dir("fig").join("b.fig");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let t = file_thumbnail(&path, 640).unwrap();
|
||||
assert_eq!((t.width, t.height), (30, 60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_pictures_are_scaled_down_to_fit_never_up() {
|
||||
let t = decode_thumbnail(&png(800, 400), 200).unwrap();
|
||||
assert_eq!((t.width, t.height), (200, 100));
|
||||
let t = decode_thumbnail(&png(300, 900), 300).unwrap();
|
||||
assert_eq!((t.width, t.height), (100, 300));
|
||||
let t = decode_thumbnail(&png(16, 8), 640).unwrap();
|
||||
assert_eq!((t.width, t.height), (16, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_damaged_oversized_and_non_zip_input_give_none() {
|
||||
// No thumbnail in the archive.
|
||||
let bytes = crate::save_bytes(&Document::new("File"), None).unwrap();
|
||||
assert_eq!(thumbnail_png(Cursor::new(&bytes)), None);
|
||||
// Not a zip: bare JSON, an old-style `.fig`, nothing at all.
|
||||
assert_eq!(thumbnail_png(Cursor::new(b"{\"format\":\"figcraft\"}".as_slice())), None);
|
||||
assert_eq!(thumbnail_png(Cursor::new(b"fig-kiwi\x0f\0\0\0".as_slice())), None);
|
||||
assert_eq!(thumbnail_png(Cursor::new(Vec::new())), None);
|
||||
// A thumbnail that isn't a picture, or is cut short.
|
||||
let damaged = zip(&[("thumbnail.png", b"\x89PNG\r\n\x1a\nnot really")]);
|
||||
let png_bytes = thumbnail_png(Cursor::new(&damaged)).unwrap();
|
||||
assert_eq!(decode_thumbnail(&png_bytes, 640), None);
|
||||
let whole = png(20, 20);
|
||||
assert_eq!(decode_thumbnail(&whole[..whole.len() / 2], 640), None);
|
||||
assert_eq!(decode_thumbnail(b"", 640), None);
|
||||
// Too many bytes for a thumbnail.
|
||||
let big = vec![0u8; MAX_THUMBNAIL_BYTES as usize + 1];
|
||||
assert_eq!(thumbnail_png(Cursor::new(zip(&[("thumbnail.png", &big)]))), None);
|
||||
// Too many pixels on a side, however small the file.
|
||||
assert_eq!(decode_thumbnail(&png(MAX_THUMBNAIL_SIDE + 1, 1), 640), None);
|
||||
// A zip with a damaged central directory.
|
||||
let mut cut = crate::save_bytes(&Document::new("File"), Some(&png(4, 4))).unwrap();
|
||||
cut.truncate(cut.len() - 10);
|
||||
assert_eq!(thumbnail_png(Cursor::new(cut)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_on_disk_by_kind() {
|
||||
let d = dir("kinds");
|
||||
// An image is its own picture; an svg and a missing file have none.
|
||||
let image = d.join("photo.PNG");
|
||||
std::fs::write(&image, png(12, 6)).unwrap();
|
||||
assert_eq!(file_thumbnail(&image, 640).map(|t| (t.width, t.height)), Some((12, 6)));
|
||||
let svg = d.join("icon.svg");
|
||||
std::fs::write(&svg, "<svg xmlns='http://www.w3.org/2000/svg'/>").unwrap();
|
||||
assert_eq!(file_thumbnail(&svg, 640), None);
|
||||
assert_eq!(file_thumbnail(&d.join("gone.figcraft"), 640), None);
|
||||
// A `.figcraft` that is plain JSON, and one that is junk.
|
||||
let json = d.join("plain.figcraft");
|
||||
std::fs::write(&json, "{}").unwrap();
|
||||
assert_eq!(file_thumbnail(&json, 640), None);
|
||||
let junk = d.join("junk.fig");
|
||||
std::fs::write(&junk, [0xFFu8; 64]).unwrap();
|
||||
assert_eq!(file_thumbnail(&junk, 640), None);
|
||||
// A folder named like a file.
|
||||
let folder = d.join("folder.figcraft");
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
assert_eq!(file_thumbnail(&folder, 640), None);
|
||||
let kinds: Vec<FileKind> =
|
||||
["a.figcraft", "a.FIG", "a.svg", "a.jpeg", "a.webp", "a.txt", "noext"].iter().map(|p| FileKind::of(std::path::Path::new(p))).collect();
|
||||
assert_eq!(kinds, [FileKind::FigCraft, FileKind::Fig, FileKind::Svg, FileKind::Image, FileKind::Image, FileKind::Other, FileKind::Other]);
|
||||
}
|
||||
|
||||
/// A document whose first page is `rgb`.
|
||||
fn doc_on(rgb: [f32; 3]) -> Document {
|
||||
let mut d = Document::new("File");
|
||||
let p = d.first_page().unwrap();
|
||||
if let Some(figcraft_doc::NodeKind::Page { background, .. }) = d.node_mut(p).map(|n| &mut n.kind) {
|
||||
*background = figcraft_doc::color::Color { r: rgb[0], g: rgb[1], b: rgb[2], a: 1.0 };
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_carry_the_first_pages_colour() {
|
||||
use crate::thumbnail::{Preview, archive_preview, file_preview};
|
||||
let thumb = png(8, 4);
|
||||
// `.figcraft`: the first page's background from document.json.
|
||||
let bytes = crate::save_bytes(&doc_on([0.1, 0.2, 0.3]), Some(&thumb)).unwrap();
|
||||
let p = archive_preview(Cursor::new(&bytes), 640);
|
||||
assert_eq!(p.page, Some([0.1, 0.2, 0.3, 1.0]));
|
||||
assert_eq!(p.picture.map(|t| (t.width, t.height)), Some((8, 4)));
|
||||
// `.fig`: the canvas colour from meta.json.
|
||||
let (bytes, _) = crate::fig::export(&doc_on([0.5, 0.25, 0.75]), Some(&thumb)).unwrap();
|
||||
let p = archive_preview(Cursor::new(&bytes), 640);
|
||||
assert_eq!(p.page, Some([0.5, 0.25, 0.75, 1.0]));
|
||||
assert!(p.picture.is_some());
|
||||
// A file with nothing on it: its colour, no picture.
|
||||
let bytes = crate::save_bytes(&doc_on([0.9, 0.9, 0.9]), None).unwrap();
|
||||
assert_eq!(archive_preview(Cursor::new(&bytes), 640), Preview { page: Some([0.9, 0.9, 0.9, 1.0]), picture: None });
|
||||
// Damaged or odd metadata: no colour, never a panic.
|
||||
for meta in [&b"{"[..], b"{\"client_meta\":{\"background_color\":{\"r\":\"x\",\"g\":0,\"b\":0}}}", b"[]", b"{\"client_meta\":7}"] {
|
||||
assert_eq!(archive_preview(Cursor::new(zip(&[("meta.json", meta)])), 640).page, None, "{}", String::from_utf8_lossy(meta));
|
||||
}
|
||||
for doc in [
|
||||
&b"{\"pages\":[5],\"nodes\":{}}"[..],
|
||||
b"{\"pages\":[],\"nodes\":{}}",
|
||||
b"{\"pages\":\"x\"}",
|
||||
b"{\"pages\":[1],\"nodes\":{\"1\":{\"background\":[1,2]}}}",
|
||||
] {
|
||||
assert_eq!(archive_preview(Cursor::new(zip(&[("document.json", doc)])), 640).page, None, "{}", String::from_utf8_lossy(doc));
|
||||
}
|
||||
// Out-of-range channels are clamped; alpha defaults to opaque.
|
||||
let doc = b"{\"pages\":[1],\"nodes\":{\"1\":{\"background\":{\"r\":2,\"g\":-1,\"b\":0.5}}}}";
|
||||
assert_eq!(archive_preview(Cursor::new(zip(&[("document.json", doc)])), 640).page, Some([1.0, 0.0, 0.5, 1.0]));
|
||||
// Not an archive, or no file at all.
|
||||
assert_eq!(archive_preview(Cursor::new(b"junk".to_vec()), 640), Preview::default());
|
||||
assert_eq!(file_preview(std::path::Path::new("/nonexistent/x.figcraft"), 640), Preview::default());
|
||||
let d = dir("preview");
|
||||
let svg = d.join("a.svg");
|
||||
std::fs::write(&svg, "<svg/>").unwrap();
|
||||
assert_eq!(file_preview(&svg, 640), Preview::default());
|
||||
let image = d.join("a.png");
|
||||
std::fs::write(&image, png(5, 5)).unwrap();
|
||||
let p = file_preview(&image, 640);
|
||||
assert!(p.page.is_none() && p.picture.is_some());
|
||||
}
|
||||
@@ -17,6 +17,7 @@ figcraft-layout = { workspace = true }
|
||||
figcraft-render = { workspace = true }
|
||||
figcraft-pathops = { workspace = true }
|
||||
figcraft-engine = { workspace = true }
|
||||
figcraft-format = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -19,6 +19,7 @@ name = "English"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "A responsive product page: hero, features, figures, a quote and a footer"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "A spring plays until it settles: its mass, stiffness and damping set how long"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "A title, an agenda, a big figure, a chart and a closing slide"
|
||||
"A to Z" = "A to Z"
|
||||
"About" = "About"
|
||||
"About FigCraft" = "About FigCraft"
|
||||
"About this server" = "About this server"
|
||||
@@ -89,6 +90,7 @@ name = "English"
|
||||
"All supported properties" = "All supported properties"
|
||||
"All variables" = "All variables"
|
||||
"Alpha" = "Alpha"
|
||||
"Alphabetical" = "Alphabetical"
|
||||
"Alt+drag" = "Alt+drag"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "An analytics screen on a grid: figures, charts drawn from vectors and a table"
|
||||
"An invite link that opens FigCraft and joins" = "An invite link that opens FigCraft and joins"
|
||||
@@ -110,6 +112,7 @@ name = "English"
|
||||
"Apply variable to text" = "Apply variable to text"
|
||||
"Apply variable to visibility" = "Apply variable to visibility"
|
||||
"Apps" = "Apps"
|
||||
"Apr" = "Apr"
|
||||
"Arc" = "Arc"
|
||||
"Arc ratio" = "Arc ratio"
|
||||
"Arc start" = "Arc start"
|
||||
@@ -121,6 +124,7 @@ name = "English"
|
||||
"As typed" = "As typed"
|
||||
"Asking the server…" = "Asking the server…"
|
||||
"Assets" = "Assets"
|
||||
"Aug" = "Aug"
|
||||
"Auto" = "Auto"
|
||||
"Auto (space around)" = "Auto (space around)"
|
||||
"Auto (space between)" = "Auto (space between)"
|
||||
@@ -319,6 +323,7 @@ name = "English"
|
||||
"Create property…" = "Create property…"
|
||||
"Create style" = "Create style"
|
||||
"Create style from the selected text" = "Create style from the selected text"
|
||||
"Created" = "Created"
|
||||
"Crop" = "Crop"
|
||||
"Crop marks" = "Crop marks"
|
||||
"Ctrl+arrows" = "Ctrl+arrows"
|
||||
@@ -333,6 +338,8 @@ name = "English"
|
||||
"Darken" = "Darken"
|
||||
"Dash" = "Dash"
|
||||
"Dashboard" = "Dashboard"
|
||||
"Date created" = "Date created"
|
||||
"Dec" = "Dec"
|
||||
"Decoration style" = "Decoration style"
|
||||
"Decoration" = "Decoration"
|
||||
"Deep select" = "Deep select"
|
||||
@@ -414,6 +421,7 @@ name = "English"
|
||||
"Edit style" = "Edit style"
|
||||
"Edit variable" = "Edit variable"
|
||||
"Edit with the builder" = "Edit with the builder"
|
||||
"Edited {when}" = "Edited {when}"
|
||||
"Effect settings" = "Effect settings"
|
||||
"Effect styles" = "Effect styles"
|
||||
"Effects" = "Effects"
|
||||
@@ -464,9 +472,11 @@ name = "English"
|
||||
"F" = "F"
|
||||
"Fade" = "Fade"
|
||||
"False" = "False"
|
||||
"Feb" = "Feb"
|
||||
"Fetching from the server…" = "Fetching from the server…"
|
||||
"File" = "File"
|
||||
"File name" = "File name"
|
||||
"File not found" = "File not found"
|
||||
"File published" = "File published"
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "Files aren't end-to-end encrypted: the server's operator can read them."
|
||||
"Files nobody opens for {n} days are deleted." = { one = "Files nobody opens for {n} day are deleted.", other = "Files nobody opens for {n} days are deleted." }
|
||||
@@ -627,6 +637,7 @@ name = "English"
|
||||
"Invite link" = "Invite link"
|
||||
"Invite link copied" = "Invite link copied"
|
||||
"It asks permission to:" = "It asks permission to:"
|
||||
"Jan" = "Jan"
|
||||
"Join" = "Join"
|
||||
"Join code copied" = "Join code copied"
|
||||
"Join code or invite link" = "Join code or invite link"
|
||||
@@ -637,6 +648,8 @@ name = "English"
|
||||
"Joined {file}" = "Joined {file}"
|
||||
"Joining…" = "Joining…"
|
||||
"Join|stroke" = "Join"
|
||||
"Jul" = "Jul"
|
||||
"Jun" = "Jun"
|
||||
"Just now" = "Just now"
|
||||
"Justify" = "Justify"
|
||||
"Keep appearance" = "Keep appearance"
|
||||
@@ -653,6 +666,7 @@ name = "English"
|
||||
"Last" = "Last"
|
||||
"Last change: {note}" = "Last change: {note}"
|
||||
"Last commit" = "Last commit"
|
||||
"Last modified" = "Last modified"
|
||||
"Last on top" = "Last on top"
|
||||
"Layer blur" = "Layer blur"
|
||||
"Layer name" = "Layer name"
|
||||
@@ -711,6 +725,7 @@ name = "English"
|
||||
"Main menu" = "Main menu"
|
||||
"Make a component" = "Make a component"
|
||||
"Make slot" = "Make slot"
|
||||
"Mar" = "Mar"
|
||||
"Margin" = "Margin"
|
||||
"Mark all as read" = "Mark all as read"
|
||||
"Mark as resolved" = "Mark as resolved"
|
||||
@@ -723,6 +738,7 @@ name = "English"
|
||||
"Max W" = "Max W"
|
||||
"Max lines" = "Max lines"
|
||||
"Maximize" = "Maximize"
|
||||
"May" = "May"
|
||||
"Measure to the selection" = "Measure to the selection"
|
||||
"Merge into main" = "Merge into main"
|
||||
"Merge regions" = "Merge regions"
|
||||
@@ -774,6 +790,7 @@ name = "English"
|
||||
"Navigate to" = "Navigate to"
|
||||
"Nested instance" = "Nested instance"
|
||||
"New design file" = "New design file"
|
||||
"Newest first" = "Newest first"
|
||||
"Next or previous control in the focused region" = "Next or previous control in the focused region"
|
||||
"Next ready for dev" = "Next ready for dev"
|
||||
"Next size option" = "Next size option"
|
||||
@@ -836,6 +853,7 @@ name = "English"
|
||||
"Nothing here yet. Set a frame's status to list it." = "Nothing here yet. Set a frame's status to list it."
|
||||
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Nothing online yet. Use Share on a file to put it online and it is listed here."
|
||||
"Nothing to export" = "Nothing to export"
|
||||
"Nov" = "Nov"
|
||||
"Now" = "Now"
|
||||
"Nudge (Shift: big nudge)" = "Nudge (Shift: big nudge)"
|
||||
"Nudge amount" = "Nudge amount"
|
||||
@@ -844,10 +862,12 @@ name = "English"
|
||||
"Number ↓" = "Number ↓"
|
||||
"Numbered list" = "Numbered list"
|
||||
"Object" = "Object"
|
||||
"Oct" = "Oct"
|
||||
"Off" = "Off"
|
||||
"Offered first when filling the slot (any content is allowed)" = "Offered first when filling the slot (any content is allowed)"
|
||||
"Offered first when swapping the instance (any component is allowed)" = "Offered first when swapping the instance (any component is allowed)"
|
||||
"Offset" = "Offset"
|
||||
"Oldest first" = "Oldest first"
|
||||
"On click" = "On click"
|
||||
"On drag" = "On drag"
|
||||
"On this page" = "On this page"
|
||||
@@ -878,6 +898,7 @@ name = "English"
|
||||
"Open|comments" = "Open"
|
||||
"Options" = "Options"
|
||||
"Orange" = "Orange"
|
||||
"Order" = "Order"
|
||||
"Other apps may not open every .fig file exported here." = "Other apps may not open every .fig file exported here."
|
||||
"Others lose access and keep their copy; the file stays on the server for you" = "Others lose access and keep their copy; the file stays on the server for you"
|
||||
"Others type this under Share ▸ Join with a code" = "Others type this under Share ▸ Join with a code"
|
||||
@@ -1007,6 +1028,7 @@ name = "English"
|
||||
"Remove auto layout" = "Remove auto layout"
|
||||
"Remove dev resource" = "Remove dev resource"
|
||||
"Remove export setting" = "Remove export setting"
|
||||
"Remove from recents" = "Remove from recents"
|
||||
"Remove from server" = "Remove from server"
|
||||
"Remove from server…" = "Remove from server…"
|
||||
"Remove guide" = "Remove guide"
|
||||
@@ -1138,6 +1160,7 @@ name = "English"
|
||||
"Send backward" = "Send backward"
|
||||
"Send report" = "Send report"
|
||||
"Send to back" = "Send to back"
|
||||
"Sep" = "Sep"
|
||||
"Server address (ws://host:47900/)" = "Server address (ws://host:47900/)"
|
||||
"Server address" = "Server address"
|
||||
"Server for online sharing" = "Server for online sharing"
|
||||
@@ -1171,6 +1194,9 @@ name = "English"
|
||||
"Show device frame" = "Show device frame"
|
||||
"Show dimensions on objects" = "Show dimensions on objects"
|
||||
"Show hotspot hints" = "Show hotspot hints"
|
||||
"Show in Explorer" = "Show in Explorer"
|
||||
"Show in Finder" = "Show in Finder"
|
||||
"Show in folder" = "Show in folder"
|
||||
"Show only this on the canvas" = "Show only this on the canvas"
|
||||
"Show open" = "Show open"
|
||||
"Show section contents" = "Show section contents"
|
||||
@@ -1202,8 +1228,10 @@ name = "English"
|
||||
"Solid" = "Solid"
|
||||
"Some variants have the same property values: give each one a unique combination" = "Some variants have the same property values: give each one a unique combination"
|
||||
"Sort" = "Sort"
|
||||
"Sort by" = "Sort by"
|
||||
"Sort by date" = "Sort by date"
|
||||
"Sort by page" = "Sort by page"
|
||||
"Sort by {column}" = "Sort by {column}"
|
||||
"Space around" = "Space around"
|
||||
"Space between" = "Space between"
|
||||
"Space drag" = "Space drag"
|
||||
@@ -1440,6 +1468,7 @@ name = "English"
|
||||
"Your name in comments" = "Your name in comments"
|
||||
"Your system asks for reduced motion: animations stay off." = "Your system asks for reduced motion: animations stay off."
|
||||
"Z" = "Z"
|
||||
"Z to A" = "Z to A"
|
||||
"Zoom" = "Zoom"
|
||||
"Zoom around the pointer" = "Zoom around the pointer"
|
||||
"Zoom device to fill window" = "Zoom device to fill window"
|
||||
@@ -1466,6 +1495,7 @@ name = "English"
|
||||
"from {server}" = "from {server}"
|
||||
"hidden" = "hidden"
|
||||
"iOS" = "iOS"
|
||||
"just now" = "just now"
|
||||
"locked" = "locked"
|
||||
"no bleed" = "no bleed"
|
||||
"nobody else here yet" = "nobody else here yet"
|
||||
@@ -1477,9 +1507,11 @@ name = "English"
|
||||
"reads the document" = "reads the document"
|
||||
"the plugin" = "the plugin"
|
||||
"this session only" = "this session only"
|
||||
"yesterday" = "yesterday"
|
||||
"{instances} in {files}" = "{instances} in {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}: draft translation, waiting for a native speaker's review."
|
||||
"{length} bleed" = "{length} bleed"
|
||||
"{month} {day}, {year}" = "{month} {day}, {year}"
|
||||
"{name} cleared from the list" = "{name} cleared from the list"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} is forgotten on this computer; the file stays on {server}"
|
||||
"{name} is on the server." = "{name} is on the server."
|
||||
|
||||
@@ -20,6 +20,7 @@ status = "draft — needs native review"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Una página de producto adaptable: cabecera, funciones, cifras, una cita y un pie"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Un muelle se reproduce hasta que se asienta: su masa, rigidez y amortiguación fijan cuánto dura"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Un título, una agenda, una gran cifra, un gráfico y una diapositiva de cierre"
|
||||
"A to Z" = "De la A a la Z"
|
||||
"About" = "Acerca de"
|
||||
"About FigCraft" = "Acerca de FigCraft"
|
||||
"About this server" = "Acerca de este servidor"
|
||||
@@ -90,6 +91,7 @@ status = "draft — needs native review"
|
||||
"All supported properties" = "Todas las propiedades compatibles"
|
||||
"All variables" = "Todas las variables"
|
||||
"Alpha" = "Alfa"
|
||||
"Alphabetical" = "Alfabético"
|
||||
"Alt+drag" = "Alt+arrastrar"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Una pantalla de analítica en cuadrícula: cifras, gráficos dibujados con vectores y una tabla"
|
||||
"An invite link that opens FigCraft and joins" = "Un enlace de invitación que abre FigCraft y se une"
|
||||
@@ -111,6 +113,7 @@ status = "draft — needs native review"
|
||||
"Apply variable to text" = "Aplicar variable al texto"
|
||||
"Apply variable to visibility" = "Aplicar variable a la visibilidad"
|
||||
"Apps" = "Aplicaciones"
|
||||
"Apr" = "abr"
|
||||
"Arc" = "Arco"
|
||||
"Arc ratio" = "Proporción del arco"
|
||||
"Arc start" = "Inicio del arco"
|
||||
@@ -122,6 +125,7 @@ status = "draft — needs native review"
|
||||
"As typed" = "Como se escribió"
|
||||
"Asking the server…" = "Consultando al servidor…"
|
||||
"Assets" = "Recursos"
|
||||
"Aug" = "ago"
|
||||
"Auto" = "Auto"
|
||||
"Auto (space around)" = "Auto (espacio alrededor)"
|
||||
"Auto (space between)" = "Auto (espacio entre)"
|
||||
@@ -320,6 +324,7 @@ status = "draft — needs native review"
|
||||
"Create property…" = "Crear propiedad…"
|
||||
"Create style" = "Crear estilo"
|
||||
"Create style from the selected text" = "Crear estilo a partir del texto seleccionado"
|
||||
"Created" = "Creado"
|
||||
"Crop" = "Recortar"
|
||||
"Crop marks" = "Marcas de corte"
|
||||
"Ctrl+arrows" = "Ctrl+flechas"
|
||||
@@ -334,6 +339,8 @@ status = "draft — needs native review"
|
||||
"Darken" = "Oscurecer"
|
||||
"Dash" = "Discontinuo"
|
||||
"Dashboard" = "Panel de control"
|
||||
"Date created" = "Fecha de creación"
|
||||
"Dec" = "dic"
|
||||
"Decoration style" = "Estilo de decoración"
|
||||
"Decoration" = "Decoración"
|
||||
"Deep select" = "Selección profunda"
|
||||
@@ -415,6 +422,7 @@ status = "draft — needs native review"
|
||||
"Edit style" = "Editar estilo"
|
||||
"Edit variable" = "Editar variable"
|
||||
"Edit with the builder" = "Editar con el generador"
|
||||
"Edited {when}" = "Editado {when}"
|
||||
"Effect settings" = "Ajustes del efecto"
|
||||
"Effect styles" = "Estilos de efecto"
|
||||
"Effects" = "Efectos"
|
||||
@@ -465,9 +473,11 @@ status = "draft — needs native review"
|
||||
"F" = "F"
|
||||
"Fade" = "Desvanecido"
|
||||
"False" = "Falso"
|
||||
"Feb" = "feb"
|
||||
"Fetching from the server…" = "Obteniendo del servidor…"
|
||||
"File" = "Archivo"
|
||||
"File name" = "Nombre del archivo"
|
||||
"File not found" = "No se encuentra el archivo"
|
||||
"File published" = "Archivo publicado"
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "Los archivos no tienen cifrado de extremo a extremo: el operador del servidor puede leerlos."
|
||||
"Files nobody opens for {n} days are deleted." = { one = "Los archivos que nadie abre en {n} día se eliminan.", other = "Los archivos que nadie abre en {n} días se eliminan." }
|
||||
@@ -628,6 +638,7 @@ status = "draft — needs native review"
|
||||
"Invite link" = "Enlace de invitación"
|
||||
"Invite link copied" = "Enlace de invitación copiado"
|
||||
"It asks permission to:" = "Pide permiso para:"
|
||||
"Jan" = "ene"
|
||||
"Join" = "Unirse"
|
||||
"Join code copied" = "Código para unirse copiado"
|
||||
"Join code or invite link" = "Código para unirse o enlace de invitación"
|
||||
@@ -638,6 +649,8 @@ status = "draft — needs native review"
|
||||
"Joined {file}" = "Conectado a {file}"
|
||||
"Joining…" = "Uniéndose…"
|
||||
"Join|stroke" = "Unión"
|
||||
"Jul" = "jul"
|
||||
"Jun" = "jun"
|
||||
"Just now" = "Justo ahora"
|
||||
"Justify" = "Justificar"
|
||||
"Keep appearance" = "Mantener apariencia"
|
||||
@@ -654,6 +667,7 @@ status = "draft — needs native review"
|
||||
"Last" = "Último"
|
||||
"Last change: {note}" = "Último cambio: {note}"
|
||||
"Last commit" = "Último commit"
|
||||
"Last modified" = "Última modificación"
|
||||
"Last on top" = "Último encima"
|
||||
"Layer blur" = "Desenfoque de capa"
|
||||
"Layer name" = "Nombre de capa"
|
||||
@@ -712,6 +726,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "Menú principal"
|
||||
"Make a component" = "Crea un componente"
|
||||
"Make slot" = "Convertir en ranura"
|
||||
"Mar" = "mar"
|
||||
"Margin" = "Margen"
|
||||
"Mark all as read" = "Marcar todo como leído"
|
||||
"Mark as resolved" = "Marcar como resuelto"
|
||||
@@ -724,6 +739,7 @@ status = "draft — needs native review"
|
||||
"Max W" = "Máx. W"
|
||||
"Max lines" = "Máx. de líneas"
|
||||
"Maximize" = "Maximizar"
|
||||
"May" = "may"
|
||||
"Measure to the selection" = "Medir hasta la selección"
|
||||
"Merge into main" = "Fusionar en la principal"
|
||||
"Merge regions" = "Fusionar regiones"
|
||||
@@ -775,6 +791,7 @@ status = "draft — needs native review"
|
||||
"Navigate to" = "Navegar a"
|
||||
"Nested instance" = "Instancia anidada"
|
||||
"New design file" = "Nuevo archivo de diseño"
|
||||
"Newest first" = "Más recientes primero"
|
||||
"Next or previous control in the focused region" = "Control siguiente o anterior de la región enfocada"
|
||||
"Next ready for dev" = "Siguiente listo para desarrollo"
|
||||
"Next size option" = "Siguiente opción de tamaño"
|
||||
@@ -837,6 +854,7 @@ status = "draft — needs native review"
|
||||
"Nothing here yet. Set a frame's status to list it." = "Aún no hay nada. Asigna un estado a un marco para verlo aquí."
|
||||
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Aún no hay nada en línea. Usa Compartir en un archivo para ponerlo en línea y aparecerá aquí."
|
||||
"Nothing to export" = "Nada que exportar"
|
||||
"Nov" = "nov"
|
||||
"Now" = "Ahora"
|
||||
"Nudge (Shift: big nudge)" = "Desplazar (Mayús: desplazamiento grande)"
|
||||
"Nudge amount" = "Cantidad de desplazamiento"
|
||||
@@ -845,10 +863,12 @@ status = "draft — needs native review"
|
||||
"Number ↓" = "Número ↓"
|
||||
"Numbered list" = "Lista numerada"
|
||||
"Object" = "Objeto"
|
||||
"Oct" = "oct"
|
||||
"Off" = "No"
|
||||
"Offered first when filling the slot (any content is allowed)" = "Se ofrecen primero al rellenar la ranura (se permite cualquier contenido)"
|
||||
"Offered first when swapping the instance (any component is allowed)" = "Se ofrecen primero al intercambiar la instancia (se permite cualquier componente)"
|
||||
"Offset" = "Desplazamiento"
|
||||
"Oldest first" = "Más antiguos primero"
|
||||
"On click" = "Al hacer clic"
|
||||
"On drag" = "Al arrastrar"
|
||||
"On this page" = "En esta página"
|
||||
@@ -879,6 +899,7 @@ status = "draft — needs native review"
|
||||
"Open|comments" = "Abiertos"
|
||||
"Options" = "Opciones"
|
||||
"Orange" = "Naranja"
|
||||
"Order" = "Orden"
|
||||
"Other apps may not open every .fig file exported here." = "Puede que otras aplicaciones no abran todos los archivos .fig exportados aquí."
|
||||
"Others lose access and keep their copy; the file stays on the server for you" = "Los demás pierden el acceso y conservan su copia; el archivo se queda en el servidor para ti"
|
||||
"Others type this under Share ▸ Join with a code" = "Los demás lo escriben en Compartir ▸ Unirse con un código"
|
||||
@@ -1008,6 +1029,7 @@ status = "draft — needs native review"
|
||||
"Remove auto layout" = "Quitar diseño automático"
|
||||
"Remove dev resource" = "Quitar recurso de desarrollo"
|
||||
"Remove export setting" = "Quitar ajuste de exportación"
|
||||
"Remove from recents" = "Quitar de recientes"
|
||||
"Remove from server" = "Quitar del servidor"
|
||||
"Remove from server…" = "Quitar del servidor…"
|
||||
"Remove guide" = "Quitar guía"
|
||||
@@ -1139,6 +1161,7 @@ status = "draft — needs native review"
|
||||
"Send backward" = "Enviar atrás"
|
||||
"Send report" = "Enviar denuncia"
|
||||
"Send to back" = "Enviar al fondo"
|
||||
"Sep" = "sept"
|
||||
"Server address (ws://host:47900/)" = "Dirección del servidor (ws://host:47900/)"
|
||||
"Server address" = "Dirección del servidor"
|
||||
"Server for online sharing" = "Servidor para compartir en línea"
|
||||
@@ -1172,6 +1195,9 @@ status = "draft — needs native review"
|
||||
"Show device frame" = "Mostrar carcasa del dispositivo"
|
||||
"Show dimensions on objects" = "Mostrar dimensiones en los objetos"
|
||||
"Show hotspot hints" = "Mostrar indicaciones de zonas activas"
|
||||
"Show in Explorer" = "Mostrar en el Explorador"
|
||||
"Show in Finder" = "Mostrar en el Finder"
|
||||
"Show in folder" = "Mostrar en la carpeta"
|
||||
"Show only this on the canvas" = "Mostrar solo esto en el lienzo"
|
||||
"Show open" = "Ver abiertos"
|
||||
"Show section contents" = "Mostrar contenido de la sección"
|
||||
@@ -1203,8 +1229,10 @@ status = "draft — needs native review"
|
||||
"Solid" = "Sólido"
|
||||
"Some variants have the same property values: give each one a unique combination" = "Algunas variantes tienen los mismos valores de propiedad: da a cada una una combinación única"
|
||||
"Sort" = "Ordenar"
|
||||
"Sort by" = "Ordenar por"
|
||||
"Sort by date" = "Ordenar por fecha"
|
||||
"Sort by page" = "Ordenar por página"
|
||||
"Sort by {column}" = "Ordenar por {column}"
|
||||
"Space around" = "Espacio alrededor"
|
||||
"Space between" = "Espacio entre"
|
||||
"Space drag" = "Espacio y arrastrar"
|
||||
@@ -1441,6 +1469,7 @@ status = "draft — needs native review"
|
||||
"Your name in comments" = "Tu nombre en los comentarios"
|
||||
"Your system asks for reduced motion: animations stay off." = "Tu sistema pide reducir el movimiento: las animaciones siguen desactivadas."
|
||||
"Z" = "Z"
|
||||
"Z to A" = "De la Z a la A"
|
||||
"Zoom" = "Zoom"
|
||||
"Zoom around the pointer" = "Zoom alrededor del puntero"
|
||||
"Zoom device to fill window" = "Ampliar el dispositivo hasta llenar la ventana"
|
||||
@@ -1467,6 +1496,7 @@ status = "draft — needs native review"
|
||||
"from {server}" = "de {server}"
|
||||
"hidden" = "oculta"
|
||||
"iOS" = "iOS"
|
||||
"just now" = "justo ahora"
|
||||
"locked" = "bloqueada"
|
||||
"no bleed" = "sin sangrado"
|
||||
"nobody else here yet" = "aún no hay nadie más"
|
||||
@@ -1478,9 +1508,11 @@ status = "draft — needs native review"
|
||||
"reads the document" = "lee el documento"
|
||||
"the plugin" = "el plugin"
|
||||
"this session only" = "solo esta sesión"
|
||||
"yesterday" = "ayer"
|
||||
"{instances} in {files}" = "{instances} en {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}: traducción en borrador, pendiente de revisión por un hablante nativo."
|
||||
"{length} bleed" = "sangrado de {length}"
|
||||
"{month} {day}, {year}" = "{day} {month} {year}"
|
||||
"{name} cleared from the list" = "{name} se borró de la lista"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} se olvidó en este ordenador; el archivo se queda en {server}"
|
||||
"{name} is on the server." = "{name} está en el servidor."
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
//! for the frames that follow (all false: released); `gamepad` presses a controller button in the
|
||||
//! prototype playing (presenter or preview pane)
|
||||
//! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog)
|
||||
//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, palette?, focusRegion?, preview?, presentScroll?, …}`
|
||||
//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}`
|
||||
//! - `ui.screenshot {path}` — PNG of the window (needs a visible window)
|
||||
//! - `ui.render {path, scale?, ids?}` — PNG of the document (no UI chrome; works without a window)
|
||||
//! - `window.new {path?, index?}` → `{window}` / `window.list {}` / `window.focus {id}` — several
|
||||
@@ -358,6 +358,16 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
|
||||
if let Some(section) = p.get("homeSection").and_then(Value::as_str).and_then(crate::home::Section::parse) {
|
||||
app.ui.home_view.section = section;
|
||||
}
|
||||
if let Some(view) = p.get("homeRecentsView").and_then(Value::as_str).and_then(crate::home_recents::View::parse) {
|
||||
view.store(&mut app.session.prefs);
|
||||
}
|
||||
if let Some(key) = p.get("homeRecentsSort").and_then(Value::as_str).and_then(crate::home_recents::SortKey::parse) {
|
||||
crate::home_recents::Sort::by(key).store(&mut app.session.prefs);
|
||||
}
|
||||
if let Some(order) = p.get("homeRecentsOrder").and_then(Value::as_str).filter(|o| matches!(*o, "asc" | "desc")) {
|
||||
let sort = crate::home_recents::Sort::of(&app.session.prefs);
|
||||
crate::home_recents::Sort { descending: order == "desc", ..sort }.store(&mut app.session.prefs);
|
||||
}
|
||||
if let Some(h) = p.get("palette").and_then(Value::as_bool) {
|
||||
app.ui.palette = if h { Some(String::new()) } else { None };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The home screen: Recents (new file, open, join with a code, the Getting started tips, open and
|
||||
//! recent files, and templates to start from on a first run), Online (your online files:
|
||||
//! recent files ([`crate::home_recents`]), and templates to start from on a first run), Online (your online files:
|
||||
//! [`crate::online`]) and Templates (the starter templates as cards with thumbnails rendered from
|
||||
//! the documents themselves, searched and filtered by kind).
|
||||
|
||||
@@ -54,6 +54,8 @@ pub struct Home {
|
||||
/// The Templates search text and kind filter.
|
||||
pub query: String,
|
||||
pub kind: Option<Kind>,
|
||||
/// The recent files: what is known about each, and the selection.
|
||||
pub recents: crate::home_recents::Recents,
|
||||
}
|
||||
|
||||
const SIDEBAR: f32 = 240.0;
|
||||
@@ -264,20 +266,7 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
subheading(ui, t, tr("Recent files"));
|
||||
let mut open_path = None;
|
||||
for p in recents.iter().take(12) {
|
||||
let name = std::path::Path::new(p).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| p.clone());
|
||||
let row = FileRow { mark: None, name: &name, ink: t.text, detail: p, detail_size: 10.0, detail_ink: t.text_tertiary };
|
||||
if listed(ui, t, ui.id().with(("recent", p.as_str())), 32.0, &row) {
|
||||
open_path = Some(p.clone());
|
||||
}
|
||||
}
|
||||
if let Some(p) = open_path
|
||||
&& app.run("file.open", json!({"path": p})).is_some()
|
||||
{
|
||||
app.ui.home = false;
|
||||
}
|
||||
crate::home_recents::section(app, ui, t);
|
||||
}
|
||||
|
||||
/// The file joined from the home screen (a code, or one of your online files) opened in a tab of
|
||||
|
||||
@@ -0,0 +1,936 @@
|
||||
//! The home screen's recent files: a grid of cards, each with the picture its file carries on the
|
||||
//! colour of its page, or a table (name, last modified, created). Sorted by name, creation or last
|
||||
//! change either way round; a click selects a file, a double-click or Enter opens it, the arrow
|
||||
//! keys move the selection, and a right-click offers Open, Show in folder and Remove from recents.
|
||||
//!
|
||||
//! What the files on disk say (whether they exist, their dates, their pictures) is read off the
|
||||
//! frame's thread: only the cards in view read their pictures, each cached by path and modification
|
||||
//! time, and a frame never waits for a file.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Id, Key, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use figcraft_engine::Prefs;
|
||||
use figcraft_format::thumbnail::{FileKind, Preview};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::a11y;
|
||||
use crate::i18n::{tr, tr_noop, trf, trn};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{ACCENT, Tokens, regular, semibold};
|
||||
use crate::widgets;
|
||||
|
||||
/// The gap between cards, across and down.
|
||||
pub const GAP: f32 = 32.0;
|
||||
/// The narrowest and widest a card gets (columns come and go to keep cards within these).
|
||||
pub const CARD_MIN: f32 = 260.0;
|
||||
pub const CARD_MAX: f32 = 360.0;
|
||||
/// The footer under a card's picture: its icon, name and time.
|
||||
const FOOTER_H: f32 = 58.0;
|
||||
/// A table row, and the table's header.
|
||||
const ROW_H: f32 = 44.0;
|
||||
const HEADER_H: f32 = 32.0;
|
||||
/// Pictures are decoded at most this many pixels on their longer side (the widest card on a
|
||||
/// double-density screen).
|
||||
const PICTURE_PX: u32 = 720;
|
||||
/// How long a picture takes to fade in (seconds).
|
||||
const FADE_SECS: f64 = 0.2;
|
||||
/// Most recent files shown.
|
||||
pub const SHOWN: usize = 12;
|
||||
/// The section whose view this is (`Prefs::home_views`).
|
||||
const SECTION: &str = "recents";
|
||||
|
||||
/// Cards or a table.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum View {
|
||||
#[default]
|
||||
Grid,
|
||||
List,
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// The id `ui.set {homeRecentsView}`, `ui.inspect` and the preferences use.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
View::Grid => "grid",
|
||||
View::List => "list",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(id: &str) -> Option<View> {
|
||||
[View::Grid, View::List].into_iter().find(|v| v.id() == id)
|
||||
}
|
||||
|
||||
/// The view the preferences keep for the recent files.
|
||||
pub fn of(prefs: &Prefs) -> View {
|
||||
prefs.home_views.get(SECTION).and_then(|v| View::parse(v)).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn store(self, prefs: &mut Prefs) {
|
||||
prefs.home_views.insert(SECTION.into(), self.id().into());
|
||||
}
|
||||
}
|
||||
|
||||
/// What the files are ordered by.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SortKey {
|
||||
Name,
|
||||
Created,
|
||||
Modified,
|
||||
}
|
||||
|
||||
impl SortKey {
|
||||
/// The sort menu, top to bottom.
|
||||
pub const ALL: [SortKey; 3] = [SortKey::Name, SortKey::Created, SortKey::Modified];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
SortKey::Name => "name",
|
||||
SortKey::Created => "created",
|
||||
SortKey::Modified => "modified",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(id: &str) -> Option<SortKey> {
|
||||
SortKey::ALL.into_iter().find(|k| k.id() == id)
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
SortKey::Name => tr_noop("Alphabetical"),
|
||||
SortKey::Created => tr_noop("Date created"),
|
||||
SortKey::Modified => tr_noop("Last modified"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The order of the recent files: by what, and which way.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Sort {
|
||||
pub key: SortKey,
|
||||
/// Newest first (Z to A by name).
|
||||
pub descending: bool,
|
||||
}
|
||||
|
||||
impl Default for Sort {
|
||||
fn default() -> Self {
|
||||
Sort::by(SortKey::Modified)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sort {
|
||||
/// `key` its usual way round: names A to Z, dates newest first.
|
||||
pub fn by(key: SortKey) -> Sort {
|
||||
Sort { key, descending: key != SortKey::Name }
|
||||
}
|
||||
|
||||
/// The order the preferences keep (the default for anything they don't know).
|
||||
pub fn of(prefs: &Prefs) -> Sort {
|
||||
match SortKey::parse(&prefs.home_sort) {
|
||||
Some(key) => Sort { key, descending: prefs.home_order != "asc" },
|
||||
None => Sort::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn store(self, prefs: &mut Prefs) {
|
||||
prefs.home_sort = self.key.id().into();
|
||||
prefs.home_order = if self.descending { "desc" } else { "asc" }.into();
|
||||
}
|
||||
|
||||
/// A header click on `key`: the other way round when it is the order already, else `key`'s
|
||||
/// usual way.
|
||||
pub fn clicked(self, key: SortKey) -> Sort {
|
||||
if self.key == key { Sort { key, descending: !self.descending } } else { Sort::by(key) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Columns for a grid `width` wide, and the cards' width: as many columns as keep each card
|
||||
/// between [`CARD_MIN`] and [`CARD_MAX`], at least one (narrower than [`CARD_MIN`] when the width
|
||||
/// is).
|
||||
pub fn grid_columns(width: f32) -> (usize, f32) {
|
||||
let w = if width.is_finite() { width.max(1.0) } else { CARD_MIN };
|
||||
let card = |n: usize| (w - GAP * (n as f32 - 1.0)) / n as f32;
|
||||
let mut n = ((w + GAP) / (CARD_MAX + GAP)).ceil().clamp(1.0, 64.0) as usize;
|
||||
while n > 1 && card(n) < CARD_MIN {
|
||||
n -= 1;
|
||||
}
|
||||
(n, card(n).min(CARD_MAX))
|
||||
}
|
||||
|
||||
/// A card `width` wide: its picture's height (16:9) and its whole height.
|
||||
pub fn card_heights(width: f32) -> (f32, f32) {
|
||||
let picture = (width * 9.0 / 16.0).round();
|
||||
(picture, picture + FOOTER_H)
|
||||
}
|
||||
|
||||
/// How long ago `then` was at `now` (seconds since 1970): "just now", "5 minutes ago",
|
||||
/// "3 hours ago", "yesterday", "4 days ago", then the date ("Mar 4, 2026", in UTC).
|
||||
pub fn ago(now: u64, then: u64) -> String {
|
||||
let d = now.saturating_sub(then);
|
||||
let n = |div: u64| usize::try_from(d / div).unwrap_or(usize::MAX);
|
||||
match d {
|
||||
0..60 => tr("just now").into(),
|
||||
60..3600 => trn("{n} minutes ago", n(60), &[]),
|
||||
3600..86_400 => trn("{n} hours ago", n(3600), &[]),
|
||||
86_400..172_800 => tr("yesterday").into(),
|
||||
172_800..604_800 => trn("{n} days ago", n(86_400), &[]),
|
||||
_ => date(then),
|
||||
}
|
||||
}
|
||||
|
||||
/// "Edited …" for a file last changed at `then`.
|
||||
pub fn edited(now: u64, then: u64) -> String {
|
||||
trf("Edited {when}", &[("when", &ago(now, then))])
|
||||
}
|
||||
|
||||
const MONTHS: [&str; 12] = [
|
||||
tr_noop("Jan"),
|
||||
tr_noop("Feb"),
|
||||
tr_noop("Mar"),
|
||||
tr_noop("Apr"),
|
||||
tr_noop("May"),
|
||||
tr_noop("Jun"),
|
||||
tr_noop("Jul"),
|
||||
tr_noop("Aug"),
|
||||
tr_noop("Sep"),
|
||||
tr_noop("Oct"),
|
||||
tr_noop("Nov"),
|
||||
tr_noop("Dec"),
|
||||
];
|
||||
|
||||
/// `secs` since 1970 as a date in UTC: "Mar 4, 2026".
|
||||
fn date(secs: u64) -> String {
|
||||
// Days to a civil date (Howard Hinnant's days_from_civil, inverted).
|
||||
let z = i64::try_from(secs / 86_400).unwrap_or(0) + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
let name = usize::try_from(month - 1).ok().and_then(|m| MONTHS.get(m)).map_or("", |m| tr(m));
|
||||
trf("{month} {day}, {year}", &[("month", &name), ("day", &day), ("year", &year)])
|
||||
}
|
||||
|
||||
/// What a file on disk says about itself.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Stat {
|
||||
pub exists: bool,
|
||||
/// Seconds since 1970.
|
||||
pub modified: Option<u64>,
|
||||
/// Where the platform keeps it.
|
||||
pub created: Option<u64>,
|
||||
}
|
||||
|
||||
/// A recent file as the screen shows it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Shown {
|
||||
pub path: String,
|
||||
/// The file name without its folder.
|
||||
pub name: String,
|
||||
/// `None` until the file has been looked at.
|
||||
pub stat: Option<Stat>,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
fn new(path: &str, stat: Option<Stat>) -> Shown {
|
||||
// Shown without its extension, like a document's name; the full path is the tooltip.
|
||||
let p = Path::new(path);
|
||||
let name = p.file_stem().or_else(|| p.file_name()).map_or_else(|| path.to_string(), |f| f.to_string_lossy().into_owned());
|
||||
Shown { path: path.to_string(), name, stat }
|
||||
}
|
||||
|
||||
fn date(&self, key: SortKey) -> Option<u64> {
|
||||
let s = self.stat?;
|
||||
match key {
|
||||
SortKey::Modified => s.modified,
|
||||
SortKey::Created => s.created,
|
||||
SortKey::Name => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The line under the name: "Edited 3 hours ago", "File not found", nothing yet.
|
||||
fn detail(&self, now: u64) -> String {
|
||||
match self.stat {
|
||||
Some(Stat { exists: false, .. }) => tr("File not found").into(),
|
||||
Some(Stat { modified: Some(m), .. }) => edited(now, m),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `files` in `sort` order. Files without the date sorted by come last either way round, in the
|
||||
/// order they had; names compare without regard to case.
|
||||
pub fn sorted(mut files: Vec<Shown>, sort: Sort) -> Vec<Shown> {
|
||||
match sort.key {
|
||||
SortKey::Name => files.sort_by(|a, b| {
|
||||
let o = a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.path.cmp(&b.path));
|
||||
if sort.descending { o.reverse() } else { o }
|
||||
}),
|
||||
key => files.sort_by(|a, b| match (a.date(key), b.date(key)) {
|
||||
(Some(x), Some(y)) => {
|
||||
if sort.descending {
|
||||
y.cmp(&x)
|
||||
} else {
|
||||
x.cmp(&y)
|
||||
}
|
||||
}
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
}),
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
/// A file's picture as the screen has it.
|
||||
struct Ready {
|
||||
/// The modification time of the file it was read from.
|
||||
modified: Option<u64>,
|
||||
page: Option<Color32>,
|
||||
texture: Option<egui::TextureHandle>,
|
||||
/// When it came in (for its fade).
|
||||
arrived: f64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Known {
|
||||
stat: Option<Stat>,
|
||||
/// Whether the dates were asked for since the recent files came into view, and whether that is
|
||||
/// still under way.
|
||||
asked: bool,
|
||||
pending: bool,
|
||||
/// The picture shown (an older one stays until a changed file's new one is in).
|
||||
shown: Option<Ready>,
|
||||
/// The picture being read, by the file's modification time.
|
||||
wanted: Option<Option<u64>>,
|
||||
}
|
||||
|
||||
/// The recent files' state: what is known about each file, the reader, the selection.
|
||||
#[derive(Default)]
|
||||
pub struct Recents {
|
||||
files: HashMap<String, Known>,
|
||||
loader: Option<Loader>,
|
||||
/// The selected file (a click, the arrow keys, a right-click).
|
||||
pub selected: Option<String>,
|
||||
/// The pass the recent files were last drawn in: coming back to them reads the dates again.
|
||||
drawn: u64,
|
||||
}
|
||||
|
||||
enum Job {
|
||||
Stat(String),
|
||||
Preview(String, Option<u64>),
|
||||
}
|
||||
|
||||
enum Reply {
|
||||
Stat(String, Stat),
|
||||
Preview(String, Option<u64>, Preview),
|
||||
}
|
||||
|
||||
fn secs(t: std::io::Result<std::time::SystemTime>) -> Option<u64> {
|
||||
t.ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs())
|
||||
}
|
||||
|
||||
fn run(job: Job) -> Reply {
|
||||
match job {
|
||||
Job::Stat(path) => {
|
||||
let meta = std::fs::metadata(&path).ok().filter(std::fs::Metadata::is_file);
|
||||
let stat = Stat {
|
||||
exists: meta.is_some(),
|
||||
modified: meta.as_ref().and_then(|m| secs(m.modified())),
|
||||
created: meta.as_ref().and_then(|m| secs(m.created())),
|
||||
};
|
||||
Reply::Stat(path, stat)
|
||||
}
|
||||
Job::Preview(path, modified) => {
|
||||
let preview = figcraft_format::thumbnail::file_preview(Path::new(&path), PICTURE_PX);
|
||||
Reply::Preview(path, modified, preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads files on a thread of its own (on the web, where there are no files to read, in place).
|
||||
struct Loader {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
thread: Option<(std::sync::mpsc::Sender<Job>, std::sync::mpsc::Receiver<Reply>)>,
|
||||
/// Replies read in place.
|
||||
done: Vec<Reply>,
|
||||
}
|
||||
|
||||
impl Loader {
|
||||
fn new(ctx: &egui::Context) -> Loader {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let (tx, jobs) = std::sync::mpsc::channel::<Job>();
|
||||
let (done_tx, rx) = std::sync::mpsc::channel::<Reply>();
|
||||
let ctx = ctx.clone();
|
||||
let spawned = std::thread::Builder::new().name("figcraft-recents".into()).spawn(move || {
|
||||
while let Ok(job) = jobs.recv() {
|
||||
let reply = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(job)));
|
||||
if let Ok(reply) = reply
|
||||
&& done_tx.send(reply).is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
let thread = match spawned {
|
||||
Ok(_) => Some((tx, rx)),
|
||||
Err(e) => {
|
||||
log::warn!("recent files: no reader thread ({e}); reading in place");
|
||||
None
|
||||
}
|
||||
};
|
||||
Loader { thread, done: Vec::new() }
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = ctx;
|
||||
Loader { done: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(&mut self, job: Job) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let job = match &self.thread {
|
||||
Some((tx, _)) => match tx.send(job) {
|
||||
Ok(()) => return,
|
||||
Err(std::sync::mpsc::SendError(job)) => job,
|
||||
},
|
||||
None => job,
|
||||
};
|
||||
self.done.push(run(job));
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Vec<Reply> {
|
||||
let out = std::mem::take(&mut self.done);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let out = match &self.thread {
|
||||
Some((_, rx)) => out.into_iter().chain(rx.try_iter()).collect(),
|
||||
None => out,
|
||||
};
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Recents {
|
||||
/// Take in what the reader found, forget files no longer listed, and ask for the dates of
|
||||
/// files not looked at since the recent files came into view. `time`: the input time.
|
||||
fn refresh(&mut self, ctx: &egui::Context, paths: &[String], time: f64) {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
if pass > self.drawn.saturating_add(1) {
|
||||
self.files.values_mut().for_each(|k| k.asked = false);
|
||||
}
|
||||
self.drawn = pass;
|
||||
let loader = self.loader.get_or_insert_with(|| Loader::new(ctx));
|
||||
for reply in loader.poll() {
|
||||
match reply {
|
||||
Reply::Stat(path, stat) => {
|
||||
if let Some(k) = self.files.get_mut(&path) {
|
||||
k.stat = Some(stat);
|
||||
k.pending = false;
|
||||
}
|
||||
}
|
||||
Reply::Preview(path, modified, preview) => {
|
||||
if let Some(k) = self.files.get_mut(&path)
|
||||
&& k.wanted == Some(modified)
|
||||
{
|
||||
let texture = preview.picture.map(|p| {
|
||||
let size = [p.width as usize, p.height as usize];
|
||||
let image = egui::ColorImage::from_rgba_unmultiplied(size, &p.rgba);
|
||||
ctx.load_texture(format!("recent-file:{path}"), image, egui::TextureOptions::LINEAR)
|
||||
});
|
||||
let page = preview.page.map(|[r, g, b, a]| crate::theme::c32(figcraft_color::Color { r, g, b, a }));
|
||||
// A changed file's new picture shows at once; a first one fades in.
|
||||
let arrived = if k.shown.is_some() { f64::NEG_INFINITY } else { time };
|
||||
k.shown = Some(Ready { modified, page, texture, arrived });
|
||||
k.wanted = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.files.retain(|p, _| paths.contains(p));
|
||||
if self.selected.as_ref().is_some_and(|s| !paths.contains(s)) {
|
||||
self.selected = None;
|
||||
}
|
||||
for p in paths {
|
||||
let k = self.files.entry(p.clone()).or_default();
|
||||
if !k.pending && !k.asked {
|
||||
k.pending = true;
|
||||
k.asked = true;
|
||||
loader.submit(Job::Stat(p.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture for `path` (asking for it when the card is in view and the file has changed
|
||||
/// since): its page colour, its texture and its opacity while it fades in. `None` while there is
|
||||
/// nothing to show yet, and for a file that isn't there.
|
||||
fn picture(&mut self, path: &str, visible: bool, time: f64) -> Option<(Option<Color32>, Option<egui::TextureHandle>, f32)> {
|
||||
let k = self.files.get_mut(path)?;
|
||||
let stat = k.stat.filter(|s| s.exists)?;
|
||||
let current = k.shown.as_ref().is_some_and(|r| r.modified == stat.modified) || k.wanted == Some(stat.modified);
|
||||
if visible
|
||||
&& !current
|
||||
&& let Some(loader) = self.loader.as_mut()
|
||||
{
|
||||
loader.submit(Job::Preview(path.to_string(), stat.modified));
|
||||
k.wanted = Some(stat.modified);
|
||||
}
|
||||
let r = k.shown.as_ref()?;
|
||||
Some((r.page, r.texture.clone(), (((time - r.arrived) / FADE_SECS) as f32).clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
/// The files as listed, with what is known about them.
|
||||
fn shown(&self, paths: &[String]) -> Vec<Shown> {
|
||||
paths.iter().map(|p| Shown::new(p, self.files.get(p).and_then(|k| k.stat))).collect()
|
||||
}
|
||||
|
||||
/// For `ui.inspect`: each file listed, whether it is there and whether its picture is in.
|
||||
pub fn inspect(&self, prefs: &Prefs) -> Value {
|
||||
let paths: Vec<String> = prefs.recent_files.iter().take(SHOWN).cloned().collect();
|
||||
let files: Vec<Value> = sorted(self.shown(&paths), Sort::of(prefs))
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let k = self.files.get(&f.path);
|
||||
let picture = match k.map(|k| (k.shown.as_ref(), k.wanted.is_some())) {
|
||||
Some((Some(Ready { texture: Some(_), .. }), _)) => "picture",
|
||||
Some((Some(Ready { page: Some(_), .. }), _)) => "page",
|
||||
Some((Some(_), _)) => "placeholder",
|
||||
Some((None, true)) => "loading",
|
||||
_ => "none",
|
||||
};
|
||||
json!({"path": f.path, "name": f.name, "exists": f.stat.map(|s| s.exists), "picture": picture, "selected": self.selected.as_deref() == Some(f.path.as_str())})
|
||||
})
|
||||
.collect();
|
||||
json!(files)
|
||||
}
|
||||
}
|
||||
|
||||
/// The recent files for `ui.inspect`: `{view, sort, order, files: [{path, name, exists, picture,
|
||||
/// selected}]}` in the order shown (`picture`: `none`, `loading`, `picture`, `page` or
|
||||
/// `placeholder`).
|
||||
pub fn inspect(app: &FigApp) -> Value {
|
||||
let prefs = &app.session.prefs;
|
||||
let sort = Sort::of(prefs);
|
||||
json!({
|
||||
"view": View::of(prefs).id(),
|
||||
"sort": sort.key.id(),
|
||||
"order": if sort.descending { "desc" } else { "asc" },
|
||||
"files": app.ui.home_view.recents.inspect(prefs),
|
||||
})
|
||||
}
|
||||
|
||||
/// What the user asked for on the recent files this frame.
|
||||
enum Action {
|
||||
Open(String),
|
||||
Reveal(String),
|
||||
Remove(String),
|
||||
Sort(Sort),
|
||||
View(View),
|
||||
}
|
||||
|
||||
/// The Recent files heading, its toolbar and the files as cards or a table.
|
||||
pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
let paths: Vec<String> = app.session.prefs.recent_files.iter().take(SHOWN).cloned().collect();
|
||||
let time = ui.input(|i| i.time);
|
||||
let now = figcraft_engine::cmd::comment::now_secs();
|
||||
let reduce_motion = app.reduce_motion();
|
||||
let (view, sort) = (View::of(&app.session.prefs), Sort::of(&app.session.prefs));
|
||||
let state = &mut app.ui.home_view.recents;
|
||||
state.refresh(ui.ctx(), &paths, time);
|
||||
let files = sorted(state.shown(&paths), sort);
|
||||
let with_created = files.iter().any(|f| f.stat.is_some_and(|s| s.created.is_some()));
|
||||
let mut action = toolbar(ui, t, view, sort, with_created);
|
||||
ui.add_space(16.0);
|
||||
let chosen = match view {
|
||||
View::Grid => grid(ui, t, state, &files, now, time, reduce_motion),
|
||||
View::List => list(ui, t, state, &files, now, sort, with_created),
|
||||
};
|
||||
action = action.or(chosen);
|
||||
match action {
|
||||
Some(Action::Open(path)) => {
|
||||
if app.run("file.open", json!({"path": path})).is_some() {
|
||||
app.ui.home = false;
|
||||
}
|
||||
}
|
||||
Some(Action::Reveal(path)) => app.services.reveal_path(Path::new(&path)),
|
||||
Some(Action::Remove(path)) => {
|
||||
app.run("file.openRecent", json!({"remove": path}));
|
||||
app.services.save_prefs(&app.session.prefs);
|
||||
}
|
||||
Some(Action::Sort(sort)) => {
|
||||
sort.store(&mut app.session.prefs);
|
||||
app.services.save_prefs(&app.session.prefs);
|
||||
}
|
||||
Some(Action::View(view)) => {
|
||||
view.store(&mut app.session.prefs);
|
||||
app.services.save_prefs(&app.session.prefs);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Recent files", and at the right the sort menu and the grid and list buttons.
|
||||
fn toolbar(ui: &mut Ui, t: &Tokens, view: View, sort: Sort, with_created: bool) -> Option<Action> {
|
||||
let mut action = None;
|
||||
ui.add_space(28.0);
|
||||
let width = ui.available_width();
|
||||
let (row, _) = ui.allocate_exact_size(vec2(width, widgets::FIELD_H), Sense::hover());
|
||||
ui.painter().text(pos2(row.min.x, row.center().y), Align2::LEFT_CENTER, tr("Recent files"), semibold(12.0), t.text_secondary);
|
||||
let toggle = Rect::from_min_size(pos2(row.max.x - 56.0, row.min.y), vec2(56.0, widgets::FIELD_H));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(toggle), |ui| {
|
||||
let active = Some(if view == View::Grid { 0 } else { 1 });
|
||||
match widgets::segmented(ui, &[(Icon::ViewGrid, tr("Show as grid")), (Icon::ViewList, tr("Show as list"))], active, 28.0) {
|
||||
Some(0) if view != View::Grid => action = Some(Action::View(View::Grid)),
|
||||
Some(1) if view != View::List => action = Some(Action::View(View::List)),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
let label = tr(sort.key.label());
|
||||
let w = (ui.painter().layout_no_wrap(label.to_string(), regular(11.0), t.text).size().x + 40.0).max(110.0);
|
||||
let menu = Rect::from_min_size(pos2(toggle.min.x - 8.0 - w, row.min.y), vec2(w, widgets::FIELD_H));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(menu), |ui| {
|
||||
let resp = widgets::dropdown(ui, label, w, None);
|
||||
crate::right::menu_popup(&resp).show(|ui| {
|
||||
heading(ui, t, tr("Sort by"));
|
||||
for key in SortKey::ALL.into_iter().filter(|k| *k != SortKey::Created || with_created) {
|
||||
if crate::right::menu_check(ui, sort.key == key, tr(key.label()), "") {
|
||||
action = Some(Action::Sort(Sort { key, ..Sort::by(key) }));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
heading(ui, t, tr("Order"));
|
||||
let (first, second) = if sort.key == SortKey::Name { (tr("A to Z"), tr("Z to A")) } else { (tr("Oldest first"), tr("Newest first")) };
|
||||
for (descending, label) in [(false, first), (true, second)] {
|
||||
if crate::right::menu_check(ui, sort.descending == descending, label, "") {
|
||||
action = Some(Action::Sort(Sort { descending, ..sort }));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
action
|
||||
}
|
||||
|
||||
/// A group's heading in a menu.
|
||||
fn heading(ui: &mut Ui, t: &Tokens, text: &str) {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover());
|
||||
ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, regular(10.5), t.text_secondary);
|
||||
}
|
||||
|
||||
/// The id of `path`'s card or row (stable, so the keyboard stays on it as the order changes).
|
||||
fn card_id(path: &str) -> Id {
|
||||
Id::new(("home_recent_file", path))
|
||||
}
|
||||
|
||||
/// Clicks, keys and the context menu on a card or row: a click selects, a double-click or Enter
|
||||
/// opens, a right-click selects and offers the menu.
|
||||
fn interact(ui: &Ui, resp: &Response, recents: &mut Recents, file: &Shown) -> Option<Action> {
|
||||
let mut action = None;
|
||||
if resp.gained_focus() {
|
||||
recents.selected = Some(file.path.clone());
|
||||
}
|
||||
// The arrow keys move the selection here, not egui's focus.
|
||||
if resp.has_focus() {
|
||||
ui.memory_mut(|m| {
|
||||
m.set_focus_lock_filter(resp.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() })
|
||||
});
|
||||
}
|
||||
let enter = resp.has_focus() && ui.input(|i| i.key_pressed(Key::Enter));
|
||||
// A click soon after another counts as a triple: it opens too.
|
||||
if resp.double_clicked() || resp.triple_clicked() || enter {
|
||||
action = Some(Action::Open(file.path.clone()));
|
||||
} else if resp.clicked() || resp.secondary_clicked() {
|
||||
recents.selected = Some(file.path.clone());
|
||||
resp.request_focus();
|
||||
}
|
||||
crate::right::context_popup(resp).show(|ui| {
|
||||
if crate::right::menu_item(ui, tr("Open"), "") {
|
||||
action = Some(Action::Open(file.path.clone()));
|
||||
ui.close();
|
||||
}
|
||||
if cfg!(not(target_arch = "wasm32")) && file.stat.is_some_and(|s| s.exists) && crate::right::menu_item(ui, reveal_label(), "") {
|
||||
action = Some(Action::Reveal(file.path.clone()));
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if crate::right::menu_item(ui, tr("Remove from recents"), "") {
|
||||
action = Some(Action::Remove(file.path.clone()));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
action
|
||||
}
|
||||
|
||||
/// The platform's name for showing a file in its folder.
|
||||
fn reveal_label() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
tr("Show in Finder")
|
||||
} else if cfg!(windows) {
|
||||
tr("Show in Explorer")
|
||||
} else {
|
||||
tr("Show in folder")
|
||||
}
|
||||
}
|
||||
|
||||
/// The arrow keys on the selected file: the one `step` away in `files` (wrapping at row ends,
|
||||
/// stopping at the first and last), selected and given the keyboard.
|
||||
fn arrows(ui: &Ui, recents: &mut Recents, files: &[Shown], across: usize, down: usize) {
|
||||
let Some(at) = files.iter().position(|f| ui.memory(|m| m.has_focus(card_id(&f.path)))) else { return };
|
||||
let pressed = |k: Key| ui.input(|i| i.key_pressed(k) && !i.modifiers.any());
|
||||
let to = if pressed(Key::ArrowRight) {
|
||||
(across > 0).then(|| at + across)
|
||||
} else if pressed(Key::ArrowLeft) {
|
||||
(across > 0).then(|| at.checked_sub(across)).flatten()
|
||||
} else if pressed(Key::ArrowDown) {
|
||||
Some(at + down)
|
||||
} else if pressed(Key::ArrowUp) {
|
||||
at.checked_sub(down)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(f) = to.and_then(|i| files.get(i)) {
|
||||
recents.selected = Some(f.path.clone());
|
||||
ui.memory_mut(|m| m.request_focus(card_id(&f.path)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The square file-type mark before a name.
|
||||
fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) {
|
||||
let r = Rect::from_center_size(center, vec2(16.0, 16.0));
|
||||
match FileKind::of(Path::new(path)) {
|
||||
FileKind::FigCraft | FileKind::Fig => {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), ACCENT);
|
||||
icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, Color32::WHITE);
|
||||
}
|
||||
kind => {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), t.field);
|
||||
let glyph = match kind {
|
||||
FileKind::Svg => Icon::Vector,
|
||||
FileKind::Image => Icon::Image,
|
||||
_ => Icon::Page,
|
||||
};
|
||||
icons::paint(ui.painter(), r.shrink(2.0), glyph, t.icon_secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name a card or row has for screen readers: "name, Edited 3 hours ago".
|
||||
fn spoken(file: &Shown, now: u64) -> String {
|
||||
let detail = file.detail(now);
|
||||
if detail.is_empty() { file.name.clone() } else { format!("{}, {detail}", file.name) }
|
||||
}
|
||||
|
||||
fn grid(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u64, time: f64, reduce_motion: bool) -> Option<Action> {
|
||||
let (columns, card_w) = grid_columns(ui.available_width());
|
||||
let (picture_h, card_h) = card_heights(card_w);
|
||||
let mut action = None;
|
||||
let mut fading = false;
|
||||
for row in files.chunks(columns) {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), card_h), Sense::hover());
|
||||
for (i, file) in row.iter().enumerate() {
|
||||
let rect = Rect::from_min_size(pos2(r.min.x + i as f32 * (card_w + GAP), r.min.y), vec2(card_w, card_h));
|
||||
let resp = ui.interact(rect, card_id(&file.path), Sense::click());
|
||||
a11y::button(&resp, &spoken(file, now));
|
||||
let resp = resp.on_hover_text(&file.path);
|
||||
let visible = ui.is_rect_visible(rect);
|
||||
let picture = recents.picture(&file.path, visible, time);
|
||||
let selected = recents.selected.as_deref() == Some(file.path.as_str());
|
||||
if let Some(a) = interact(ui, &resp, recents, file) {
|
||||
action = Some(a);
|
||||
}
|
||||
if !visible {
|
||||
continue;
|
||||
}
|
||||
let Some(stat) = file.stat else {
|
||||
skeleton(ui, t, rect, picture_h);
|
||||
continue;
|
||||
};
|
||||
let alpha = picture.as_ref().map_or(1.0, |(_, _, a)| if reduce_motion { 1.0 } else { *a });
|
||||
fading |= alpha < 1.0;
|
||||
card(ui, t, rect, picture_h, &resp, selected, file, stat, picture, alpha, now);
|
||||
}
|
||||
ui.add_space(GAP);
|
||||
}
|
||||
if fading {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
arrows(ui, recents, files, 1, columns);
|
||||
action
|
||||
}
|
||||
|
||||
/// A card while its file is being looked at: grey blocks where the picture, icon and text go.
|
||||
fn skeleton(ui: &Ui, t: &Tokens, rect: Rect, picture_h: f32) {
|
||||
let p = ui.painter();
|
||||
p.rect_filled(rect, CornerRadius::same(8), t.panel);
|
||||
let picture = Rect::from_min_size(rect.min, vec2(rect.width(), picture_h)).shrink(1.0);
|
||||
p.rect_filled(picture, CornerRadius { nw: 7, ne: 7, sw: 0, se: 0 }, t.field);
|
||||
let y = picture.max.y + FOOTER_H / 2.0;
|
||||
p.rect_filled(Rect::from_center_size(pos2(rect.min.x + 24.0, y), vec2(16.0, 16.0)), CornerRadius::same(4), t.field);
|
||||
p.rect_filled(Rect::from_min_size(pos2(rect.min.x + 44.0, y - 11.0), vec2(rect.width() * 0.45, 9.0)), CornerRadius::same(3), t.field);
|
||||
p.rect_filled(Rect::from_min_size(pos2(rect.min.x + 44.0, y + 4.0), vec2(rect.width() * 0.3, 8.0)), CornerRadius::same(3), t.field);
|
||||
p.rect_stroke(rect, CornerRadius::same(8), Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn card(
|
||||
ui: &Ui,
|
||||
t: &Tokens,
|
||||
rect: Rect,
|
||||
picture_h: f32,
|
||||
resp: &Response,
|
||||
selected: bool,
|
||||
file: &Shown,
|
||||
stat: Stat,
|
||||
picture: Option<(Option<Color32>, Option<egui::TextureHandle>, f32)>,
|
||||
alpha: f32,
|
||||
now: u64,
|
||||
) {
|
||||
let p = ui.painter();
|
||||
p.rect_filled(rect, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.panel });
|
||||
let area = Rect::from_min_size(rect.min, vec2(rect.width(), picture_h)).shrink(1.0);
|
||||
let top = CornerRadius { nw: 7, ne: 7, sw: 0, se: 0 };
|
||||
p.rect_filled(area, top, t.canvas);
|
||||
match picture {
|
||||
Some((page, texture, _)) if stat.exists && (page.is_some() || texture.is_some()) => {
|
||||
if let Some(page) = page {
|
||||
p.rect_filled(area, top, page.gamma_multiply(alpha));
|
||||
}
|
||||
if let Some(texture) = texture {
|
||||
let size = texture.size_vec2();
|
||||
let inner = area.shrink(area.height() * 0.05);
|
||||
let fit = (inner.width() / size.x.max(1.0)).min(inner.height() / size.y.max(1.0));
|
||||
let image = Rect::from_center_size(inner.center(), size * fit);
|
||||
p.image(texture.id(), image, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE.gamma_multiply(alpha));
|
||||
}
|
||||
}
|
||||
// Being read: the empty area, the picture fades in.
|
||||
None if stat.exists => {}
|
||||
// Nothing to show (a missing file, an svg, a damaged picture): a file glyph.
|
||||
_ => icons::paint(p, Rect::from_center_size(area.center(), vec2(28.0, 28.0)), Icon::Page, t.icon_secondary),
|
||||
}
|
||||
p.line_segment([pos2(rect.min.x, area.max.y + 0.5), pos2(rect.max.x, area.max.y + 0.5)], Stroke::new(1.0, t.border));
|
||||
let y = area.max.y + 1.0 + FOOTER_H / 2.0;
|
||||
file_icon(ui, t, pos2(rect.min.x + 24.0, y), &file.path);
|
||||
let (x, w) = (rect.min.x + 44.0, rect.width() - 56.0);
|
||||
let name = widgets::elide_font(ui, &file.name, w, semibold(12.5));
|
||||
p.text(pos2(x, y - 8.0), Align2::LEFT_CENTER, name, semibold(12.5), t.text);
|
||||
let detail = widgets::elide_font(ui, &file.detail(now), w, regular(11.5));
|
||||
p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, detail, regular(11.5), t.text_secondary);
|
||||
if selected {
|
||||
p.rect_stroke(rect, CornerRadius::same(8), Stroke::new(2.0, ACCENT), StrokeKind::Inside);
|
||||
} else {
|
||||
p.rect_stroke(rect, CornerRadius::same(8), Stroke::new(1.0, if resp.hovered() { t.border_strong } else { t.border }), StrokeKind::Inside);
|
||||
if resp.has_focus() {
|
||||
p.rect_stroke(rect, CornerRadius::same(8), Stroke::new(2.0, t.focus), StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The table: Name, Last modified and Created (where the platform keeps it) headers that sort, and
|
||||
/// a row per file.
|
||||
fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u64, sort: Sort, with_created: bool) -> Option<Action> {
|
||||
let width = ui.available_width();
|
||||
let date_w = 180.0_f32.min(width / 4.0);
|
||||
let mut columns = vec![(SortKey::Name, tr("Name")), (SortKey::Modified, tr("Last modified"))];
|
||||
if with_created {
|
||||
columns.push((SortKey::Created, tr("Created")));
|
||||
}
|
||||
// Each column's left edge: the name takes what the dates leave.
|
||||
let date_x = |i: usize| width - date_w * (columns.len() - i) as f32;
|
||||
let mut action = None;
|
||||
let (header, _) = ui.allocate_exact_size(vec2(width, HEADER_H), Sense::hover());
|
||||
for (i, (key, label)) in columns.iter().enumerate() {
|
||||
let x0 = if i == 0 { header.min.x } else { header.min.x + date_x(i) };
|
||||
let x1 = if i + 1 < columns.len() { header.min.x + date_x(i + 1) } else { header.max.x };
|
||||
let cell = Rect::from_min_max(pos2(x0, header.min.y), pos2(x1, header.max.y));
|
||||
let resp = ui.interact(cell, Id::new(("home_recents_header", key.id())), Sense::click());
|
||||
a11y::button(&resp, &trf("Sort by {column}", &[("column", label)]));
|
||||
let on = sort.key == *key;
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(cell, CornerRadius::same(4), t.hover);
|
||||
}
|
||||
let font = if on { semibold(11.0) } else { regular(11.0) };
|
||||
let end = ui
|
||||
.painter()
|
||||
.text(pos2(cell.min.x + 12.0, cell.center().y), Align2::LEFT_CENTER, *label, font, if on { t.text } else { t.text_secondary })
|
||||
.max
|
||||
.x;
|
||||
if on {
|
||||
let icon = if sort.descending { Icon::Chevron } else { Icon::ChevronUp };
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(end + 10.0, cell.center().y), vec2(10.0, 10.0)), icon, t.icon_secondary);
|
||||
}
|
||||
if resp.has_focus() {
|
||||
ui.painter().rect_stroke(cell, CornerRadius::same(4), Stroke::new(2.0, t.focus), StrokeKind::Outside);
|
||||
}
|
||||
if resp.clicked() {
|
||||
action = Some(Action::Sort(sort.clicked(*key)));
|
||||
}
|
||||
}
|
||||
ui.painter().line_segment([pos2(header.min.x, header.max.y - 0.5), pos2(header.max.x, header.max.y - 0.5)], Stroke::new(1.0, t.border));
|
||||
for file in files {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover());
|
||||
let resp = ui.interact(r, card_id(&file.path), Sense::click());
|
||||
a11y::button(&resp, &spoken(file, now));
|
||||
let resp = resp.on_hover_text(&file.path);
|
||||
if let Some(a) = interact(ui, &resp, recents, file) {
|
||||
action = Some(a);
|
||||
}
|
||||
if !ui.is_rect_visible(r) {
|
||||
continue;
|
||||
}
|
||||
let selected = recents.selected.as_deref() == Some(file.path.as_str());
|
||||
let p = ui.painter();
|
||||
if selected {
|
||||
p.rect_filled(r, CornerRadius::same(5), t.selected_row);
|
||||
} else if resp.hovered() {
|
||||
p.rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
if resp.has_focus() && !selected {
|
||||
p.rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, t.focus), StrokeKind::Inside);
|
||||
}
|
||||
file_icon(ui, t, pos2(r.min.x + 20.0, r.center().y), &file.path);
|
||||
let name_w = date_x(1) - 52.0;
|
||||
p.text(
|
||||
pos2(r.min.x + 40.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
widgets::elide_font(ui, &file.name, name_w, regular(12.0)),
|
||||
regular(12.0),
|
||||
t.text,
|
||||
);
|
||||
let cells: Vec<String> = columns
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(|(key, _)| match file.stat {
|
||||
Some(Stat { exists: false, .. }) if *key == SortKey::Modified => tr("File not found").into(),
|
||||
Some(s) => match *key {
|
||||
SortKey::Modified => s.modified,
|
||||
_ => s.created,
|
||||
}
|
||||
.map(|then| ago(now, then))
|
||||
.unwrap_or_default(),
|
||||
None => String::new(),
|
||||
})
|
||||
.collect();
|
||||
for (i, text) in cells.iter().enumerate() {
|
||||
let x = r.min.x + date_x(i + 1) + 12.0;
|
||||
p.text(
|
||||
pos2(x, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
widgets::elide_font(ui, text, date_w - 16.0, regular(11.5)),
|
||||
regular(11.5),
|
||||
t.text_secondary,
|
||||
);
|
||||
}
|
||||
p.line_segment([pos2(r.min.x, r.max.y - 0.5), pos2(r.max.x, r.max.y - 0.5)], Stroke::new(1.0, t.border.gamma_multiply(0.6)));
|
||||
}
|
||||
arrows(ui, recents, files, 0, 1);
|
||||
action
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
//! The home screen's recent files, drawn headless: cards with the picture each file carries on its
|
||||
//! page's colour (read off the frame's thread, only for cards in view), "Edited …" and "File not
|
||||
//! found", the sort menu and the table's headers, grid and list kept in the preferences and set
|
||||
//! through `ui.set`, a click that selects, a double-click or Enter that opens, the arrow keys and
|
||||
//! Tab, the context menu, and the pure parts: relative times, columns and sorting.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Shape};
|
||||
use figcraft_engine::{Prefs, Session};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Services;
|
||||
use crate::home_recents::{CARD_MAX, CARD_MIN, GAP, Shown, Sort, SortKey, Stat, View, ago, edited, grid_columns, sorted};
|
||||
use crate::layers_drag_tests::Ui;
|
||||
use crate::live_tests::{flat, has_text, text_center};
|
||||
|
||||
const HOUR: u64 = 3600;
|
||||
const DAY: u64 = 86_400;
|
||||
|
||||
#[test]
|
||||
fn relative_times_at_each_boundary() {
|
||||
let now = 1_800_000_000; // 2027-01-15 08:00 UTC
|
||||
let cases = [
|
||||
(0, "just now"),
|
||||
(59, "just now"),
|
||||
(60, "1 minute ago"),
|
||||
(119, "1 minute ago"),
|
||||
(120, "2 minutes ago"),
|
||||
(HOUR - 1, "59 minutes ago"),
|
||||
(HOUR, "1 hour ago"),
|
||||
(2 * HOUR, "2 hours ago"),
|
||||
(DAY - 1, "23 hours ago"),
|
||||
(DAY, "yesterday"),
|
||||
(2 * DAY - 1, "yesterday"),
|
||||
(2 * DAY, "2 days ago"),
|
||||
(7 * DAY - 1, "6 days ago"),
|
||||
(7 * DAY, "Jan 8, 2027"),
|
||||
];
|
||||
for (d, want) in cases {
|
||||
assert_eq!(ago(now, now - d), want, "{d} seconds");
|
||||
}
|
||||
// A clock that went backwards reads as now; dates far back and the epoch itself.
|
||||
assert_eq!(ago(now, now + 500), "just now");
|
||||
assert_eq!(ago(now, 951_782_400), "Feb 29, 2000");
|
||||
assert_eq!(ago(now, 0), "Jan 1, 1970");
|
||||
assert_eq!(ago(now, 1_709_251_199), "Feb 29, 2024", "the last second of a leap day");
|
||||
assert_eq!(edited(now, now - 3 * HOUR), "Edited 3 hours ago");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_follow_the_width() {
|
||||
for (width, columns) in
|
||||
[(100.0, 1), (259.0, 1), (300.0, 1), (551.0, 1), (552.0, 2), (700.0, 2), (850.0, 3), (1120.0, 3), (1600.0, 5), (3000.0, 8)]
|
||||
{
|
||||
let (n, card) = grid_columns(width);
|
||||
assert_eq!(n, columns, "{width}");
|
||||
assert!(card <= CARD_MAX, "{width}: {card}");
|
||||
if n > 1 {
|
||||
assert!(card >= CARD_MIN, "{width}: {card}");
|
||||
assert!(n as f32 * card + (n as f32 - 1.0) * GAP <= width + 0.01, "{width}: fits");
|
||||
}
|
||||
}
|
||||
// Nonsense widths still give one column.
|
||||
assert_eq!(grid_columns(f32::NAN).0, 1);
|
||||
assert_eq!(grid_columns(-5.0).0, 1);
|
||||
// A card's picture is 16:9 over a 58-point footer.
|
||||
let (picture, card) = crate::home_recents::card_heights(352.0);
|
||||
assert_eq!((picture, card), (198.0, 256.0));
|
||||
}
|
||||
|
||||
fn shown(path: &str, modified: Option<u64>, created: Option<u64>) -> Shown {
|
||||
let stat = Some(Stat { exists: modified.is_some(), modified, created });
|
||||
Shown { path: path.into(), name: Path::new(path).file_stem().unwrap().to_string_lossy().into(), stat }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_by_modified_created_and_name_either_way() {
|
||||
let files = || {
|
||||
vec![
|
||||
shown("/a/beta.fig", Some(300), Some(10)),
|
||||
shown("/a/Alpha.figcraft", Some(100), Some(30)),
|
||||
shown("/b/gone.figcraft", None, None),
|
||||
shown("/a/charlie.svg", Some(200), None),
|
||||
]
|
||||
};
|
||||
let names = |sort: Sort| sorted(files(), sort).into_iter().map(|f| f.name).collect::<Vec<_>>();
|
||||
assert_eq!(Sort::default(), Sort { key: SortKey::Modified, descending: true });
|
||||
assert_eq!(names(Sort::default()), ["beta", "charlie", "Alpha", "gone"]);
|
||||
assert_eq!(names(Sort { key: SortKey::Modified, descending: false }), ["Alpha", "charlie", "beta", "gone"], "unknown dates last either way");
|
||||
assert_eq!(names(Sort::by(SortKey::Name)), ["Alpha", "beta", "charlie", "gone"], "A to Z, whatever the case");
|
||||
assert_eq!(names(Sort { key: SortKey::Name, descending: true }), ["gone", "charlie", "beta", "Alpha"]);
|
||||
assert_eq!(names(Sort::by(SortKey::Created)), ["Alpha", "beta", "gone", "charlie"], "newest first, then the list's order");
|
||||
// A header click: the other way round on the current column, else that column's usual way.
|
||||
let s = Sort::default();
|
||||
assert_eq!(s.clicked(SortKey::Modified), Sort { key: SortKey::Modified, descending: false });
|
||||
assert_eq!(s.clicked(SortKey::Name), Sort { key: SortKey::Name, descending: false });
|
||||
assert_eq!(s.clicked(SortKey::Name).clicked(SortKey::Name), Sort { key: SortKey::Name, descending: true });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_and_sort_are_kept_in_the_preferences_and_come_back() {
|
||||
let mut prefs = Prefs::default();
|
||||
assert_eq!((View::of(&prefs), Sort::of(&prefs)), (View::Grid, Sort::default()));
|
||||
View::List.store(&mut prefs);
|
||||
Sort { key: SortKey::Name, descending: true }.store(&mut prefs);
|
||||
let back: Prefs = serde_json::from_value(serde_json::to_value(&prefs).unwrap()).unwrap();
|
||||
assert_eq!((View::of(&back), Sort::of(&back)), (View::List, Sort { key: SortKey::Name, descending: true }));
|
||||
assert_eq!(back.home_views.get("recents").map(String::as_str), Some("list"), "kept per section");
|
||||
// Old preferences without them, and junk in them, read as the defaults.
|
||||
let old: Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
|
||||
assert_eq!((View::of(&old), Sort::of(&old)), (View::Grid, Sort::default()));
|
||||
let junk: Prefs = serde_json::from_value(json!({"home_views": {"recents": "tiles"}, "home_sort": "size"})).unwrap();
|
||||
assert_eq!((View::of(&junk), Sort::of(&junk)), (View::Grid, Sort::default()));
|
||||
}
|
||||
|
||||
/// What the services were asked to do.
|
||||
#[derive(Default)]
|
||||
struct Log {
|
||||
saved: Vec<Prefs>,
|
||||
revealed: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
struct Watch(Arc<Mutex<Log>>);
|
||||
impl Services for Watch {
|
||||
fn save_prefs(&mut self, prefs: &Prefs) {
|
||||
self.0.lock().unwrap().saved.push(prefs.clone());
|
||||
}
|
||||
fn reveal_path(&mut self, path: &Path) {
|
||||
self.0.lock().unwrap().revealed.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(w, h, image::Rgba([200, 40, 40, 255]));
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// A folder of recent files: `pic.figcraft` (a picture on a dark page, edited 3 hours ago),
|
||||
/// `empty.figcraft` (nothing on a light grey page, edited 2 days ago), `art.fig` (a picture, edited
|
||||
/// just now), `icon.svg`, and `gone.figcraft` (missing).
|
||||
fn files(name: &str) -> (PathBuf, Vec<String>) {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-home-recents-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let doc = |rgb: f32| {
|
||||
let mut d = figcraft_doc::Document::new("File");
|
||||
let p = d.first_page().unwrap();
|
||||
if let Some(figcraft_doc::NodeKind::Page { background, .. }) = d.node_mut(p).map(|n| &mut n.kind) {
|
||||
*background = figcraft_color::Color { r: rgb, g: rgb, b: rgb, a: 1.0 };
|
||||
}
|
||||
d
|
||||
};
|
||||
let write = |file: &str, bytes: Vec<u8>, ago: u64| {
|
||||
let path = dir.join(file);
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let f = std::fs::File::options().write(true).open(&path).unwrap();
|
||||
f.set_modified(SystemTime::now() - Duration::from_secs(ago)).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
};
|
||||
let paths = vec![
|
||||
write("pic.figcraft", figcraft_format::save_bytes(&doc(0.1), Some(&png(64, 32))).unwrap(), 3 * HOUR + 60),
|
||||
write("empty.figcraft", figcraft_format::save_bytes(&doc(0.9), None).unwrap(), 2 * DAY + 60),
|
||||
write("art.fig", figcraft_format::fig::export(&doc(0.2), Some(&png(20, 40))).unwrap().0, 0),
|
||||
write("icon.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>".to_vec(), 5 * 60),
|
||||
dir.join("gone.figcraft").to_string_lossy().into_owned(),
|
||||
];
|
||||
(dir, paths)
|
||||
}
|
||||
|
||||
fn home(name: &str) -> (Ui, Vec<String>, Arc<Mutex<Log>>) {
|
||||
let (_, paths) = files(name);
|
||||
let mut s = Session::empty();
|
||||
s.prefs.recent_files = paths.clone();
|
||||
s.prefs.getting_started_dismissed = true;
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.home = true;
|
||||
let log = Arc::new(Mutex::new(Log::default()));
|
||||
u.app.services = Box::new(Watch(log.clone()));
|
||||
settle(&mut u);
|
||||
(u, paths, log)
|
||||
}
|
||||
|
||||
fn recents(u: &Ui) -> Value {
|
||||
u.app.inspect()["homeRecents"].clone()
|
||||
}
|
||||
|
||||
/// Frames until every file has been looked at and every picture in view is in (the reader is a
|
||||
/// thread of its own) and the fades are over.
|
||||
fn settle(u: &mut Ui) -> Vec<Shape> {
|
||||
for _ in 0..400 {
|
||||
u.frame(vec![]);
|
||||
let r = recents(u);
|
||||
let files = r["files"].as_array().cloned().unwrap_or_default();
|
||||
if files.iter().all(|f| !f["exists"].is_null() && f["picture"] != "loading" && (f["exists"] == false || f["picture"] != "none")) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
for _ in 0..20 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
flat(&u.frame_output(vec![]).shapes)
|
||||
}
|
||||
|
||||
fn file<'a>(r: &'a Value, name: &str) -> &'a Value {
|
||||
r["files"].as_array().unwrap().iter().find(|f| f["name"] == name).unwrap_or_else(|| panic!("no {name}: {r}"))
|
||||
}
|
||||
|
||||
fn click_with(u: &mut Ui, p: Pos2, button: PointerButton) {
|
||||
for pressed in [true, false] {
|
||||
u.frame(vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE }]);
|
||||
}
|
||||
u.frame(vec![]);
|
||||
}
|
||||
|
||||
fn click(u: &mut Ui, p: Pos2) {
|
||||
click_with(u, p, PointerButton::Primary);
|
||||
}
|
||||
|
||||
fn double_click(u: &mut Ui, p: Pos2) {
|
||||
let mut events = vec![Event::PointerMoved(p)];
|
||||
for _ in 0..2 {
|
||||
for pressed in [true, false] {
|
||||
events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
}
|
||||
for e in events {
|
||||
u.frame(vec![e]);
|
||||
}
|
||||
u.frame(vec![]);
|
||||
}
|
||||
|
||||
fn press(u: &mut Ui, key: Key) {
|
||||
for pressed in [true, false] {
|
||||
u.frame(vec![Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }]);
|
||||
}
|
||||
}
|
||||
|
||||
fn texts(s: &[Shape]) -> Vec<String> {
|
||||
s.iter()
|
||||
.filter_map(|s| match s {
|
||||
Shape::Text(t) => Some(t.galley.text().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Filled rectangles in exactly `c`.
|
||||
fn rects_of(s: &[Shape], c: egui::Color32) -> usize {
|
||||
s.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == c)).count()
|
||||
}
|
||||
|
||||
/// Images drawn (meshes with a texture of their own).
|
||||
fn images(s: &[Shape]) -> usize {
|
||||
s.iter().filter(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default())).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cards_show_pictures_on_their_page_colour_with_name_and_time() {
|
||||
let (mut u, _, _) = home("cards");
|
||||
let s = settle(&mut u);
|
||||
let r = recents(&u);
|
||||
assert_eq!(r["view"], "grid");
|
||||
assert_eq!(r["sort"], "modified");
|
||||
// Last modified, newest first; the missing file last.
|
||||
let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["art", "icon", "pic", "empty", "gone"]);
|
||||
assert_eq!(file(&r, "pic")["picture"], "picture");
|
||||
assert_eq!(file(&r, "art")["picture"], "picture");
|
||||
assert_eq!(file(&r, "empty")["picture"], "page", "nothing on it: its page colour alone");
|
||||
assert_eq!(file(&r, "icon")["picture"], "placeholder");
|
||||
assert_eq!(file(&r, "gone")["exists"], false);
|
||||
assert_eq!(images(&s), 2, "two pictures drawn");
|
||||
// Each picture sits on its file's page colour (not one fixed colour).
|
||||
let page = |v: f32| crate::theme::c32(figcraft_color::Color { r: v, g: v, b: v, a: 1.0 });
|
||||
assert!(rects_of(&s, page(0.1)) >= 1 && rects_of(&s, page(0.9)) >= 1 && rects_of(&s, page(0.2)) >= 1);
|
||||
// The footer: the file's name and when it was edited, or that it's gone.
|
||||
for want in
|
||||
["pic", "Edited 3 hours ago", "empty", "Edited 2 days ago", "art", "Edited just now", "Edited 5 minutes ago", "gone", "File not found"]
|
||||
{
|
||||
assert!(has_text(&s, want), "{want}: {:?}", texts(&s));
|
||||
}
|
||||
// The toolbar.
|
||||
assert!(has_text(&s, "Recent files") && has_text(&s, "Last modified"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_cards_in_view_read_their_pictures() {
|
||||
let (dir, mut paths) = files("visible");
|
||||
// Twelve files: the last rows are below the window.
|
||||
let bytes = std::fs::read(&paths[0]).unwrap();
|
||||
for i in 0..8 {
|
||||
let p = dir.join(format!("more{i}.figcraft"));
|
||||
std::fs::write(&p, &bytes).unwrap();
|
||||
paths.push(p.to_string_lossy().into_owned());
|
||||
}
|
||||
let mut s = Session::empty();
|
||||
s.prefs.recent_files = paths;
|
||||
s.prefs.getting_started_dismissed = true;
|
||||
s.prefs.home_sort = "name".into();
|
||||
s.prefs.home_order = "asc".into();
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.home = true;
|
||||
settle(&mut u);
|
||||
let r = recents(&u);
|
||||
let pictures: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["picture"].as_str().unwrap()).collect();
|
||||
assert_eq!(pictures.len(), 12);
|
||||
// A to Z: art.fig, empty.figcraft, gone.figcraft (missing: nothing to read) on the first row.
|
||||
assert_eq!(pictures.get(..3), Some(["picture", "page", "none"].as_slice()), "the first row is read: {pictures:?}");
|
||||
assert!(pictures.iter().skip(9).all(|p| *p == "none"), "the last row is not: {pictures:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_selects_a_double_click_opens() {
|
||||
let (mut u, paths, _) = home("open");
|
||||
let s = settle(&mut u);
|
||||
let at = text_center(&s, "pic");
|
||||
click(&mut u, at);
|
||||
assert!(u.app.ui.home, "a click doesn't open");
|
||||
assert!(u.app.session.docs.is_empty());
|
||||
assert_eq!(file(&recents(&u), "pic")["selected"], true);
|
||||
// The selection is drawn as an accent border.
|
||||
let s = settle(&mut u);
|
||||
let border = s.iter().any(|s| matches!(s, Shape::Rect(r) if r.stroke.width == 2.0 && r.stroke.color == crate::theme::ACCENT));
|
||||
assert!(border, "a 2-point accent border");
|
||||
double_click(&mut u, at);
|
||||
assert!(!u.app.ui.home, "a double-click opens");
|
||||
assert_eq!(u.app.session.ds().unwrap().path.as_deref(), Some(paths[0].as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_reaches_the_cards_named_for_screen_readers_arrows_move_and_enter_opens() {
|
||||
let (mut u, paths, _) = home("keys");
|
||||
u.ctx.enable_accesskit();
|
||||
u.frame(vec![]);
|
||||
let nodes: Vec<_> = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes.into_iter().map(|(_, n)| n).collect();
|
||||
for name in ["art, Edited just now", "pic, Edited 3 hours ago", "gone, File not found"] {
|
||||
assert!(nodes.iter().any(|n| n.role() == Role::Button && n.label() == Some(name)), "no button named {name:?}");
|
||||
}
|
||||
let mut reached = false;
|
||||
for _ in 0..40 {
|
||||
press(&mut u, Key::Tab);
|
||||
if crate::a11y::focused_name(&u.ctx).starts_with("art, ") {
|
||||
reached = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(reached, "Tab never reached the first card");
|
||||
assert_eq!(file(&recents(&u), "art")["selected"], true, "the card with the keyboard is selected");
|
||||
// Right: the next card; Down: the one below (three columns); Left and Up back.
|
||||
press(&mut u, Key::ArrowRight);
|
||||
u.frame(vec![]);
|
||||
assert!(crate::a11y::focused_name(&u.ctx).starts_with("icon, "), "{}", crate::a11y::focused_name(&u.ctx));
|
||||
assert_eq!(file(&recents(&u), "icon")["selected"], true);
|
||||
assert_eq!(file(&recents(&u), "art")["selected"], false);
|
||||
press(&mut u, Key::ArrowRight);
|
||||
press(&mut u, Key::ArrowRight);
|
||||
u.frame(vec![]);
|
||||
assert!(crate::a11y::focused_name(&u.ctx).starts_with("empty, "), "Right wraps to the next row");
|
||||
press(&mut u, Key::ArrowUp);
|
||||
u.frame(vec![]);
|
||||
assert!(crate::a11y::focused_name(&u.ctx).starts_with("art, "));
|
||||
press(&mut u, Key::ArrowDown);
|
||||
press(&mut u, Key::ArrowLeft);
|
||||
u.frame(vec![]);
|
||||
assert!(crate::a11y::focused_name(&u.ctx).starts_with("pic, "), "{}", crate::a11y::focused_name(&u.ctx));
|
||||
assert!(u.app.ui.home, "nothing opened yet");
|
||||
press(&mut u, Key::Enter);
|
||||
u.frame(vec![]);
|
||||
assert!(!u.app.ui.home);
|
||||
assert_eq!(u.app.session.ds().unwrap().path.as_deref(), Some(paths[0].as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_context_menu_opens_shows_in_the_folder_and_removes() {
|
||||
let (mut u, paths, log) = home("menu");
|
||||
let s = settle(&mut u);
|
||||
click_with(&mut u, text_center(&s, "icon"), PointerButton::Secondary);
|
||||
let s = flat(&u.frame_output(vec![]).shapes);
|
||||
assert_eq!(file(&recents(&u), "icon")["selected"], true, "a right-click selects");
|
||||
let reveal = if cfg!(target_os = "macos") {
|
||||
"Show in Finder"
|
||||
} else if cfg!(windows) {
|
||||
"Show in Explorer"
|
||||
} else {
|
||||
"Show in folder"
|
||||
};
|
||||
for item in ["Open", reveal, "Remove from recents"] {
|
||||
assert!(has_text(&s, item), "{item}");
|
||||
}
|
||||
click(&mut u, text_center(&s, reveal));
|
||||
assert_eq!(log.lock().unwrap().revealed, vec![PathBuf::from(&paths[3])]);
|
||||
let s = settle(&mut u);
|
||||
click_with(&mut u, text_center(&s, "icon"), PointerButton::Secondary);
|
||||
let s = flat(&u.frame_output(vec![]).shapes);
|
||||
click(&mut u, text_center(&s, "Remove from recents"));
|
||||
assert!(!u.app.session.prefs.recent_files.contains(&paths[3]));
|
||||
assert_eq!(u.app.session.prefs.recent_files.len(), 4);
|
||||
assert!(log.lock().unwrap().saved.last().is_some_and(|p| p.recent_files.len() == 4), "kept");
|
||||
let s = settle(&mut u);
|
||||
assert!(!has_text(&s, "icon"));
|
||||
// Open from the menu.
|
||||
click_with(&mut u, text_center(&s, "pic"), PointerButton::Secondary);
|
||||
let s = flat(&u.frame_output(vec![]).shapes);
|
||||
click(&mut u, text_center(&s, "Open"));
|
||||
assert!(!u.app.ui.home);
|
||||
assert_eq!(u.app.session.ds().unwrap().path.as_deref(), Some(paths[0].as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sort_menu_and_the_view_buttons_are_kept_in_the_preferences() {
|
||||
let (mut u, _, log) = home("toolbar");
|
||||
let s = settle(&mut u);
|
||||
click(&mut u, text_center(&s, "Last modified"));
|
||||
let s = flat(&u.frame_output(vec![]).shapes);
|
||||
for item in ["Sort by", "Order"] {
|
||||
assert!(has_text(&s, item), "{item}");
|
||||
}
|
||||
assert!(texts(&s).iter().any(|t| t.contains('✓') && t.contains("Last modified")), "the current sort is checked: {:?}", texts(&s));
|
||||
assert!(texts(&s).iter().any(|t| t.contains('✓') && t.contains("Newest first")));
|
||||
assert!(texts(&s).iter().any(|t| t.ends_with("Oldest first")));
|
||||
let alphabetical = texts(&s).into_iter().find(|t| t.ends_with("Alphabetical")).unwrap();
|
||||
click(&mut u, text_center(&s, &alphabetical));
|
||||
assert_eq!(Sort::of(&u.app.session.prefs), Sort { key: SortKey::Name, descending: false });
|
||||
assert_eq!(log.lock().unwrap().saved.last().map(|p| p.home_sort.clone()).as_deref(), Some("name"));
|
||||
let s = settle(&mut u);
|
||||
let names: Vec<String> = recents(&u)["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(names, ["art", "empty", "gone", "icon", "pic"]);
|
||||
assert!(has_text(&s, "Alphabetical"), "the button names the sort");
|
||||
// The list button: a table, no pictures.
|
||||
u.ctx.enable_accesskit();
|
||||
u.frame(vec![]);
|
||||
let out = u.frame_output(vec![]);
|
||||
let nodes: Vec<_> = out.platform_output.accesskit_update.unwrap().nodes.into_iter().map(|(_, n)| n).collect();
|
||||
let list = nodes.iter().find(|n| n.label() == Some("Show as list")).expect("a list button");
|
||||
let b = list.bounds().unwrap();
|
||||
click(&mut u, Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32));
|
||||
assert_eq!(View::of(&u.app.session.prefs), View::List);
|
||||
assert_eq!(log.lock().unwrap().saved.last().and_then(|p| p.home_views.get("recents").cloned()).as_deref(), Some("list"));
|
||||
let s = settle(&mut u);
|
||||
assert_eq!(images(&s), 0, "no pictures in the list");
|
||||
for want in ["Name", "Last modified", "3 hours ago", "2 days ago", "File not found", "pic"] {
|
||||
assert!(has_text(&s, want), "{want}: {:?}", texts(&s));
|
||||
}
|
||||
// A header sorts by its column, and again the other way round.
|
||||
let header = |s: &[Shape]| {
|
||||
let mut hits: Vec<Pos2> = texts_at(s, "Last modified");
|
||||
hits.sort_by(|a, b| b.y.total_cmp(&a.y));
|
||||
hits[0]
|
||||
};
|
||||
click(&mut u, header(&s));
|
||||
assert_eq!(Sort::of(&u.app.session.prefs), Sort { key: SortKey::Modified, descending: true });
|
||||
let s = settle(&mut u);
|
||||
click(&mut u, header(&s));
|
||||
assert_eq!(Sort::of(&u.app.session.prefs), Sort { key: SortKey::Modified, descending: false });
|
||||
let s = settle(&mut u);
|
||||
assert!(text_center(&s, "empty").y < text_center(&s, "art").y, "oldest first");
|
||||
assert_eq!(texts_at(&s, "Last modified").len(), 2, "the header and the dropdown read the same");
|
||||
// Kept, the preferences give the same view and sort back.
|
||||
let kept = log.lock().unwrap().saved.last().cloned().unwrap();
|
||||
let back: Prefs = serde_json::from_value(serde_json::to_value(&kept).unwrap()).unwrap();
|
||||
assert_eq!((View::of(&back), Sort::of(&back)), (View::List, Sort { key: SortKey::Modified, descending: false }));
|
||||
}
|
||||
|
||||
/// The middle of each text reading `s`.
|
||||
fn texts_at(shapes: &[Shape], s: &str) -> Vec<Pos2> {
|
||||
shapes
|
||||
.iter()
|
||||
.filter_map(|sh| match sh {
|
||||
Shape::Text(t) if t.galley.text() == s => Some(t.pos + t.galley.size() / 2.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_set_switches_the_view_and_the_sort() {
|
||||
let (mut u, _, _) = home("uiset");
|
||||
let ctx = u.ctx.clone();
|
||||
crate::control::handle(&mut u.app, &ctx, "ui.set", &json!({"homeRecentsView": "list", "homeRecentsSort": "name", "homeRecentsOrder": "desc"}))
|
||||
.unwrap();
|
||||
let r = recents(&u);
|
||||
assert_eq!((r["view"].as_str(), r["sort"].as_str(), r["order"].as_str()), (Some("list"), Some("name"), Some("desc")));
|
||||
// Unknown values change nothing.
|
||||
crate::control::handle(&mut u.app, &ctx, "ui.set", &json!({"homeRecentsView": "tiles", "homeRecentsSort": "size", "homeRecentsOrder": "up"}))
|
||||
.unwrap();
|
||||
let r = recents(&u);
|
||||
assert_eq!((r["view"].as_str(), r["sort"].as_str(), r["order"].as_str()), (Some("list"), Some("name"), Some("desc")));
|
||||
crate::control::handle(&mut u.app, &ctx, "ui.set", &json!({"homeRecentsView": "grid", "homeRecentsSort": "modified"})).unwrap();
|
||||
let r = recents(&u);
|
||||
assert_eq!((r["view"].as_str(), r["sort"].as_str(), r["order"].as_str()), (Some("grid"), Some("modified"), Some("desc")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_still_opens_as_before() {
|
||||
let (mut u, paths, _) = home("missing");
|
||||
let s = settle(&mut u);
|
||||
double_click(&mut u, text_center(&s, "gone"));
|
||||
// `file.open` fails: the home screen stays.
|
||||
assert!(u.app.ui.home);
|
||||
assert!(u.app.session.docs.is_empty());
|
||||
assert!(u.app.session.prefs.recent_files.contains(&paths[4]), "the list is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_home_screen_draws_no_frames_once_the_pictures_are_in() {
|
||||
let (_, paths) = files("idle");
|
||||
let (mut h, _) = crate::live_tests::Harness::new();
|
||||
h.app.session.prefs.recent_files = paths;
|
||||
h.app.session.prefs.getting_started_dismissed = true;
|
||||
h.app.ui.home = true;
|
||||
for _ in 0..400 {
|
||||
h.frame(vec![]);
|
||||
let r = h.app.inspect()["homeRecents"].clone();
|
||||
if r["files"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|f| !f["exists"].is_null() && f["picture"] != "loading" && (f["exists"] == false || f["picture"] != "none"))
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
h.frames(30);
|
||||
let (_, delay) = h.frame(vec![]);
|
||||
assert!(delay >= Duration::from_secs(1), "a frame in {delay:?}: {:?}", h.ctx.repaint_causes());
|
||||
assert_eq!(file(&h.app.inspect()["homeRecents"], "pic")["picture"], "picture");
|
||||
}
|
||||
@@ -217,7 +217,7 @@ fn a_first_run_offers_templates_on_recents_and_recent_files_replace_them() {
|
||||
u.app.ui.home = true;
|
||||
u.app.session.prefs.recent_files = vec!["/tmp/ridgeline.figcraft".into()];
|
||||
let s = settle(&mut u);
|
||||
assert!(has_text(&s, "Recent files") && has_text(&s, "ridgeline.figcraft"));
|
||||
assert!(has_text(&s, "Recent files") && has_text(&s, "ridgeline"));
|
||||
assert!(!has_text(&s, "Start from a template") && !has_text(&s, "See all templates"));
|
||||
assert!(has_text(&s, "Open files"), "the document opened from the template is listed");
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod frame_stats;
|
||||
pub mod guides;
|
||||
pub mod handles;
|
||||
pub mod home;
|
||||
pub mod home_recents;
|
||||
pub mod i18n;
|
||||
pub mod icons;
|
||||
pub mod layers;
|
||||
@@ -83,6 +84,8 @@ mod frame_label_tests;
|
||||
#[cfg(test)]
|
||||
mod gap_handle_tests;
|
||||
#[cfg(test)]
|
||||
mod home_recents_tests;
|
||||
#[cfg(test)]
|
||||
mod home_tests;
|
||||
#[cfg(test)]
|
||||
mod idle_tests;
|
||||
@@ -182,6 +185,9 @@ pub trait Services {
|
||||
None
|
||||
}
|
||||
fn open_url(&mut self, _url: &str) {}
|
||||
/// Show the file at `path` in the platform's file manager (Finder, Explorer), selected where the
|
||||
/// platform can.
|
||||
fn reveal_path(&mut self, _path: &std::path::Path) {}
|
||||
/// The system asks for reduced motion (its accessibility setting).
|
||||
fn reduce_motion(&self) -> bool {
|
||||
false
|
||||
@@ -1135,6 +1141,7 @@ impl FigApp {
|
||||
"online": online::inspect(self),
|
||||
"tabs": self.session.docs.iter().map(|d| json!({"name": d.doc.name, "live": tabs::live_mark(self, d.uid).map(|shared| if shared { "shared" } else { "private" })})).collect::<Vec<_>>(),
|
||||
"homeSection": self.ui.home_view.section.id(),
|
||||
"homeRecents": home_recents::inspect(self),
|
||||
"dark": self.ui.dark,
|
||||
"focus": {"region": self.ui.nav.region.id(), "keyboard": self.ui.nav.keyboard},
|
||||
"reduceMotion": self.reduce_motion(),
|
||||
|
||||
@@ -190,11 +190,11 @@ separate calls. Returns `{queued}`.
|
||||
|
||||
Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`.
|
||||
|
||||
### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}`
|
||||
### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}`
|
||||
|
||||
Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`,
|
||||
`home` (bool), `homeSection` (`recents`, `online` or `templates`: which section the home screen shows, as `ui.inspect` reports it; opening
|
||||
`online` asks each server once what became of your online files), `palette` (bool: the Actions palette), `mainMenu` (bool, or a top-level menu such
|
||||
`online` asks each server once what became of your online files), `homeRecentsView` (`grid|list`), `homeRecentsSort` (`modified|name|created`) and `homeRecentsOrder` (`desc|asc`): how the home screen shows its recent files (kept in the preferences; `ui.inspect` reports them under `homeRecents` with each file and whether its picture is in), `palette` (bool: the Actions palette), `mainMenu` (bool, or a top-level menu such
|
||||
as `"Plugins"` to open the main menu with it open), `pluginsWindow` (bool: Plugins ▸ Manage
|
||||
plugins…), `pluginsTab` (`installed|browse`: that window's tab), `pluginConfirm` (a plugin id of the
|
||||
directory listed last: its install confirmation shows, as its Install button would; `null` closes
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
@@ -73,6 +73,7 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged |
|
||||
| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `<image>` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Leptos code (#223): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: leptos`: a `#[component]` with `#[prop]` defaults, variant enums and `view!` markup, instances as calls, Code Connect `use` lines; the Dev Mode language list and rem units include it. Numbers unchanged |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
@@ -21,7 +21,7 @@ interaction feel not yet compared.
|
||||
| Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points |
|
||||
| Actions palette (Cmd+K, Cmd+/) | done | commands; no assets search parity |
|
||||
| Context menus | done | canvas and layers; Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) |
|
||||
| Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open and recent files, a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder |
|
||||
| Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder |
|
||||
| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content |
|
||||
| Keyboard shortcuts panel | done | docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover |
|
||||
| Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored |
|
||||
@@ -164,6 +164,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Home screen recent files as cards with thumbnails on their page colour, a sortable table view, selection, keyboard and a context menu |
|
||||
| 2026-10-13 | minor | Vector edit mode regions: with the Move tool the filled area under the pointer is hatched with "/" lines in the selection colour (about 1.4 px), clipped by the area's own fill rule so holes stay clear, and the hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); the spacing follows octave folding, fitted to measurements at several zoom levels (8 px at every power-of-two zoom, the gap growing with the zoom and stepping back at √2 · 2ⁿ), anchored to the vector so it doesn't slide when panning, one continuous pattern across selected areas; a click selects an area with its points (they move with it but keep the unselected look), Shift adds an area or takes a selected one out, a press on a selected area drags everything selected, drags show no hover hatch, a click on an empty spot (such as a hole) clears the areas and stays in edit mode, the last area listed wins where areas overlap, only filled vectors have areas; `vector.edit.select {regions}` and `vector.edit.state` report them (`regions_tests`, `vector_regions_tests`) |
|
||||
| 2026-10-13 | minor | Home screen: a Templates tab (eight starter templates as cards with thumbnails, search and a kind filter; click or Enter opens a new document) replaces the Community placeholder, the Drafts placeholder is removed, Recents offers four templates while there are no recent files and shows a Getting started card (closable; Help ▸ Getting started brings it back), File ▸ New from template…, and a first run opens on the home screen |
|
||||
| 2026-10-13 | minor | Full screen (#227): View ▸ Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux, Ctrl+Cmd+F on macOS) makes the window fill the screen, taskbar and title bar included, and leaves it again; FigCraft's own item (not in the incumbent's menu). Percentages and hours unchanged |
|
||||
|
||||
Reference in New Issue
Block a user