diff --git a/.gitignore b/.gitignore index 2a6e1d695..0efa6743a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,6 @@ # Private Arcade frontend. Clone into this path: # git clone git@github.com:makepad/sandbox.git apps/sandbox /apps/sandbox/ -# git clone git@github.com:makepad/source-library.git apps/source-library -/apps/source-library/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html diff --git a/Cargo.toml b/Cargo.toml index 460538847..d7ed84544 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,33 +1,18 @@ workspace.members = [ # === app === - "apps/mpbrowser", - "apps/mpterm", - "apps/mpwm", - "apps/finance", - "apps/mpsheets", - "libs/mp_theme", - "libs/mp_wm_api", - "apps/mptask", - "apps/mpimage", - "apps/mpvideo", - "apps/mppdf", - "apps/mpfiles", "apps/route", "apps/asset-ui", "apps/asset-server", - "apps/ai-hub", "apps/vj", "apps/mixer", "libs/asset/importer", - # The tiled-RTS map generator, shared by the importer and the sandbox. - "libs/rtsmap", "libs/texcomp", "libs/asset/data", "libs/asset/client", "libs/asset/widgets", "libs/asset/store", "libs/asset/chat", - "libs/chat_ui", + "libs/asset/chat_ui", "libs/asset/annotate", "libs/render", "libs/raytrace", @@ -37,8 +22,6 @@ workspace.members = [ "libs/sim", "libs/sim/math", "libs/show_control", - "libs/asset/creator", - "libs/strict_json", # === remesh (FaithC port) / xatlas (jpcy port) === "libs/remesh", "libs/xatlas", @@ -56,7 +39,6 @@ workspace.members = [ "examples/datagrid", "examples/todo", "examples/portallist_hit", - "examples/modal_footprint", "examples/vector", "examples/aichat", "examples/pdf", @@ -89,20 +71,6 @@ workspace.members = [ "studio/desktop", # === own SQLite-format database engine (P0 reader, sqlq CLI) === "libs/sqlite_query", - # === headless music score engine === - "libs/score", - "libs/score_layout", - "libs/score_play", - "libs/score_render", - "libs/score_ai", - "libs/score_import", - "libs/score_pdf", - "libs/score_ui", - "apps/score", - "libs/midi_file", - "libs/soundfont", - "libs/piano_model", - "libs/musicxml", # === own MP3 / Ogg Vorbis decoders === "libs/audio_decode", # === own Ogg Vorbis encoder (stem side-channels) === @@ -111,25 +79,14 @@ workspace.members = [ "libs/audio_lyrics", # === stems + lyrics side-channel bake/publish (asset-ui + VJ) === "libs/audio_sidechannels", - "libs/teamtalk", # === pictures of audio (spectrogram, wave strip, composite) === "libs/audio_picture", # === mkfl motion payload + classical optical flow + all-intra re-encode # (shared by the enhance backend and the VJ's flow-warp import) === "libs/video_flow", - # === realtime frame tweening: the classical GPU optical-flow pass - # chain, the RIFE producer behind the same warp, and the mode set - # (extracted from the VJ, which is still its reference user) === - "libs/frametween", - # === archive.org search + download content input (VJ / asset-ui) === - "libs/archive_org", - # === mp4 sample index for range-streaming playback === - "libs/mp4_index", # === necessary tools === "platform/video", "tools/cargo_makepad", - # === OSM PBF -> tile archive + nav artifact bake passes (CLI + in-app) === - "libs/map_build", "tools/map_tiles", "tools/map_bake", "tools/remote", @@ -142,12 +99,10 @@ workspace.exclude = [ # like a subdirectory. Not a required member so a clean makepad tree # still loads. "apps/sandbox", - # Private Source Library picture wall. Same shape as sandbox: clone into - # apps/source-library; path deps stay ../../. - "apps/source-library", + "apps/sandbox/tools/sandbox-eval", "libs/terminal_core", # standalone GPU generate service (own workspace, like diffusion) - "libs/ai/hub", + "libs/asset/ai", "libs/diffusion", # the AI model workspace (loader + cuda/metal stores + model crates) — aiarch.md "libs/ai", diff --git a/apps/ai-hub/Cargo.toml b/apps/ai-hub/Cargo.toml deleted file mode 100644 index 0ecb86897..000000000 --- a/apps/ai-hub/Cargo.toml +++ /dev/null @@ -1,35 +0,0 @@ -[package] -name = "makepad-app-ai-hub" -version = "0.1.0" -edition = "2021" -publish = false - -[[bin]] -name = "makepad-ai-hub" -path = "src/main.rs" - -[dependencies] -makepad-ai-hub = { path = "../../libs/ai/hub", default-features = false } - -# Feature passthrough: the headless node exposes exactly the library's -# feature set, so box launch scripts keep their --features flags working. -[features] -default = ["flux", "paint", "paint-cuda", "llm", "tts", "indextts", "video", "interpolate", "audio", "mesh", "matte-native", "depth-native", "segment-native", "upscale-native", "motion-native", "rig-native", "splat-native"] -python-backends = ["makepad-ai-hub/python-backends"] -flux = ["makepad-ai-hub/flux"] -paint = ["makepad-ai-hub/paint"] -paint-cuda = ["makepad-ai-hub/paint-cuda"] -llm = ["makepad-ai-hub/llm"] -tts = ["makepad-ai-hub/tts"] -indextts = ["makepad-ai-hub/indextts"] -video = ["makepad-ai-hub/video"] -interpolate = ["makepad-ai-hub/interpolate"] -audio = ["makepad-ai-hub/audio"] -mesh = ["makepad-ai-hub/mesh"] -matte-native = ["makepad-ai-hub/matte-native"] -depth-native = ["makepad-ai-hub/depth-native"] -segment-native = ["makepad-ai-hub/segment-native"] -upscale-native = ["makepad-ai-hub/upscale-native"] -motion-native = ["makepad-ai-hub/motion-native"] -splat-native = ["makepad-ai-hub/splat-native"] -rig-native = ["makepad-ai-hub/rig-native"] diff --git a/apps/asset-server/src/embed.rs b/apps/asset-server/src/embed.rs deleted file mode 100644 index 6f5ea2ce4..000000000 --- a/apps/asset-server/src/embed.rs +++ /dev/null @@ -1,358 +0,0 @@ -//! FULLY-LOCAL MODE, shared: an app hosting its own Asset Server. -//! -//! The VJ and the sandbox are thin clients over a store somebody else runs — -//! normally asset-ui's embedded one, or the standalone host. That stays the -//! default and still wins whenever it is actually reachable. This module is -//! the shared answer to "and if it is not?": the app brings up [`Host`] in -//! its own process, on 127.0.0.1 only, rooted in the user's main library -//! when one exists on disk (a private seed root only when none does), with -//! the ai-content library publisher riding along. -//! -//! The thin-client law does not bend for this. Hosting changes WHERE the -//! store runs, never who owns the content: the app still browses, publishes -//! and fetches over HTTP, through the same `AssetClient`, with the same -//! catalog-event subscription. Nothing durable moves into the app. -//! -//! ## Loopback, deliberately -//! -//! The embedded host binds 127.0.0.1 for both planes (port 0 = OS-assigned, -//! or pinned by `_ASSET_PORT`) and runs NO discovery beacon: an app -//! hosting for itself has no business accepting connections from the -//! network, and announcing the store would invite other apps onto a private -//! instance of the user's library. That is also what makes reference -//! imports safe to enable here (see `makepad_asset_store::blobrefs`). -//! -//! ## Choosing -//! -//! `_ASSET_EMBED` decides, `auto` by default: -//! -//! - `never` — attach only; if nothing is reachable, behave as before. -//! - `auto` — attach if an external store ANSWERS, else host. -//! - `always` — host, regardless of what else is running. -//! -//! "Answers" is a real probe, not a guess: `GET /v1/health` against the -//! endpoints the caller advertises, failing that a short listen for a UDP -//! beacon, failing that the main store root's `server.lock` — a held lock -//! is proof the user's library is hosted RIGHT NOW whatever its ports, and -//! self-hosting then would silently split their library in two. - -use crate::{Host, HostConfig}; -use makepad_asset_client::ApiEndpoints; -use makepad_asset_store::BlobRefPolicy; -use std::io::{Read, Write}; -use std::net::{SocketAddr, TcpStream}; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -/// How the app got its store this run. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum StoreMode { - /// Attached to a store some other process runs (the usual case). - Attached, - /// Hosting the store in this process, on loopback. - Hosting, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum EmbedPolicy { - Never, - Auto, - Always, -} - -/// `_ASSET_EMBED`, `auto` when unset or unrecognised. -pub fn embed_policy(prefix: &str) -> EmbedPolicy { - match std::env::var(format!("{prefix}_ASSET_EMBED")) - .unwrap_or_default() - .trim() - .to_ascii_lowercase() - .as_str() - { - "never" | "off" | "0" => EmbedPolicy::Never, - "always" | "force" | "1" => EmbedPolicy::Always, - _ => EmbedPolicy::Auto, - } -} - -/// The checkout this binary was built in — every default root hangs off it. -fn checkout_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") -} - -/// Where asset-ui keeps the user's main store. -pub fn main_store_root() -> PathBuf { - checkout_root().join("local/asset-ui/asset-server") -} - -/// The ai-content library this checkout generates into — the same default -/// the standalone asset-server and asset-ui use. -fn library_root() -> PathBuf { - checkout_root().join("local/ai_content_library") -} - -/// The root the app hosts over when it hosts. -/// -/// `_ASSET_ROOT` pins it. Otherwise the user's MAIN library — -/// asset-ui's store root — whenever it holds a catalog: hosting only happens -/// after the probe found nobody serving, and `AssetServer::start` takes the -/// same `server.lock` the daemon would, so losing the race resolves to -/// "attach instead", never to two servers over one WAL. The private seed -/// root is only for a machine with no main library at all — self-hosting a -/// fresh empty root next to a full library reads as "the app lost my -/// content". -pub fn default_store_root(prefix: &str, seed_dir: &str) -> PathBuf { - if let Ok(root) = std::env::var(format!("{prefix}_ASSET_ROOT")) { - return PathBuf::from(root); - } - let main = main_store_root(); - if main.join("catalog.sqlite3").exists() { - return main; - } - checkout_root().join("local").join(seed_dir) -} - -/// Does something on the other end of `addr` answer `GET /v1/health` like an -/// Asset Server? -/// -/// Deliberately minimal and deliberately SHORT: this runs on the startup -/// path, and its only job is to tell "the user's server is up" from "that -/// port file is stale". A 400 ms budget is generous for loopback and -/// invisible to a human. Anything unexpected reads as "no" — the cost of a -/// false negative is one extra local store; the cost of a false positive is -/// an app that never connects. -fn health_answers(addr: SocketAddr) -> bool { - let Ok(mut stream) = TcpStream::connect_timeout(&addr, Duration::from_millis(400)) else { - return false; - }; - let _ = stream.set_read_timeout(Some(Duration::from_millis(400))); - let _ = stream.set_write_timeout(Some(Duration::from_millis(400))); - let req = format!( - "GET /v1/health HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", - addr - ); - if stream.write_all(req.as_bytes()).is_err() { - return false; - } - let mut buf = [0u8; 256]; - let mut got = 0usize; - // One short read is enough: the status line is the first 15 bytes. - while got < 16 { - match stream.read(&mut buf[got..]) { - Ok(0) => break, - Ok(n) => got += n, - Err(_) => break, - } - } - buf[..got].starts_with(b"HTTP/1.1 200") -} - -/// Listen briefly for an Asset Server beacon on the LAN discovery port. -/// -/// This is the second chance for "a server is running": HTTP ports are -/// ephemeral and `listen` files go stale every launch, but a beacon is -/// live. One beacon period is 2 s, so we wait a little over one. -fn beacon_heard(wait_ms: u64) -> bool { - use makepad_asset_client::discovery::DiscoveryListener; - use makepad_asset_client::util::now_ms; - let Ok(listener) = DiscoveryListener::start( - makepad_asset_client::wire::DEFAULT_DISCOVERY_PORT, - 10_000, - now_ms, - ) else { - return false; - }; - let deadline = std::time::Instant::now() + Duration::from_millis(wait_ms); - while std::time::Instant::now() < deadline { - if !listener.snapshot(now_ms()).is_empty() { - return true; - } - std::thread::sleep(Duration::from_millis(100)); - } - false -} - -/// Is an external store actually reachable right now? -pub fn external_store_reachable(hinted: Option) -> bool { - if let Some(endpoints) = hinted { - if health_answers(endpoints.control) { - return true; - } - } - // Ports move every launch; the beacon does not. - if beacon_heard(2_400) { - return true; - } - // THE USER'S MAIN STORE IS ALIVE BUT NOT ANSWERING YET (a succession - // handover, a stale `listen` file, a beacon missed by a hair): the - // lock holder is proof it exists. Self-hosting here would SILENTLY - // put this app on a private empty store — "no content in the grid" — - // so treat a held lock as reachable and let the attach path keep - // discovering; it retries on its own. - main_store_lock_held() -} - -/// True when another process holds the main (asset-ui) store's server -/// lock — i.e. the user's library is hosted right now, whatever its ports. -fn main_store_lock_held() -> bool { - let root = main_store_root(); - let Ok(file) = std::fs::OpenOptions::new() - .write(true) - .open(root.join("server.lock")) - else { - return false; - }; - // The SAME advisory lock the store takes (File::try_lock): if we can - // take it, nobody serves that root; the file drop releases it. - match file.try_lock() { - Ok(()) => { - let _ = file.unlock(); - false - } - Err(_) => true, - } -} - -/// The in-process host (server + library publisher), held for as long as -/// the app runs. -/// -/// Dropping it stops the publisher first and the server last, so the field -/// holding this must be declared AFTER anything that talks to the store — -/// the same drop-order discipline asset-ui's `AssetStore` uses. -pub struct LocalStore { - host: Host, - root: PathBuf, -} - -impl LocalStore { - pub fn endpoints(&self) -> ApiEndpoints { - self.host.endpoints() - } - - pub fn root(&self) -> &Path { - &self.root - } - - pub fn control_addr(&self) -> SocketAddr { - self.host.endpoints().control - } - - pub fn data_addr(&self) -> SocketAddr { - self.host.endpoints().data - } - - pub fn server_id(&self) -> [u8; 16] { - self.host.server_id() - } - - pub fn token(&self) -> &str { - self.host.token() - } - - pub fn publisher_running(&self) -> bool { - self.host.publisher_running() - } -} - -/// What [`resolve`] decided, and why — the `note` belongs in the app's -/// status line, because "which store am I on" is the first thing that -/// matters when content does not show up. -pub struct Resolved { - pub mode: StoreMode, - /// The embedded host, when hosting. The caller owns it for the life of - /// the app and points its client at [`LocalStore::endpoints`] with - /// [`LocalStore::server_id`] and [`LocalStore::token`]. - pub local: Option, - pub note: String, -} - -/// Decide between attaching and hosting. -/// -/// `pinned` is the caller saying "an explicit server was named" — naming a -/// server is not something you do by accident, so it always means attach. -/// `hinted` is where the caller last knew a server to live, for the health -/// probe. -pub fn resolve( - prefix: &str, - seed_dir: &str, - pinned: bool, - hinted: Option, -) -> Resolved { - let policy = embed_policy(prefix); - - if pinned || policy == EmbedPolicy::Never { - let note = if pinned { - format!("asset server pinned by {prefix}_ASSET_SERVER") - } else { - format!("attach-only ({prefix}_ASSET_EMBED=never)") - }; - return Resolved { mode: StoreMode::Attached, local: None, note }; - } - - if policy == EmbedPolicy::Auto && external_store_reachable(hinted) { - return Resolved { - mode: StoreMode::Attached, - local: None, - note: "attached to the running asset server".to_string(), - }; - } - - let root = default_store_root(prefix, seed_dir); - match host_at(&root, prefix) { - Ok(local) => { - let note = format!( - "local store on {} over {} · library publisher {}", - local.control_addr(), - root.file_name().and_then(|n| n.to_str()).unwrap_or("store"), - if local.publisher_running() { "on" } else { "off" } - ); - Resolved { mode: StoreMode::Hosting, local: Some(local), note } - } - Err(error) => { - // Hosting failed — most often because another process already - // holds this root's lock. Say so and fall back to the attach - // path, which keeps retrying discovery on its own. - Resolved { - mode: StoreMode::Attached, - local: None, - note: format!("local store unavailable ({error}); attaching instead"), - } - } - } -} - -/// Bring up the in-process host on loopback. -/// -/// This is the standalone asset-server's own [`Host`] — catalog + CAS plus -/// the ai-content LIBRARY PUBLISHER, so a self-hosted app sees the same -/// `local/ai_content_library` rows asset-ui and the standalone server -/// publish, not a bare seed store. Two deployment defaults are overridden, -/// deliberately, and both stay: LOOPBACK ONLY, and NO discovery beacon. -fn host_at(root: &Path, prefix: &str) -> Result { - std::fs::create_dir_all(root).map_err(|e| format!("create store root: {e}"))?; - let port: u16 = std::env::var(format!("{prefix}_ASSET_PORT")) - .ok() - .and_then(|p| p.trim().parse().ok()) - .unwrap_or(0); - let mut cfg = HostConfig::new(root.to_path_buf()); - cfg.control_addr = SocketAddr::from(([127, 0, 0, 1], port)); - // The data plane always takes an OS-assigned port: pinning one would - // only create a second thing to collide. - cfg.data_addr = SocketAddr::from(([127, 0, 0, 1], 0)); - cfg.beacon = false; - // The user's generation library, when this checkout has one: the - // publisher keeps turning it into catalog rows exactly as the - // standalone server would. - let library = library_root(); - cfg.library = library.join("index.json").exists().then_some(library); - // Reference imports ON: the whole point of the local mode is pointing - // the store at content that stays where it is. Loopback-only and no - // prefix restriction, which is exactly the privilege this process - // already has over the user's own files. - cfg.blob_refs = BlobRefPolicy::local_host(); - cfg.log = true; - let host = Host::start(&cfg).map_err(|e| format!("{e}"))?; - if host.token().is_empty() { - return Err("admin token file empty".to_string()); - } - Ok(LocalStore { host, root: root.to_path_buf() }) -} diff --git a/apps/asset-server/src/lib.rs b/apps/asset-server/src/lib.rs index 123c1346c..bf4380f57 100644 --- a/apps/asset-server/src/lib.rs +++ b/apps/asset-server/src/lib.rs @@ -6,31 +6,39 @@ //! and headless workers are all clients of ONE catalog, and they come and go //! independently. When the catalog lives inside one of those apps, that //! app's lifetime becomes everybody's lifetime: closing the Asset UI window -//! takes the store and the events hub down under every other connected -//! client, which they see as `503 state unavailable` mid-session. This -//! binary breaks that coupling — the server outlives every window. +//! takes the store, the chat broker and the events hub down under every +//! other connected client, which they see as `503 state unavailable` +//! mid-session. This binary breaks that coupling — the server outlives every +//! window. //! //! # What it carries //! -//! [`Host::start`] composes two things, both existing, tested code: +//! [`Host::start`] composes three things that a fleet of clients needs, all +//! of them existing, tested code: //! //! 1. [`makepad_asset_store::AssetServer`] — catalog + CAS over the control -//! and data planes, the games publish path, the committed events hub, -//! game rooms, the blob GC janitor, and the LAN discovery beacon. +//! and data planes, the chat broker (including client-executed tool +//! parking for game sessions), the games publish path, the committed +//! events hub, the job queue and worker/lease protocol, the lease + blob +//! GC janitor, and the LAN discovery beacon. //! 2. The **library publisher** (`makepad_asset_importer::watch`) — whatever //! the generation pipelines write into the ai-content library becomes //! catalog rows. Headless, so it belongs beside the server rather than //! inside a UI. +//! 3. The **fleet job coordinator** (`makepad_asset_importer::gen_service`) +//! — claims queued generation jobs, dispatches them to the asset-ai GPU +//! boxes the LAN announces, publishes the verified results, and +//! advertises what the fleet can execute right now on +//! `GET /v1/job-profiles`. Without it, jobs any client enqueues sit at +//! "waiting for agent" forever. //! //! # What deliberately stays client-side //! -//! Everything that CREATES content (aicore: "the store stores, the client -//! creates"). Generation runs in the creating apps over their own ai-hub -//! fleet connections (`makepad-asset-creator`), chat sessions live in-app, -//! and loops that derive content with resources only a UI process has — the -//! offscreen thumbnail renders, the classic-game import wizards, the -//! stems/lyrics analysis bake — stay in the app and reach the catalog as -//! ordinary clients. +//! Loops that DERIVE content using resources only a UI process has — the +//! offscreen thumbnail renders (`Cx`, a GPU surface, the splat/mesh +//! viewers), the classic-game import wizards, the stems/lyrics analysis +//! bake — stay in the app and reach the catalog as ordinary clients. Moving +//! them here would mean giving a headless daemon a window. //! //! # Single-owner laws //! @@ -40,19 +48,23 @@ //! let it attach (see the README). //! - An `AssetClient` cache root is single-owner too, so each loop gets its //! own child of the work root. - -pub mod embed; +//! - The job coordinator is at most ONE per process (its stop flag is a +//! `'static`, borrowed by the service for the thread's whole life). A +//! second [`Host`] with `jobs` enabled in the same process refuses the +//! coordinator and says so in [`Host::jobs_error`] rather than starting a +//! second claimer that would fight the first for leases. use makepad_asset_client::{ApiEndpoints, AssetClient, ClientConfig, PublishRights}; -use makepad_asset_store::{AssetServer, BlobRefPolicy, DiscoveryConfig, ServerConfig}; +use makepad_asset_store::{AssetServer, DiscoveryConfig, ServerConfig}; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread::JoinHandle; -/// Default namespace the library publisher publishes into. Same value the -/// creator apps publish with, so rows from either path sit side by side. +/// Default namespace the coordinator advertises and publishes into. Same +/// value the Asset UI's in-process coordinator uses, so a job enqueued +/// against either host is routed identically. pub const DEFAULT_NAMESPACE: &str = "gen"; /// Everything the host needs. Every knob is a named field: nothing here is @@ -73,21 +85,37 @@ pub struct HostConfig { pub beacon: bool, /// ai-content library to publish continuously. `None` = no publisher. pub library: Option, - /// Namespace for published rows. + /// Run the fleet job coordinator. + pub jobs: bool, + /// Let the coordinator advertise the fleet's executable job profiles. + /// Ignored when `jobs` is false. + pub announce: bool, + /// Explicit GPU-box URL list; `None` = LAN discovery. + pub fleet_file: Option, + /// Namespace for published rows and advertised profiles. pub namespace: String, /// Parent for the loops' single-owner client caches. Defaults to the /// server root's parent, which puts them exactly where the Asset UI's /// own hosting mode puts them. pub work_root: PathBuf, - /// Reference-import policy handed to the server. The deployment default - /// (owned blobs only); a loopback-only embedder may open this up. - pub blob_refs: BlobRefPolicy, + /// Chat: local Qwen fleet node base URLs. Empty = LAN fleet discovery, + /// same as the Asset UI's embedded broker. + pub chat_fleet_bases: Vec, + /// Named fleet the chat broker talks to. Empty = `default`. + pub chat_fleet: String, + /// Live chat sessions this server will hold at once, and how many any + /// one principal may hold. A box that serves several parallel chat + /// slots wants these raised; the defaults (32 / 8) match the library. + /// 0 = keep the library default. + pub chat_max_sessions: usize, + pub chat_max_sessions_per_owner: usize, /// Log to stderr. pub log: bool, } impl HostConfig { - /// Deployment defaults: ephemeral planes on every interface, beacon on. + /// Deployment defaults: ephemeral planes on every interface, beacon on, + /// both background loops on, LAN fleet. pub fn new(root: PathBuf) -> Self { let work_root = root .parent() @@ -99,49 +127,81 @@ impl HostConfig { data_addr: SocketAddr::from(([0, 0, 0, 0], 0)), beacon: true, library: None, + jobs: true, + announce: true, + fleet_file: None, namespace: DEFAULT_NAMESPACE.to_string(), work_root, - blob_refs: BlobRefPolicy::default(), + chat_fleet_bases: Vec::new(), + chat_fleet: String::new(), + chat_max_sessions: 0, + chat_max_sessions_per_owner: 0, log: true, } } } /// A running host. Dropping it (or calling [`Host::shutdown`]) stops the -/// publisher first and the server last, so nothing is still publishing into -/// a catalog that is closing. +/// loops first and the server last, so nothing is still publishing into a +/// catalog that is closing. pub struct Host { - // Declaration order IS drop order: the loop is joined while the server - // it talks to is still answering. + // Declaration order IS drop order: both loops are joined while the + // server they talk to is still answering. publish: Option, + jobs: Option, server: Option, endpoints: ApiEndpoints, server_id: [u8; 16], token: String, library_error: Option, + jobs_error: Option, } /// One owned background thread plus the flag that stops it. struct BackgroundLoop { - stop: Arc, + stop: Stop, join: Option>, } +/// A loop's stop flag. The library watcher takes `&AtomicBool` and can own a +/// per-host `Arc`; the generation service borrows a `&'static` for the +/// thread's whole life, so there is exactly one of those per process. +enum Stop { + Owned(Arc), + Static(&'static AtomicBool), +} + +impl Stop { + fn raise(&self) { + match self { + Stop::Owned(flag) => flag.store(true, Ordering::Release), + Stop::Static(flag) => flag.store(true, Ordering::Release), + } + } +} + impl Drop for BackgroundLoop { fn drop(&mut self) { - self.stop.store(true, Ordering::Release); + self.stop.raise(); if let Some(join) = self.join.take() { let _ = join.join(); } } } +/// Stop flag for the one in-process job coordinator; see the module header. +static JOBS_STOP: AtomicBool = AtomicBool::new(false); +/// Set while a coordinator is live, so a second one is refused instead of +/// silently competing for the same leases. +static JOBS_RUNNING: AtomicBool = AtomicBool::new(false); + impl Host { - /// Bring the server up, then the publisher. + /// Bring the server up, then the background loops. /// - /// The SERVER is the only thing whose failure is fatal — a publisher - /// that cannot start is reported ([`Host::library_error`]) and logged, - /// never a reason to deny every client its catalog. + /// The SERVER is the only thing whose failure is fatal — a loop that + /// cannot start is reported ([`Host::library_error`], + /// [`Host::jobs_error`]) and logged, never a reason to deny every client + /// its catalog. pub fn start(config: &HostConfig) -> Result { let mut cfg = ServerConfig::new(config.root.clone()); cfg.control_addr = config.control_addr; @@ -151,7 +211,14 @@ impl Host { // root the same way it does against an Asset-UI-hosted server. cfg.bootstrap_admin = true; cfg.discovery = config.beacon.then(DiscoveryConfig::lan_default); - cfg.blob_refs = config.blob_refs.clone(); + cfg.chat.fleet_bases = config.chat_fleet_bases.clone(); + cfg.chat.fleet = config.chat_fleet.clone(); + if config.chat_max_sessions > 0 { + cfg.chat.max_sessions = config.chat_max_sessions; + } + if config.chat_max_sessions_per_owner > 0 { + cfg.chat.max_sessions_per_owner = config.chat_max_sessions_per_owner; + } cfg.log = config.log; let server = AssetServer::start(cfg).map_err(|error| format!("asset server: {error}"))?; @@ -175,23 +242,37 @@ impl Host { } }, }; + let (jobs, jobs_error) = if config.jobs { + match start_coordinator(config, endpoints, server_id, &token) { + Ok(handle) => (Some(handle), None), + Err(error) => { + log(config.log, &format!("job coordinator: {error}")); + (None, Some(error)) + } + } + } else { + (None, None) + }; log( config.log, &format!( - "asset host up: control {} data {} · publisher {}", + "asset host up: control {} data {} · publisher {} · coordinator {}", endpoints.control, endpoints.data, publish.as_ref().map_or("off", |_| "on"), + jobs.as_ref().map_or("off", |_| "on"), ), ); Ok(Host { publish, + jobs, server: Some(server), endpoints, server_id, token, library_error, + jobs_error, }) } @@ -214,14 +295,24 @@ impl Host { self.publish.is_some() } + pub fn coordinator_running(&self) -> bool { + self.jobs.is_some() + } + /// Why the library publisher is not running, when it was asked for. pub fn library_error(&self) -> Option<&str> { self.library_error.as_deref() } - /// Stop the publisher, then the server. Idempotent; also runs on drop. + /// Why the job coordinator is not running, when it was asked for. + pub fn jobs_error(&self) -> Option<&str> { + self.jobs_error.as_deref() + } + + /// Stop the loops, then the server. Idempotent; also runs on drop. pub fn shutdown(&mut self) { drop(self.publish.take()); + drop(self.jobs.take()); if let Some(mut server) = self.server.take() { server.shutdown(); } @@ -288,7 +379,64 @@ fn start_publisher( log(log_enabled, "library publisher: stopped"); }) .map_err(|error| format!("cannot spawn the publisher thread: {error}"))?; - Ok(BackgroundLoop { stop, join: Some(join) }) + Ok(BackgroundLoop { + stop: Stop::Owned(stop), + join: Some(join), + }) +} + +/// Claim queued generation jobs and dispatch them to the GPU fleet. +fn start_coordinator( + config: &HostConfig, + endpoints: ApiEndpoints, + server_id: [u8; 16], + token: &str, +) -> Result { + use makepad_asset_importer::gen_service::{FleetSource, GenServiceConfig}; + if JOBS_RUNNING.swap(true, Ordering::AcqRel) { + return Err("a job coordinator is already running in this process".to_string()); + } + JOBS_STOP.store(false, Ordering::Release); + let service = GenServiceConfig { + servers: vec![endpoints], + server_id: Some(server_id), + token: token.to_string(), + cache_root: config.work_root.join("jobs-cache"), + namespace: config.namespace.clone(), + suffix: "asset-host".to_string(), + rights: PublishRights::generated_cc0(), + fleet: match &config.fleet_file { + Some(path) => FleetSource::File(path.clone()), + None => FleetSource::Lan, + }, + announce: config.announce, + log: config.log, + }; + let log_enabled = config.log; + let join = std::thread::Builder::new() + .name("asset-host-jobs".to_string()) + .spawn(move || { + log( + log_enabled, + &format!( + "job coordinator: {} -> the GPU fleet", + service.servers[0].control + ), + ); + makepad_asset_importer::gen_service::run(&service, &JOBS_STOP); + log(log_enabled, "job coordinator: stopped"); + JOBS_RUNNING.store(false, Ordering::Release); + }); + match join { + Ok(join) => Ok(BackgroundLoop { + stop: Stop::Static(&JOBS_STOP), + join: Some(join), + }), + Err(error) => { + JOBS_RUNNING.store(false, Ordering::Release); + Err(format!("cannot spawn the coordinator thread: {error}")) + } + } } fn log(enabled: bool, message: &str) { @@ -314,13 +462,15 @@ mod tests { } /// An isolated host: loopback-only ephemeral planes, NO beacon (a test - /// must never advertise itself to the operator's LAN). + /// must never advertise itself to the operator's LAN), no coordinator + /// (it would claim the real fleet's jobs). fn isolated(name: &str) -> HostConfig { let base = test_root(name); let mut config = HostConfig::new(base.join("server")); config.control_addr = "127.0.0.1:0".parse().unwrap(); config.data_addr = "127.0.0.1:0".parse().unwrap(); config.beacon = false; + config.jobs = false; config.log = false; config.work_root = base.join("work"); config @@ -401,6 +551,7 @@ mod tests { fn the_work_root_defaults_beside_the_server_root() { let config = HostConfig::new(PathBuf::from("/store/local/asset-ui/asset-server")); assert_eq!(config.work_root, PathBuf::from("/store/local/asset-ui")); + assert!(config.jobs, "a fleet host coordinates jobs by default"); assert!(config.beacon, "a fleet host is discoverable by default"); assert_eq!(config.namespace, DEFAULT_NAMESPACE); assert_eq!( diff --git a/apps/asset-server/src/main.rs b/apps/asset-server/src/main.rs index bb0c560e8..753cd8496 100644 --- a/apps/asset-server/src/main.rs +++ b/apps/asset-server/src/main.rs @@ -3,8 +3,8 @@ //! Parse flags into a [`HostConfig`], start the host, and wait for //! SIGINT/SIGTERM to shut it down cleanly. With no flags at all it serves the //! checkout's standard store root on ephemeral ports, announces itself on the -//! LAN, and publishes the ai-content library — i.e. everything the Asset -//! UI's embedded mode does, minus the window. +//! LAN, publishes the ai-content library, and coordinates fleet jobs — i.e. +//! everything the Asset UI's embedded mode does, minus the window. //! //! See `README.md` beside this file for the deployment runbook. @@ -17,11 +17,10 @@ use std::time::Duration; const USAGE: &str = "\ makepad-asset-server [options] -The standalone, multiplayer-first Asset Server: catalog + CAS, events hub, -game rooms, LAN beacon, plus the ai-content library publisher. Clients -(asset-ui, vj, sandbox) attach to it and may come and go without ever -taking the store down. Generation runs in the creating apps over their own -fleet connections — the store stores. +The standalone, multiplayer-first Asset Server: catalog + CAS, chat broker, +events hub, job queue, LAN beacon, plus the ai-content library publisher and +the GPU-fleet job coordinator. Clients (asset-ui, vj, sandbox, workers) +attach to it and may come and go without ever taking the store down. Options: --root Server root. Default: $AI_CONTENT_ASSET_ROOT, else @@ -33,9 +32,21 @@ Options: --library ai-content library to publish continuously. Default: /local/ai_content_library --no-library Do not run the library publisher + --no-jobs Do not run the fleet job coordinator + --no-announce Coordinate jobs, but do not advertise the fleet's + executable profiles on GET /v1/job-profiles + --fleet GPU-box URL list (default: LAN discovery) --namespace Namespace for published rows (default gen) --work Parent for the loops' client caches (default: the server root's parent) + --chat-fleet Local Qwen fleet node for the chat broker + (repeatable; default: LAN fleet discovery) + --chat-fleet-name Named fleet the chat broker talks to + --chat-max-sessions Live chat sessions this server holds at once + (default 32) + --chat-max-sessions-per-owner + Live chat sessions one principal may hold + (default 8) --quiet No stderr logging --help This text "; @@ -94,8 +105,15 @@ fn parse_config() -> HostConfig { let mut beacon = true; let mut library: Option = None; let mut no_library = false; + let mut jobs = true; + let mut announce = true; + let mut fleet_file: Option = None; let mut namespace: Option = None; let mut work: Option = None; + let mut chat_fleet_bases: Vec = Vec::new(); + let mut chat_fleet = String::new(); + let mut chat_max_sessions = 0usize; + let mut chat_max_sessions_per_owner = 0usize; let mut log = true; let value_of = |name: &str, args: &mut dyn Iterator| -> String { @@ -118,8 +136,37 @@ fn parse_config() -> HostConfig { "--no-beacon" => beacon = false, "--library" => library = Some(PathBuf::from(value_of("--library", &mut args))), "--no-library" => no_library = true, + "--no-jobs" => jobs = false, + "--no-announce" => announce = false, + "--fleet" => fleet_file = Some(PathBuf::from(value_of("--fleet", &mut args))), "--namespace" => namespace = Some(value_of("--namespace", &mut args)), "--work" => work = Some(PathBuf::from(value_of("--work", &mut args))), + "--chat-fleet" => { + let value = value_of("--chat-fleet", &mut args); + if value.is_empty() { + fail("malformed --chat-fleet"); + } + chat_fleet_bases.push(value); + } + "--chat-fleet-name" => chat_fleet = value_of("--chat-fleet-name", &mut args), + "--chat-max-sessions" => { + let value = value_of("--chat-max-sessions", &mut args); + chat_max_sessions = value + .parse() + .unwrap_or_else(|_| fail("malformed --chat-max-sessions")); + if chat_max_sessions == 0 { + fail("--chat-max-sessions must be at least 1"); + } + } + "--chat-max-sessions-per-owner" => { + let value = value_of("--chat-max-sessions-per-owner", &mut args); + chat_max_sessions_per_owner = value + .parse() + .unwrap_or_else(|_| fail("malformed --chat-max-sessions-per-owner")); + if chat_max_sessions_per_owner == 0 { + fail("--chat-max-sessions-per-owner must be at least 1"); + } + } "--quiet" => log = false, "--help" | "-h" => { println!("{USAGE}"); @@ -145,6 +192,9 @@ fn parse_config() -> HostConfig { } else { Some(library.unwrap_or_else(|| checkout_root().join("local/ai_content_library"))) }; + config.jobs = jobs; + config.announce = announce; + config.fleet_file = fleet_file; if let Some(namespace) = namespace { if namespace.is_empty() { fail("--namespace needs a value"); @@ -154,6 +204,10 @@ fn parse_config() -> HostConfig { if let Some(work) = work { config.work_root = work; } + config.chat_fleet_bases = chat_fleet_bases; + config.chat_fleet = chat_fleet; + config.chat_max_sessions = chat_max_sessions; + config.chat_max_sessions_per_owner = chat_max_sessions_per_owner; config.log = log; config } diff --git a/apps/asset-ui/Cargo.toml b/apps/asset-ui/Cargo.toml index a5d4b4649..fc8e25666 100644 --- a/apps/asset-ui/Cargo.toml +++ b/apps/asset-ui/Cargo.toml @@ -9,7 +9,7 @@ license = "MIT OR Apache-2.0" makepad-widgets = { path = "../../widgets" } # Service wire types + fleet/affinity scheduler (transport here is # cx.http_request; the lib's blocking client is for worker-thread consumers). -makepad-ai-hub = { path = "../../libs/ai/hub", default-features = false } +makepad-asset-ai = { path = "../../libs/asset/ai", default-features = false } makepad-micro-serde = { path = "../../libs/micro_serde" } makepad-base64 = { path = "../../libs/base64" } # Mesh viewer: Renderer stage for a single GLB. @@ -23,7 +23,6 @@ makepad-xr = { path = "../../xr" } # retry), catalog runtimes, committed-event subscriber, verified cache. The # VJ worker owns these crates; this app consumes the public API only. makepad-asset-client = { path = "../../libs/asset/client" } -makepad-asset-creator = { path = "../../libs/asset/creator" } # Shared preview/viewer widgets: the pool every host draws catalog content # with (VJ and the DJ surface adopt the same set). # The renderer feature brings the 3D faces (mesh turntable, world walker). @@ -34,18 +33,13 @@ makepad-asset-chat = { path = "../../libs/asset/chat" } # broker session + its worker channel, the transcript with its tool chips # and rate meter, and the list widget. This app supplies the personality # (namespace `gen`, client profile `gen`, its own client-executed tools). -makepad-chat-ui = { path = "../../libs/chat_ui" } +makepad-asset-chat-ui = { path = "../../libs/asset/chat_ui" } # Embedded catalog/chat broker for now: the app starts the real Asset # Server in-process instead of waiting for a LAN beacon. makepad-asset-store = { path = "../../libs/asset/store" } # Local licensed-pack compiler (Kenney Import). Same fail-closed walk as # `makepad-asset-importer --import-pack`; no network fetch. makepad-asset-importer = { path = "../../libs/asset/importer" } -# The vision-annotation pass, for its VERSION only: this app draws the -# annotation bar and must agree with the store on which annotator version -# the catalog owes. The work itself is `annotate.asset`, a vision job the -# fleet coordinator claims — nothing here runs a model. -makepad-asset-annotate = { path = "../../libs/asset/annotate" } # The library is 241 MP3s and 4 WAVs: the transport has to be able to play # what the catalog actually holds, and the container has to be knowable from # the bytes when a digest-named cache object has no name to read. Zero diff --git a/apps/asset-ui/src/artifact_io.rs b/apps/asset-ui/src/artifact_io.rs index 052f97668..963516e9e 100644 --- a/apps/asset-ui/src/artifact_io.rs +++ b/apps/asset-ui/src/artifact_io.rs @@ -911,7 +911,7 @@ mod tests { // pixels + its cache-source key; garbage comes back as an honest // None (the app pins a badge instead of retrying forever). let sidecar = dir.join("lib-1.glb.thumb"); - let png = makepad_ai_hub::testpattern::encode_png_rgba( + let png = makepad_asset_ai::testpattern::encode_png_rgba( &[255u8; 6 * 4 * 4], 6, 4, @@ -996,7 +996,7 @@ mod tests { let y = (i / 1024) as u8; px.copy_from_slice(&[x, y, 128, 255]); } - let png = makepad_ai_hub::testpattern::encode_png_rgba(&rgba, 1024, 1024).unwrap(); + let png = makepad_asset_ai::testpattern::encode_png_rgba(&rgba, 1024, 1024).unwrap(); std::fs::write(&render, &png).unwrap(); io.request(IoRequest { file: "lib-flux.png".into(), @@ -1066,7 +1066,7 @@ mod tests { let v = if y < 64 { 200 } else { 20 }; px.copy_from_slice(&[v, v, v, 255]); } - let png = makepad_ai_hub::testpattern::encode_png_rgba(&rgba, 512, 128).unwrap(); + let png = makepad_asset_ai::testpattern::encode_png_rgba(&rgba, 512, 128).unwrap(); std::fs::write(&composite, &png).unwrap(); let fft_views = vec![ ThumbnailView { diff --git a/apps/asset-ui/src/asset_store_state.rs b/apps/asset-ui/src/asset_store_state.rs index 37a007e5f..a32506e79 100644 --- a/apps/asset-ui/src/asset_store_state.rs +++ b/apps/asset-ui/src/asset_store_state.rs @@ -50,8 +50,8 @@ use makepad_asset_client::{ ApiEndpoints, AssetDetailDto, CatalogEventDto, CatalogFacet, CatalogHit, CatalogQuery, CatalogSubscriptionEvent, ClientError, ClientEvent, ClientOutput, ClientRequest, GcRequest, - GcStatusDto, JobProfileDto, PageCursor, PipelineId, RequestId, RetireDto, SessionConfig, - SessionConnector, SessionHandles, SessionMsg, SessionStatus, + GcStatusDto, JobProfileDto, PageCursor, RequestId, RetireDto, SessionConfig, SessionConnector, + SessionHandles, SessionMsg, SessionStatus, }; use makepad_asset_data::{AssetId, AssetRevisionId}; pub use makepad_asset_data::AssetKind; @@ -180,8 +180,6 @@ pub fn server_kind_label(kind: AssetKind) -> &'static str { AssetKind::Billboard => "billboard", AssetKind::Game => "game", AssetKind::VjEffect => "vjeffect", - AssetKind::Data => "data", - AssetKind::ModelProgram => "model-program", } } @@ -364,6 +362,10 @@ pub struct AssetStore { /// `embedded` so it is joined while the server it publishes into is /// still alive. publish: Option, + /// In-process job coordinator: claims generation jobs queued on the + /// hosted server (the VJ's GEN tab, chat) and dispatches them to the + /// LAN fleet. Without it those jobs sit at "waiting for agent" forever. + jobs: Option, /// LIVECODING: observed origin directories → catalog, no copy. Declared /// BEFORE `embedded` for the same reason `publish` is: joined while the /// server it publishes into is still alive. @@ -393,11 +395,10 @@ pub struct AssetStore { pub selected: Option, pub detail: Remote, detail_req: Option, - /// Generation capabilities of the LIVE LAN fleet, built by probing the - /// boxes directly (the store advertises nothing any more — generation - /// is client-driven, aicore §9). + /// Advertised generation capabilities (`/v1/jobs/profiles`) — the REAL + /// server-side generation surface for the Runs panel. pub profiles: Remote>, - profiles_rx: Option>>, + profiles_req: Option, /// Committed catalog events, newest first, capped. pub events: VecDeque, /// The event feed delivered its initial cursor and is following commits. @@ -409,12 +410,6 @@ pub struct AssetStore { /// drains this to re-open what it is showing: a new revision means a new /// blob digest, so re-resolving is the whole of "stay current". changed_assets: Vec, - /// Declared runs the server announced as OVER since the app last looked - /// (`pipeline.finished`). This is the only honest end-of-run signal: a - /// publish is per-asset and coincidental, and a run that fails publishes - /// nothing at all. Drained by the RUNS chip, which re-reads on it - /// instead of waiting out its poll interval. - finished_pipelines: Vec, /// In-flight `RetireAsset`/`RetireRevision` requests, tracked only to /// surface a failure (or a mismatched output) honestly — success is /// applied locally via [`AssetStore::on_retired`] the moment the @@ -654,22 +649,6 @@ impl AssetStore { } } } - // Fleet-built generation profiles landing from their worker thread. - if let Some(rx) = &self.profiles_rx { - match rx.try_recv() { - Ok(profiles) => { - self.profiles_rx = None; - self.profiles = Remote::Ready(profiles); - changed = true; - } - Err(std::sync::mpsc::TryRecvError::Empty) => {} - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - self.profiles_rx = None; - self.profiles = Remote::Failed("fleet probe thread died".to_string()); - changed = true; - } - } - } let mut catalog_events = Vec::new(); let mut feed_events = Vec::new(); if let Some(handles) = &mut self.handles { @@ -808,6 +787,7 @@ impl AssetStore { if self.host_loops == HostLoops::Run { self.publish = start_publish_loop(&server, token, self.library_dir.clone()); + self.jobs = start_job_loop(&server, token); // LIVECODING: observed origin directories, catalogued in place. // Only the HOST runs this — reference admission is loopback // privilege, so an attached client never observes for somebody @@ -822,7 +802,7 @@ impl AssetStore { /// /// The subscriber only reports FAILED polls: a poll that succeeds with /// nothing to report sends no event at all, so a blip that healed would - /// otherwise look like a death forever. A GC status fetch is a plain + /// otherwise look like a death forever. A profiles fetch is a plain /// authenticated GET that changes nothing, and its answer — success, or /// even a refusal, which is still a server talking — clears the loss. fn submit_probe(&mut self) { @@ -830,7 +810,10 @@ impl AssetStore { return; } let Some(handles) = &mut self.handles else { return }; - if let Ok(id) = handles.catalog.submit(ClientRequest::GcStatus) { + if let Ok(id) = handles + .catalog + .submit(ClientRequest::FetchJobProfiles { domain: None }) + { self.probe_req = Some(id); } } @@ -856,7 +839,7 @@ impl AssetStore { self.search_continuation = false; self.next_cursor = None; self.detail_req = None; - self.profiles_rx = None; + self.profiles_req = None; self.probe_req = None; self.gc_req = None; self.gc_cancel_req = None; @@ -1023,11 +1006,6 @@ impl AssetStore { } /// Take the assets catalog events touched since the last call. - /// Runs the server announced as finished since the last call. - pub fn take_finished_pipelines(&mut self) -> Vec { - std::mem::take(&mut self.finished_pipelines) - } - pub fn take_changed_assets(&mut self) -> Vec { std::mem::take(&mut self.changed_assets) } @@ -1145,23 +1123,18 @@ impl AssetStore { } } - /// Build the generation-profile list from the LIVE fleet: probe the - /// boxes the LAN announces and let the shared profile builder say what - /// they can execute right now. Runs on its own thread — LAN probes must - /// never stall a frame — and lands through `profiles_rx` in [`Self::poll`]. fn submit_profiles(&mut self) { - let (tx, rx) = std::sync::mpsc::channel(); - self.profiles_rx = Some(rx); - self.profiles = Remote::Loading; - let _ = std::thread::Builder::new() - .name("asset-ui-profiles".to_string()) - .spawn(move || { - let snapshots = makepad_asset_creator::runner::fleet_snapshots(); - let profiles = makepad_asset_importer::gen_profiles::build_profiles( - &snapshots, "gen", - ); - let _ = tx.send(profiles); - }); + let Some(handles) = &mut self.handles else { return }; + match handles + .catalog + .submit(ClientRequest::FetchJobProfiles { domain: None }) + { + Ok(id) => { + self.profiles_req = Some(id); + self.profiles = Remote::Loading; + } + Err(error) => self.profiles = Remote::Failed(error.to_string()), + } } fn on_catalog_event(&mut self, event: ClientEvent) -> bool { @@ -1251,6 +1224,8 @@ impl AssetStore { 0 } else if Some(id) == self.detail_req { 1 + } else if Some(id) == self.profiles_req { + 2 } else { return false; }; @@ -1297,6 +1272,10 @@ impl AssetStore { self.detail_req = None; self.detail = Remote::Ready(detail); } + (2, ClientOutput::JobProfiles(profiles)) => { + self.profiles_req = None; + self.profiles = Remote::Ready(profiles); + } // A mismatched output shape for a tracked id is a // protocol-level surprise — surface it, don't guess. (0, other) => { @@ -1305,10 +1284,14 @@ impl AssetStore { self.next_cursor = None; self.search = Remote::Failed(format!("unexpected output {other:?}")); } - (_, other) => { + (1, other) => { self.detail_req = None; self.detail = Remote::Failed(format!("unexpected output {other:?}")); } + (_, other) => { + self.profiles_req = None; + self.profiles = Remote::Failed(format!("unexpected output {other:?}")); + } } true } @@ -1323,10 +1306,14 @@ impl AssetStore { self.next_cursor = None; self.search = Remote::Failed(error.to_string()); } - _ => { + 1 => { self.detail_req = None; self.detail = Remote::Failed(error.to_string()); } + _ => { + self.profiles_req = None; + self.profiles = Remote::Failed(error.to_string()); + } } true } @@ -1389,11 +1376,6 @@ impl AssetStore { self.changed_assets.push(asset_id); } } - if let Some(pipeline) = event.pipeline { - if !self.finished_pipelines.contains(&pipeline) { - self.finished_pipelines.push(pipeline); - } - } self.events.push_front(event); } self.events.truncate(EVENT_LOG_CAP); @@ -1469,7 +1451,7 @@ fn now_ms() -> u64 { .unwrap_or(0) } -pub(crate) fn checkout_root() -> PathBuf { +fn checkout_root() -> PathBuf { if let Ok(root) = std::env::var("MAKEPAD_ROOT") { return PathBuf::from(root); } @@ -1673,6 +1655,76 @@ fn start_embedded_asset_server_at( Ok((server, token)) } +/// Stop flag for the single in-process job coordinator (see PUBLISH_STOP). +static JOBS_STOP: AtomicBool = AtomicBool::new(false); + +/// Owns the job-coordinator thread; dropping the store stops and joins it. +struct JobLoop { + join: Option>, +} + +impl Drop for JobLoop { + fn drop(&mut self) { + JOBS_STOP.store(true, Ordering::Release); + if let Some(join) = self.join.take() { + let _ = join.join(); + } + } +} + +/// Claim + dispatch generation jobs from the hosted server to the fleet the +/// LAN announces (the same boxes the asset-ui's own pipelines use). +/// +/// This runs the SHARED generation service, so the embedded server gets the +/// same behaviour as a standalone worker: one claim loop per fleet box (N +/// queued jobs of a kind drain across the N boxes that serve it), every +/// wired kind rather than video alone, and a live advertisement on +/// `GET /v1/job-profiles` of what those boxes can actually execute — which +/// is what stops a client enqueueing a tier whose weights are on no box. +fn start_job_loop( + server: &makepad_asset_store::AssetServer, + token: &str, +) -> Option { + let endpoints = localized_endpoints(server); + let server_id = server.server_id(); + let token = token.to_string(); + let cache = asset_ui_home().join("jobs-cache"); + JOBS_STOP.store(false, Ordering::Release); + let join = std::thread::Builder::new() + .name("asset-ui-jobs".to_string()) + .spawn(move || { + use makepad_asset_importer::gen_service::{FleetSource, GenServiceConfig}; + log!( + "job loop: coordinating jobs on {}/{} → LAN fleet", + endpoints.control, + endpoints.data + ); + makepad_asset_importer::gen_service::run( + &GenServiceConfig { + servers: vec![endpoints], + server_id: Some(server_id), + token, + cache_root: cache, + namespace: "gen".to_string(), + suffix: "asset-ui".to_string(), + rights: makepad_asset_client::PublishRights::generated_cc0(), + fleet: FleetSource::Lan, + announce: true, + log: true, + }, + &JOBS_STOP, + ); + log!("job loop: stopped"); + }); + match join { + Ok(join) => Some(JobLoop { join: Some(join) }), + Err(error) => { + log!("job loop: could not spawn: {error}"); + None + } + } +} + /// Stop flag for the single in-process publish loop. A `static` (not an /// `Arc`) because `watch::run` borrows it for the thread's whole life and /// there is at most one loop per process. @@ -2038,9 +2090,6 @@ mod tests { game_id: None, game_revision: None, alias: Some(format!("game/asset-{seq}")), - model_preview: None, - pipeline: None, - pipeline_state: None, content_kind: None, ts_ms: seq, }; @@ -2156,9 +2205,6 @@ mod tests { game_id: None, game_revision: None, alias: None, - model_preview: None, - pipeline: None, - pipeline_state: None, content_kind: None, ts_ms: 1, }]); @@ -2182,9 +2228,6 @@ mod tests { game_id: None, game_revision: None, alias: None, - model_preview: None, - pipeline: None, - pipeline_state: None, content_kind: None, ts_ms: 2, }]); diff --git a/apps/asset-ui/src/audio.rs b/apps/asset-ui/src/audio.rs index 717e0cb85..39f5ca7db 100644 --- a/apps/asset-ui/src/audio.rs +++ b/apps/asset-ui/src/audio.rs @@ -601,7 +601,7 @@ pub fn waveform_thumbnail_png(pcm: &WavPcm) -> Option> { WAVEFORM_THUMB_W, WAVEFORM_THUMB_H, ) { - return makepad_ai_hub::testpattern::encode_png_rgba( + return makepad_asset_ai::testpattern::encode_png_rgba( &rgba, WAVEFORM_THUMB_W, WAVEFORM_THUMB_H, @@ -618,7 +618,7 @@ pub fn waveform_thumbnail_png(pcm: &WavPcm) -> Option> { (pixel >> 24) as u8, ]); } - makepad_ai_hub::testpattern::encode_png_rgba(&rgba, WAVEFORM_THUMB_W, WAVEFORM_THUMB_H) + makepad_asset_ai::testpattern::encode_png_rgba(&rgba, WAVEFORM_THUMB_W, WAVEFORM_THUMB_H) .ok() } @@ -675,7 +675,7 @@ mod tests { // Round-trip against the service's own encoder shape: a 100-sample // 24kHz mono ramp. let samples: Vec = (0..100).map(|i| i as f32 / 100.0 - 0.5).collect(); - let wav = makepad_ai_hub::wav::encode_wav_pcm16_mono(&samples, 24_000); + let wav = makepad_asset_ai::wav::encode_wav_pcm16_mono(&samples, 24_000); let pcm = parse_wav(&wav).unwrap(); assert_eq!(pcm.sample_rate, 24_000); assert_eq!(pcm.channels, 1); diff --git a/apps/asset-ui/src/chat.rs b/apps/asset-ui/src/chat.rs index dfc6365b3..8e8ce57a4 100644 --- a/apps/asset-ui/src/chat.rs +++ b/apps/asset-ui/src/chat.rs @@ -2,7 +2,7 @@ //! //! The mechanics — the session, the worker thread on a channel, the //! transcript with its tool chips and rate meter, cancel and clear — are the -//! shared component in [`makepad_chat_ui`], the same one the game +//! shared component in [`makepad_asset_chat_ui`], the same one the game //! sandbox runs. This file is what makes it the ASSET UI's chat: //! //! - it opens the session as `("gen", "gen")`, so the broker assembles the @@ -22,11 +22,11 @@ use crate::pipeline::{ IMAGE_SIZES, IMAGE_STEPS, MESH_FACE_COUNTS, MESH_TEXTURE_SIZES, MUSIC_DEFAULT_SECONDS, MUSIC_LENGTHS, VIDEO_LENGTHS, VIDEO_SIZES, }; -use makepad_ai_hub::fleet::BoxSnapshot; +use makepad_asset_ai::fleet::BoxSnapshot; use makepad_asset_chat::tools::{ContentToolCall, GenerateThen}; use makepad_asset_chat::wire::ToolOutcome; -use makepad_chat_ui::feed::{default_call_title, default_outcome_summary, ellipsis}; -use makepad_chat_ui::{ChatFeed, ClientTools, FeedConfig}; +use makepad_asset_chat_ui::feed::{default_call_title, default_outcome_summary, ellipsis}; +use makepad_asset_chat_ui::{ChatFeed, ClientTools, FeedConfig}; use makepad_asset_client::dto::ChatToolOutcomeDto; use makepad_asset_client::json::{self, Value}; use makepad_asset_client::{ApiEndpoints, ChatAttachment}; @@ -36,10 +36,10 @@ use std::sync::{Arc, Mutex}; /// The transcript and its rate meter are the shared component's; this app /// only reads them. -pub use makepad_chat_ui::{ChatData, ChatRole}; +pub use makepad_asset_chat_ui::{ChatData, ChatRole}; // Test-only: the module tests below read the shared transcript directly. #[cfg(test)] -use makepad_chat_ui::CHAT; +use makepad_asset_chat_ui::CHAT; // --------------------------------------------------------------------------- // mutable generation defaults @@ -452,10 +452,6 @@ pub struct ChatJob { pub video_steps: u32, pub seconds: u32, pub voice: Option, - /// The sung words for a music job, as their own field — an empty one is - /// what makes a music model generate an instrumental. - pub lyrics: Option, - pub seed: Option, } // --------------------------------------------------------------------------- @@ -648,17 +644,6 @@ impl AppTools { } ChatJobKind::Music => { pairs.push(("seconds", Value::Int(job.seconds as i64))); - // The lyric script is its own field all the way down: the - // caption never carries the sung words. - if let Some(text) = &job.lyrics { - pairs.push(("lyrics", json::s(text.clone()))); - } - if let Some(steps) = job.steps { - pairs.push(("steps", Value::Int(steps as i64))); - } - if let Some(seed) = job.seed { - pairs.push(("seed", Value::Int(seed as i64))); - } } ChatJobKind::Speech => { if let Some(v) = &job.voice { @@ -700,8 +685,6 @@ impl AppTools { video_steps: VIDEO_LENGTHS[0].1, seconds: MUSIC_DEFAULT_SECONDS, voice: None, - lyrics: None, - seed: None, }) } @@ -759,8 +742,6 @@ impl AppTools { video_steps: steps.unwrap_or(VIDEO_LENGTHS[0].1), seconds: MUSIC_DEFAULT_SECONDS, voice: None, - lyrics: None, - seed: None, }), ContentToolCall::AudioGenerate { prompt, model } => self.queue_job(ChatJob { prompt, @@ -774,8 +755,6 @@ impl AppTools { video_steps: 0, seconds: 0, voice: None, - lyrics: None, - seed: None, }), ContentToolCall::SpeechGenerate { prompt, model, voice } => self.queue_job(ChatJob { prompt, @@ -789,26 +768,20 @@ impl AppTools { video_steps: 0, seconds: 0, voice, - lyrics: None, - seed: None, }), - ContentToolCall::MusicGenerate { prompt, model, seconds, lyrics, steps, seed } => { - self.queue_job(ChatJob { - prompt, - kind: ChatJobKind::Music, - then: GenerateThen::None, - model, - width: 0, - height: 0, - steps, - frames: 0, - video_steps: 0, - seconds: seconds.unwrap_or(MUSIC_DEFAULT_SECONDS), - voice: None, - lyrics, - seed, - }) - } + ContentToolCall::MusicGenerate { prompt, model, seconds } => self.queue_job(ChatJob { + prompt, + kind: ChatJobKind::Music, + then: GenerateThen::None, + model, + width: 0, + height: 0, + steps: None, + frames: 0, + video_steps: 0, + seconds: seconds.unwrap_or(MUSIC_DEFAULT_SECONDS), + voice: None, + }), ContentToolCall::MeshGenerate { prompt, model, width, height, steps } => { match self.image_job( ChatJobKind::Mesh, @@ -934,12 +907,12 @@ impl ClientTools for AppTools { #[cfg(test)] mod tests { use super::*; - use makepad_ai_hub::protocol::{HealthJson, ModelInfoJson}; + use makepad_asset_ai::protocol::{HealthJson, ModelInfoJson}; fn snap(url: &str, domain: &str, id: &str, state: &str) -> BoxSnapshot { BoxSnapshot { base_url: url.into(), - health: Some(HealthJson { realtime: None, + health: Some(HealthJson { service: "makepad-asset-ai".into(), version: "t".into(), gpu: Some("RTX".into()), diff --git a/apps/asset-ui/src/enhance_meta.rs b/apps/asset-ui/src/enhance_meta.rs index a53188bd0..02a50cb0c 100644 --- a/apps/asset-ui/src/enhance_meta.rs +++ b/apps/asset-ui/src/enhance_meta.rs @@ -1,15 +1,34 @@ -//! Post-process pass: rewrite path-like library labels into catalog titles -//! (Doom lump → Imp, Quake MDL → Shambler). No model, no invented lore. +//! Post-process pass: walk the local library and fill missing metadata. //! -//! This file also held a stub "vision caption" layer that wrote -//! `.vision.json` sidecars saying `skipped`: nothing ever called it -//! and nothing ever read one. The real vision pass is -//! [`crate::annotate_queue`], which writes descriptions into the STORE, -//! where search can index them — a sidecar beside a local GLB was never -//! reachable by a catalog query. +//! Two layers, both fail-closed: +//! 1. Catalog names (Doom lump → Imp, Quake MDL → Shambler). No model. +//! 2. Optional vision caption into `.vision.json` when a Qwen-VL +//! (or any fleet model with `vl` in the id) is actually ready. Until +//! that box is provisioned the pass records `skipped` and does not +//! invent descriptions. use crate::library::{Library, LibraryMeta}; use makepad_asset_importer::stateful_billboard::{mesh_title, sprite_title, world_title}; +use std::path::{Path, PathBuf}; + +pub const VISION_SIDECAR: &str = "vision.json"; + +#[derive(Clone, Debug, Default)] +pub struct EnhanceStats { + pub scanned: usize, + pub named: usize, + pub vision_wrote: usize, + pub vision_skipped: usize, + pub message: String, +} + +pub fn sidecar_path(library_dir: &Path, file: &str) -> PathBuf { + library_dir.join(format!("{file}.{VISION_SIDECAR}")) +} + +pub fn has_vision(library_dir: &Path, file: &str) -> bool { + sidecar_path(library_dir, file).is_file() +} /// Rewrite path-like labels to catalog titles. Does not invent lore. pub fn apply_catalog_names(library: &mut Library) -> usize { @@ -108,6 +127,28 @@ fn label_asset_stem(label: &str) -> Option<&str> { } } +/// Mark assets that have no vision blob. Does not call a model unless +/// `vision_ready` is true — then the caller supplies captions. +pub fn stamp_skipped_vision(library_dir: &Path, items: &[LibraryMeta], reason: &str) -> usize { + let mut n = 0usize; + for item in items { + let path = sidecar_path(library_dir, &item.file); + if path.is_file() { + continue; + } + let body = format!( + "{{\"status\":\"skipped\",\"reason\":\"{}\",\"label\":\"{}\",\"domain\":\"{}\"}}\n", + reason.replace('"', "'"), + item.label.replace('"', "'"), + item.domain + ); + if std::fs::write(&path, body).is_ok() { + n += 1; + } + } + n +} + #[cfg(test)] mod tests { use super::*; @@ -165,3 +206,10 @@ mod tests { assert_eq!(better_label(&duke).as_deref(), Some("TILE-0123")); } } + +pub fn fleet_has_vision(model_ids: &[String]) -> bool { + model_ids.iter().any(|id| { + let l = id.to_ascii_lowercase(); + l.contains("vl") || l.contains("vision") || l.contains("qwen2.5-vl") || l.contains("qwen3-vl") + }) +} diff --git a/apps/asset-ui/src/fast_presets.rs b/apps/asset-ui/src/fast_presets.rs index b4c4111ea..4ddcd0980 100644 --- a/apps/asset-ui/src/fast_presets.rs +++ b/apps/asset-ui/src/fast_presets.rs @@ -202,8 +202,6 @@ pub fn apply_gen(saved: &SavedFastPreset) -> GenParams { enhance_upscale: saved.enhance_upscale.unwrap_or(2), enhance_interpolate: saved.enhance_interpolate.unwrap_or(2), enhance_flow: saved.enhance_flow.unwrap_or(true), - // Loop-ness derives from the preset row at dispatch, never a spec. - video_loop: false, } } diff --git a/apps/asset-ui/src/fleet_poll.rs b/apps/asset-ui/src/fleet_poll.rs index 7ac893a17..9b93b665c 100644 --- a/apps/asset-ui/src/fleet_poll.rs +++ b/apps/asset-ui/src/fleet_poll.rs @@ -1,6 +1,6 @@ //! Fleet discovery over `cx.http_request`: polls `GET /health` + //! `GET /models` on every fleet endpoint and feeds the parsed JSON into -//! [`makepad_ai_hub::fleet::BoxSnapshot`]s — the scheduler +//! [`makepad_asset_ai::fleet::BoxSnapshot`]s — the scheduler //! (`fleet::pick_box` / `pick_for_domain`) is pure over those snapshots. //! //! Endpoint lifecycle (the part that keeps the fleet free of duplicates): @@ -25,9 +25,9 @@ //! keyed by url, not row index — rows may be coalesced away mid-flight and //! the late response is then dropped instead of updating the wrong box. -use makepad_ai_hub::discovery::DiscoveredNode; -use makepad_ai_hub::fleet::BoxSnapshot; -use makepad_ai_hub::protocol::{HealthJson, JobStatusJson, JobsJson, LorasJson, ModelsJson}; +use makepad_asset_ai::discovery::DiscoveredNode; +use makepad_asset_ai::fleet::BoxSnapshot; +use makepad_asset_ai::protocol::{HealthJson, JobStatusJson, JobsJson, LorasJson, ModelsJson}; use makepad_micro_serde::DeJson; use makepad_widgets::*; use std::collections::HashMap; @@ -58,47 +58,9 @@ pub struct FleetPoll { pub loras: Vec>, /// Endpoints with any request in flight (index-parallel to snapshots). busy: Vec, - /// Consecutive failed `/health` probes per endpoint. A box is only - /// declared offline after [`OFFLINE_AFTER`] of them: a node denoising a - /// whole GPU or streaming 17 GB of weights answers late, and late is not - /// the same as gone. The panel used to flip such a box to "down" on the - /// first slow answer and back on the next, once a second. - health_fails: Vec, - /// A failing streak has already been reported for this endpoint, per - /// probe kind (health, models); cleared by that probe's next success. - /// Without it a busy node writes one log line per poll — the log filled - /// with thousands of them while every box in fact answered 200 to curl. - /// The two are separate because a box that answers `/health` and drops - /// `/models` would otherwise clear the latch every round and complain - /// every round. - health_quiet: Vec, - models_quiet: Vec, in_flight: HashMap, } -/// Consecutive `/health` failures before a box is shown as offline. -/// Three misses at the poll cadence is several seconds of real silence, well -/// past any answer a loaded box is merely slow to give. -const OFFLINE_AFTER: u32 = 3; - -/// What a failed `/health` probe means for a row. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ProbeVerdict { - /// Keep showing the box exactly as it was; `remaining` more consecutive - /// misses would declare it offline. - Hold { remaining: u32 }, - /// Enough silence: the box is gone, not merely busy. - Offline, -} - -fn verdict_after_misses(misses: u32) -> ProbeVerdict { - if misses >= OFFLINE_AFTER { - ProbeVerdict::Offline - } else { - ProbeVerdict::Hold { remaining: OFFLINE_AFTER - misses } - } -} - impl FleetPoll { pub fn new() -> Self { Self { @@ -107,9 +69,6 @@ impl FleetPoll { jobs: Vec::new(), loras: Vec::new(), busy: Vec::new(), - health_fails: Vec::new(), - health_quiet: Vec::new(), - models_quiet: Vec::new(), in_flight: HashMap::new(), } } @@ -120,25 +79,6 @@ impl FleetPoll { self.jobs.remove(index); self.loras.remove(index); self.busy.remove(index); - self.health_fails.remove(index); - self.health_quiet.remove(index); - self.models_quiet.remove(index); - } - - /// One `/health` answer landed: the row is live and its miss streak - /// (and the log-once latch) resets. Returns the streak it ended. - fn note_health_ok(&mut self, index: usize) -> u32 { - let missed = self.health_fails[index]; - self.health_fails[index] = 0; - self.health_quiet[index] = false; - missed - } - - /// One `/health` probe missed. The row is NOT touched until the verdict - /// says offline — a box mid-denoise or mid-download is still there. - fn note_health_miss(&mut self, index: usize) -> ProbeVerdict { - self.health_fails[index] = self.health_fails[index].saturating_add(1); - verdict_after_misses(self.health_fails[index]) } fn row_by_url(&self, url: &str) -> Option { @@ -183,9 +123,6 @@ impl FleetPoll { self.jobs.push(Vec::new()); self.loras.push(Vec::new()); self.busy.push(false); - self.health_fails.push(0); - self.health_quiet.push(false); - self.models_quiet.push(false); changed = true; } } @@ -269,14 +206,12 @@ impl FleetPoll { /// (caller redraws the fleet panel). A response for a row that was /// coalesced or lease-expired while the request flew is dropped. pub fn handle_response(&mut self, cx: &mut Cx, item: &NetworkResponse) -> bool { - let (request_id, response, transport_error) = match item { + let (request_id, response) = match item { NetworkResponse::HttpResponse { request_id, response, - } => (*request_id, Some(response), None), - NetworkResponse::HttpError { request_id, error } => { - (*request_id, None, Some(error.message.clone())) - } + } => (*request_id, Some(response)), + NetworkResponse::HttpError { request_id, .. } => (*request_id, None), _ => return false, }; let Some(in_flight) = self.in_flight.remove(&request_id) else { @@ -287,22 +222,15 @@ impl FleetPoll { }; match in_flight.pending { Pending::Health => { - let status = response.map(|r| r.status_code); let health = response .filter(|r| r.status_code == 200) .and_then(|r| r.get_string_body()) .and_then(|body| HealthJson::deserialize_json_lenient(&body).ok()); let up = health.is_some(); + self.latency_ms[index] = + up.then(|| in_flight.sent_at.elapsed().as_millis() as u64); + self.snapshots[index].health = health; if up { - let missed = self.note_health_ok(index); - if missed > 0 { - log!( - "fleet: {} answered again after {missed} missed probe(s)", - in_flight.base_url - ); - } - self.latency_ms[index] = Some(in_flight.sent_at.elapsed().as_millis() as u64); - self.snapshots[index].health = health; // A fresh health may prove this row aliases another // (same node_key/node_id) — collapse before fetching // models so the duplicate never renders. @@ -312,114 +240,49 @@ impl FleetPoll { let url = format!("{base_url}/models"); self.get(cx, base_url, url, Pending::Models); } - return true; - } - // SLOW IS NOT GONE. A missed probe costs the row its - // latency reading and nothing else until the misses pile up. - let verdict = self.note_health_miss(index); - self.latency_ms[index] = None; - self.busy[index] = false; - let reason = transport_error.unwrap_or_else(|| match status { - Some(code) => format!("HTTP {code}"), - None => "unreadable /health".to_string(), - }); - let was_up = self.snapshots[index].is_up(); - match verdict { - ProbeVerdict::Offline => { - if was_up { - // ONE line, naming the reason, at the moment the - // box actually goes offline. - log!( - "fleet: {} offline after {OFFLINE_AFTER} failed /health probes: \ - {reason}", - in_flight.base_url - ); - } - self.snapshots[index].health = None; - self.snapshots[index].models.clear(); - was_up - } - ProbeVerdict::Hold { remaining } => { - if !self.health_quiet[index] { - self.health_quiet[index] = true; - log!( - "fleet: {} missed a /health probe ({reason}) — holding it up \ - for {remaining} more", - in_flight.base_url - ); - } - // The row keeps its last known health and models: a - // busy box is still the same box. - false - } + } else { + self.snapshots[index].models.clear(); + self.busy[index] = false; } + true } Pending::Models => { self.busy[index] = false; - // One line per failing STREAK. A saturated box drops a - // model probe now and then; that is worth knowing once, not - // thirty times a minute. - let complain = |quiet: &mut bool, message: String| { - if !*quiet { - *quiet = true; - log!("{}", message); - } - }; - let quiet = &mut self.models_quiet[index]; let models = match response { Some(response) if response.status_code == 200 => { match response.get_string_body() { Some(body) => match ModelsJson::deserialize_json_lenient(&body) { Ok(models) => Some(models), Err(error) => { - complain( - quiet, - format!( - "fleet: {} /models JSON rejected ({} bytes): {:?}", - in_flight.base_url, - body.len(), - error - ), + log!( + "fleet: {} /models JSON rejected ({} bytes): {:?}", + in_flight.base_url, + body.len(), + error ); None } }, None => { - complain( - quiet, - format!( - "fleet: {} /models returned no text body", - in_flight.base_url - ), - ); + log!("fleet: {} /models returned no text body", in_flight.base_url); None } } } Some(response) => { - complain( - quiet, - format!( - "fleet: {} /models returned HTTP {}", - in_flight.base_url, response.status_code - ), + log!( + "fleet: {} /models returned HTTP {}", + in_flight.base_url, + response.status_code ); None } None => { - complain( - quiet, - format!( - "fleet: {} /models request failed ({}) — keeping its last model list", - in_flight.base_url, - transport_error.as_deref().unwrap_or("no response") - ), - ); + log!("fleet: {} /models request failed", in_flight.base_url); None } }; if let Some(models) = models { - *quiet = false; self.snapshots[index].models = models.models; } // Live job list last (running + queued, other clients too). @@ -598,70 +461,10 @@ mod tests { DiscoveredNode { base_url: base_url.to_string(), node_id, - fleet: makepad_ai_hub::discovery::DEFAULT_FLEET.to_string(), + fleet: makepad_asset_ai::discovery::DEFAULT_FLEET.to_string(), } } - /// SLOW IS NOT GONE. Under a full-GPU denoise or a 17 GB weight pull a - /// box answers late; the panel used to flip it to "down" on the first - /// miss and back on the next, once a second, while every box in fact - /// answered 200 to curl. - #[test] - fn a_busy_box_survives_missed_probes_and_only_then_goes_offline() { - let mut fleet = fleet(&["http://10.0.0.165:8123"]); - fleet.snapshots[0].health = Some(health(1000, "key-a")); - fleet.snapshots[0].models = ModelsJson::deserialize_json_lenient( - r#"{"models":[{"id":"minimax-h3","domain":"video","backend":"h3","available":true,"gated":false,"state":"ready"}]}"#, - ) - .expect("test models json parses") - .models; - - // Two misses: still up, still holding its model list. - assert_eq!(fleet.note_health_miss(0), ProbeVerdict::Hold { remaining: 2 }); - assert!(fleet.snapshots[0].is_up()); - assert_eq!(fleet.note_health_miss(0), ProbeVerdict::Hold { remaining: 1 }); - assert!(fleet.snapshots[0].is_up()); - assert_eq!(fleet.snapshots[0].models.len(), 1); - - // One answer and the streak is gone — no flicker, no lost state. - assert_eq!(fleet.note_health_ok(0), 2); - assert_eq!(fleet.note_health_miss(0), ProbeVerdict::Hold { remaining: 2 }); - - // Sustained silence IS offline. - fleet.note_health_miss(0); - assert_eq!(fleet.note_health_miss(0), ProbeVerdict::Offline); - } - - #[test] - fn the_offline_verdict_needs_a_full_streak() { - assert_eq!(verdict_after_misses(0), ProbeVerdict::Hold { remaining: 3 }); - assert_eq!(verdict_after_misses(1), ProbeVerdict::Hold { remaining: 2 }); - assert_eq!(verdict_after_misses(OFFLINE_AFTER - 1), ProbeVerdict::Hold { remaining: 1 }); - assert_eq!(verdict_after_misses(OFFLINE_AFTER), ProbeVerdict::Offline); - assert_eq!(verdict_after_misses(99), ProbeVerdict::Offline); - } - - /// The log-once latch: a failing streak says so once, not once per poll. - /// The old behaviour wrote 16-32 identical lines per box per minute. - #[test] - fn a_failing_streak_is_reported_once_not_once_per_poll() { - let mut fleet = fleet(&["http://10.0.0.165:8123"]); - fleet.snapshots[0].health = Some(health(1000, "key-a")); - assert!(!fleet.health_quiet[0], "a healthy row has nothing latched"); - // The handler latches on the first complaint of a streak. - fleet.health_quiet[0] = true; - fleet.note_health_miss(0); - assert!(fleet.health_quiet[0], "still latched: no second line"); - // A success unlatches it, so the NEXT streak is reported again. - fleet.note_health_ok(0); - assert!(!fleet.health_quiet[0]); - // A box that answers /health and drops /models keeps its OWN latch, - // so it does not complain once per poll forever. - fleet.models_quiet[0] = true; - fleet.note_health_ok(0); - assert!(fleet.models_quiet[0]); - } - #[test] fn same_node_id_via_two_addresses_stays_one_row() { // First beacon answered health with node_id 11; the same service diff --git a/apps/asset-ui/src/import.rs b/apps/asset-ui/src/import.rs index eccbb5645..976646ac9 100644 --- a/apps/asset-ui/src/import.rs +++ b/apps/asset-ui/src/import.rs @@ -7,7 +7,6 @@ //! not-provisioned cards until they get the same local-folder path. use makepad_asset_importer::ao_bake; -use makepad_asset_importer::classic_import::ClassicSource; use makepad_asset_importer::pack_import::{ self, kenney_pack, kenney_spec, KenneyPack, IMPORT_MANIFEST_FILE, KENNEY_ASSETS_HOME, KENNEY_CREDITS, KENNEY_GITHUB, KENNEY_HOME, KENNEY_LICENSE, KENNEY_PACKS, KENNEY_SOURCE_ID, @@ -55,10 +54,6 @@ pub enum ImportJob { Duke3d { path: String, }, - EaClassic { - source: ClassicSource, - path: String, - }, Quake2 { path: String, }, @@ -87,7 +82,6 @@ impl ImportJob { ImportJob::Doom { .. } => "Doom shareware".into(), ImportJob::Quake { .. } => "Quake shareware".into(), ImportJob::Duke3d { .. } => "Duke3D shareware".into(), - ImportJob::EaClassic { source, .. } => source.title().into(), ImportJob::Quake2 { .. } => "Quake II shareware".into(), ImportJob::Quake3 { .. } => "Quake III demo".into(), ImportJob::DarkMod { .. } => "The Dark Mod".into(), @@ -106,10 +100,6 @@ impl ImportJob { pub fn conflicts(&self, other: &ImportJob) -> bool { match (self, other) { (ImportJob::Kenney { pack: a, .. }, ImportJob::Kenney { pack: b, .. }) => a == b, - ( - ImportJob::EaClassic { source: a, .. }, - ImportJob::EaClassic { source: b, .. }, - ) => a == b, (ImportJob::KenneyAll, ImportJob::KenneyAll) | (ImportJob::KenneyAll, ImportJob::Kenney { .. }) | (ImportJob::Kenney { .. }, ImportJob::KenneyAll) @@ -4140,8 +4130,6 @@ fn kind_tag(kind: AssetKind) -> &'static str { AssetKind::Billboard => "billboard", AssetKind::Game => "game", AssetKind::VjEffect => "vjeffect", - AssetKind::Data => "data", - AssetKind::ModelProgram => "model-program", } } diff --git a/apps/asset-ui/src/import_classic.rs b/apps/asset-ui/src/import_classic.rs index 8c37a107c..fdf15db34 100644 --- a/apps/asset-ui/src/import_classic.rs +++ b/apps/asset-ui/src/import_classic.rs @@ -1,4 +1,4 @@ -//! Additive Import cards for libre, freeware, and official shareware packs. +//! Additive Import cards for Freedoom, LibreQuake, and official shareware. //! //! Downloads go through platform `cx.http_request`. Bytes are unpacked by //! [`makepad_asset_importer::classic_fetch`], then converted via @@ -107,87 +107,6 @@ pub const DUKE3D_MODULE: PackModule = PackModule { import_wired: true, }; -const EA_CNC_HOME: &str = "https://www.ea.com/games/command-and-conquer"; -const EA_CLASSIC_LICENSE: &str = "EA freeware — local use, not redistributable"; -const EA_CLASSIC_LICENSE_BLURB: &str = "© Westwood Studios / Electronic Arts. EA freeware for local preview in this app only. Not a redistributable grant."; -const EA_CLASSIC_CREDITS: &str = "Westwood Studios / Electronic Arts"; - -pub const EA_MODULES: [PackModule; 4] = [ - PackModule { - id: "cnc", - title: "Tiberian Dawn", - blurb: "EA's 1995 RTS, freeware since 2007 — terrain, units, structures, sounds and every campaign and multiplayer map convert into RTS maps for the sandbox.", - license: EA_CLASSIC_LICENSE, - license_blurb: EA_CLASSIC_LICENSE_BLURB, - homepage: EA_CNC_HOME, - terms_url: EA_CNC_HOME, - source_page: EA_CNC_HOME, - github: None, - credits: EA_CLASSIC_CREDITS, - import_wired: true, - }, - PackModule { - id: "ra", - title: "Red Alert", - blurb: "The 1996 sequel, freeware since 2008 — Allied and Soviet arsenals, snow/temperate/interior maps.", - license: EA_CLASSIC_LICENSE, - license_blurb: EA_CLASSIC_LICENSE_BLURB, - homepage: EA_CNC_HOME, - terms_url: EA_CNC_HOME, - source_page: EA_CNC_HOME, - github: None, - credits: EA_CLASSIC_CREDITS, - import_wired: true, - }, - PackModule { - id: "ts", - title: "Tiberian Sun", - blurb: "The 1999 isometric sequel, freeware since 2010 — GDI/Nod units and tilesets; maps are generated from the tilesets.", - license: EA_CLASSIC_LICENSE, - license_blurb: EA_CLASSIC_LICENSE_BLURB, - homepage: EA_CNC_HOME, - terms_url: EA_CNC_HOME, - source_page: EA_CNC_HOME, - github: None, - credits: EA_CLASSIC_CREDITS, - import_wired: true, - }, - PackModule { - id: "d2k", - title: "Dune 2000", - blurb: "Westwood's 1998 Dune RTS — Atreides/Harkonnen/Ordos units and the Arrakis tilesets; maps are generated.", - license: EA_CLASSIC_LICENSE, - license_blurb: EA_CLASSIC_LICENSE_BLURB, - homepage: EA_CNC_HOME, - terms_url: EA_CNC_HOME, - source_page: EA_CNC_HOME, - github: None, - credits: EA_CLASSIC_CREDITS, - import_wired: true, - }, -]; - -pub const EA_SOURCES: [ClassicSource; 4] = [ - ClassicSource::Cnc, - ClassicSource::RedAlert, - ClassicSource::TiberianSun, - ClassicSource::Dune2000, -]; - -pub const EA_PACK_LABELS: [&str; 4] = ["Tiberian Dawn", "Red Alert", "Tiberian Sun", "Dune 2000"]; - -pub fn ea_source_for_index(index: usize) -> ClassicSource { - EA_SOURCES.get(index).copied().unwrap_or(ClassicSource::Cnc) -} - -pub fn ea_index_for_source(source: ClassicSource) -> Option { - EA_SOURCES.iter().position(|candidate| *candidate == source) -} - -fn is_ea_source(source: ClassicSource) -> bool { - ea_index_for_source(source).is_some() -} - pub const QUAKE2_MODULE: PackModule = PackModule { id: QUAKE2_SOURCE_ID, title: "Quake II shareware", @@ -238,10 +157,6 @@ pub const PACK_MODULES_WITH_CLASSIC: &[PackModule] = &[ LIBREQUAKE_MODULE, QUAKE_MODULE, DUKE3D_MODULE, - EA_MODULES[0], - EA_MODULES[1], - EA_MODULES[2], - EA_MODULES[3], QUAKE2_MODULE, QUAKE3_MODULE, DARKMOD_MODULE, @@ -1898,10 +1813,8 @@ fn classic_library_landings( let mut seen_icons = std::collections::BTreeSet::new(); let mut seen_titles = std::collections::BTreeSet::new(); for asset in assets { - let ea_source = is_ea_source(source); if matches!(asset.kind, AssetKind::Texture) && !matches!(source, classic_import::ClassicSource::Quake3) - && !(ea_source && asset.key.starts_with("icons/")) { continue; } @@ -1926,30 +1839,11 @@ fn classic_library_landings( AssetKind::Character => (path, "model/gltf-binary", "character"), AssetKind::Weapon => (path, "model/gltf-binary", "weapon"), AssetKind::Prop => (path, "model/gltf-binary", "prop"), - AssetKind::Texture => ( - path, - "image/png", - if ea_source && asset.key.starts_with("icons/") { - "texture" - } else { - "image" - }, - ), + AssetKind::Texture => (path, "image/png", "image"), AssetKind::Audio => { let music = asset.key.starts_with("music/") || asset.tags.iter().any(|t| t.eq_ignore_ascii_case("music")); - let speech = asset.tags.iter().any(|t| t.eq_ignore_ascii_case("speech")); - ( - path, - "audio/wav", - if speech { - "speech" - } else if music { - "music" - } else { - "sfx" - }, - ) + (path, "audio/wav", if music { "music" } else { "sfx" }) } AssetKind::Billboard => { let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); @@ -2014,17 +1908,12 @@ fn classic_library_landings( { continue; } - let dedupe_key = if ea_source { - asset.key.clone() - } else { - title.clone() - }; if !matches!(source, classic_import::ClassicSource::Quake3) && matches!( asset.kind, AssetKind::Billboard | AssetKind::Audio | AssetKind::Texture ) - && !seen_titles.insert(dedupe_key) + && !seen_titles.insert(title.clone()) { continue; } @@ -2302,10 +2191,6 @@ pub struct ClassicImportPage { pub librequake: ClassicImportCard, pub quake: ClassicImportCard, pub duke3d: ClassicImportCard, - pub cnc: ClassicImportCard, - pub ra: ClassicImportCard, - pub ts: ClassicImportCard, - pub d2k: ClassicImportCard, pub quake2: ClassicImportCard, pub quake3: ClassicImportCard, pub darkmod: ClassicImportCard, @@ -2319,10 +2204,6 @@ impl Default for ClassicImportPage { librequake: ClassicImportCard::new(ClassicSource::LibreQuake), quake: ClassicImportCard::new(ClassicSource::Quake), duke3d: ClassicImportCard::new(ClassicSource::Duke3d), - cnc: ClassicImportCard::new(ClassicSource::Cnc), - ra: ClassicImportCard::new(ClassicSource::RedAlert), - ts: ClassicImportCard::new(ClassicSource::TiberianSun), - d2k: ClassicImportCard::new(ClassicSource::Dune2000), quake2: ClassicImportCard::new(ClassicSource::Quake2), quake3: ClassicImportCard::new(ClassicSource::Quake3), darkmod: ClassicImportCard::new(ClassicSource::DarkMod), @@ -2338,10 +2219,6 @@ impl ClassicImportPage { ClassicSource::LibreQuake => &self.librequake, ClassicSource::Quake => &self.quake, ClassicSource::Duke3d => &self.duke3d, - ClassicSource::Cnc => &self.cnc, - ClassicSource::RedAlert => &self.ra, - ClassicSource::TiberianSun => &self.ts, - ClassicSource::Dune2000 => &self.d2k, ClassicSource::Quake2 => &self.quake2, ClassicSource::Quake3 => &self.quake3, ClassicSource::DarkMod => &self.darkmod, @@ -2355,10 +2232,6 @@ impl ClassicImportPage { ClassicSource::LibreQuake => &mut self.librequake, ClassicSource::Quake => &mut self.quake, ClassicSource::Duke3d => &mut self.duke3d, - ClassicSource::Cnc => &mut self.cnc, - ClassicSource::RedAlert => &mut self.ra, - ClassicSource::TiberianSun => &mut self.ts, - ClassicSource::Dune2000 => &mut self.d2k, ClassicSource::Quake2 => &mut self.quake2, ClassicSource::Quake3 => &mut self.quake3, ClassicSource::DarkMod => &mut self.darkmod, @@ -2371,10 +2244,6 @@ impl ClassicImportPage { || self.librequake.compiling() || self.quake.compiling() || self.duke3d.compiling() - || self.cnc.compiling() - || self.ra.compiling() - || self.ts.compiling() - || self.d2k.compiling() || self.quake2.compiling() || self.quake3.compiling() || self.darkmod.compiling() @@ -2424,10 +2293,6 @@ impl ClassicImportPage { &mut self.librequake, &mut self.quake, &mut self.duke3d, - &mut self.cnc, - &mut self.ra, - &mut self.ts, - &mut self.d2k, &mut self.quake2, &mut self.quake3, &mut self.darkmod, @@ -2441,14 +2306,10 @@ impl ClassicImportPage { let c = self.librequake.poll(); let d = self.quake.poll(); let e = self.duke3d.poll(); - let f = self.cnc.poll(); - let g = self.ra.poll(); - let h = self.ts.poll(); - let i = self.d2k.poll(); - let j = self.quake2.poll(); - let k = self.quake3.poll(); - let l = self.darkmod.poll(); - a || b || c || d || e || f || g || h || i || j || k || l + let f = self.quake2.poll(); + let g = self.quake3.poll(); + let h = self.darkmod.poll(); + a || b || c || d || e || f || g || h } pub fn take_all_landings(&mut self) -> Vec { @@ -2457,10 +2318,6 @@ impl ClassicImportPage { out.extend(self.librequake.take_library_landings()); out.extend(self.quake.take_library_landings()); out.extend(self.duke3d.take_library_landings()); - out.extend(self.cnc.take_library_landings()); - out.extend(self.ra.take_library_landings()); - out.extend(self.ts.take_library_landings()); - out.extend(self.d2k.take_library_landings()); out.extend(self.quake2.take_library_landings()); out.extend(self.quake3.take_library_landings()); out.extend(self.darkmod.take_library_landings()); @@ -2473,10 +2330,6 @@ impl ClassicImportPage { out.extend(self.librequake.take_previews()); out.extend(self.quake.take_previews()); out.extend(self.duke3d.take_previews()); - out.extend(self.cnc.take_previews()); - out.extend(self.ra.take_previews()); - out.extend(self.ts.take_previews()); - out.extend(self.d2k.take_previews()); out.extend(self.quake2.take_previews()); out.extend(self.quake3.take_previews()); out.extend(self.darkmod.take_previews()); @@ -2546,10 +2399,6 @@ mod tests { DOOM_MODULE, QUAKE_MODULE, DUKE3D_MODULE, - EA_MODULES[0], - EA_MODULES[1], - EA_MODULES[2], - EA_MODULES[3], QUAKE2_MODULE, QUAKE3_MODULE, ] { @@ -2567,62 +2416,6 @@ mod tests { } } - #[test] - fn ea_classics_modules_dropdown_and_library_domains_are_wired() { - let ids: Vec<_> = PACK_MODULES_WITH_CLASSIC.iter().map(|module| module.id).collect(); - for id in ["cnc", "ra", "ts", "d2k"] { - assert!(ids.contains(&id), "missing EA classic module {id}"); - } - for (index, source) in EA_SOURCES.into_iter().enumerate() { - assert_eq!(ea_source_for_index(index), source); - assert_eq!(ea_index_for_source(source), Some(index)); - } - - let staged = std::env::temp_dir().join(format!( - "asset-ui-ea-landings-{}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&staged); - std::fs::create_dir_all(staged.join("billboards/cnc")).unwrap(); - std::fs::create_dir_all(staged.join("worlds")).unwrap(); - std::fs::write(staged.join("billboards/cnc/mtnk.billboard"), b"manifest").unwrap(); - std::fs::write(staged.join("billboards/cnc/mtnk_thumb.png"), b"png").unwrap(); - std::fs::write(staged.join("worlds/scm01ea.glb"), b"glb").unwrap(); - std::fs::write(staged.join("worlds/scm01ea.png"), b"png").unwrap(); - let assets = [ - classic_import::ClassicAsset { - key: "billboards/cnc/mtnk".into(), - kind: AssetKind::Billboard, - rel_path: "billboards/cnc/mtnk.billboard".into(), - tags: vec!["unit".into()], - icon_rel: Some("billboards/cnc/mtnk_thumb.png".into()), - }, - classic_import::ClassicAsset { - key: "worlds/scm01ea".into(), - kind: AssetKind::World, - rel_path: "worlds/scm01ea.glb".into(), - tags: vec!["map".into()], - icon_rel: Some("worlds/scm01ea.png".into()), - }, - ]; - let landings = classic_library_landings(&staged, ClassicSource::Cnc, "cnc", &assets); - assert_eq!( - landings - .iter() - .find(|landing| landing.path.ends_with("mtnk.billboard")) - .map(|landing| landing.domain), - Some("billboard") - ); - assert_eq!( - landings - .iter() - .find(|landing| landing.path.ends_with("scm01ea.glb")) - .map(|landing| landing.domain), - Some("map") - ); - let _ = std::fs::remove_dir_all(staged); - } - #[test] fn darkmod_card_is_explicitly_nc_sa() { let text = format!( diff --git a/apps/asset-ui/src/library.rs b/apps/asset-ui/src/library.rs index 4178ff7d6..7f5a160cd 100644 --- a/apps/asset-ui/src/library.rs +++ b/apps/asset-ui/src/library.rs @@ -1219,15 +1219,7 @@ pub fn infer_import_tags( fn push_prompt_source_tag(tags: &mut Vec, prompt: &str) { let p = prompt.to_ascii_lowercase(); - let source = if p == "cnc" || p.starts_with("cnc ") || p.starts_with("cnc:") { - "cnc" - } else if p == "ra" || p.starts_with("ra ") || p.starts_with("ra:") { - "ra" - } else if p == "ts" || p.starts_with("ts ") || p.starts_with("ts:") { - "ts" - } else if p == "d2k" || p.starts_with("d2k ") || p.starts_with("d2k:") { - "d2k" - } else if p.starts_with("the dark mod") { + let source = if p.starts_with("the dark mod") { "darkmod" } else if p.starts_with("freedoom") { "freedoom" @@ -2417,7 +2409,7 @@ frame 1 A 2 64 64 trooper_a2.png let dir = TestDir::new("audio-provenance"); let mut library = Library::open(&dir.0); let samples: Vec = (0..64).map(|i| (i as f32 / 8.0).sin() * 0.5).collect(); - let wav_bytes = makepad_ai_hub::wav::encode_wav_pcm16_mono(&samples, 24_000); + let wav_bytes = makepad_asset_ai::wav::encode_wav_pcm16_mono(&samples, 24_000); // A poisoned caller thumbnail (e.g. the upstream pipeline image) is // DISCARDED; the sidecar is the payload's own waveform strip. diff --git a/apps/asset-ui/src/main.rs b/apps/asset-ui/src/main.rs index ea73ff321..b1c3ba573 100644 --- a/apps/asset-ui/src/main.rs +++ b/apps/asset-ui/src/main.rs @@ -5,7 +5,7 @@ //! //! - FLEET: GPU boxes announce themselves on the LAN UDP beacon; the app //! joins whatever is live. Capabilities come from /health + /models, jobs -//! are routed by the model-affinity scheduler in `makepad_ai_hub::fleet` +//! are routed by the model-affinity scheduler in `makepad_asset_ai::fleet` //! — observable (per-stage "affinity: loaded") and overridable (pin a //! model and/or a box from the dropdowns). //! - PIPELINE: one-click preset chains (prompt → expand → image → mesh, @@ -23,7 +23,7 @@ //! introspection, plus catalog ops when the Asset Server is up), //! LIBRARY (searchable Local/Server asset browser with kind/category/tag //! filters, thumbnail grid and a revision/provenance/publish detail rail), -//! IMPORT (hardcoded licensed pack modules — Kenney first), +//! IMPORT (hardcoded OSS pack modules — Kenney first, local-folder only), //! RUNS + WORKERS (local pipeline + LAN fleet, cancellable), //! and ADMIN + AUDIT. The left generator column //! stays on all of them. Server @@ -59,7 +59,6 @@ mod mesh_view; mod music_page; use crate::mask_paint::{MaskPaint, MaskPaintAction}; mod pipeline; -mod runs_chip; mod scheduler; mod store_views; mod thumbnail_renderer; @@ -71,10 +70,8 @@ use crate::artifact_io::{ ViewerOpenGate, }; use crate::fleet_poll::FleetPoll; -use crate::runs_chip::RunsChip; use crate::import::{ImportJob, ImportPage, ImportQueue}; use crate::import_classic::ClassicImportPage; -use makepad_asset_importer::classic_import::ClassicSource; use crate::music_page::MusicImportPage; use crate::library::{Library, ThumbnailBackfillJob}; use crate::billboard_view::BillboardView; @@ -91,7 +88,7 @@ use crate::fast_presets::{SavedFastPreset, MAX_FAST_PRESETS}; use makepad_asset_widgets::{VideoAction, VideoView}; use crate::pipeline::{ ENHANCE_FACTORS, - consumer_only_domain, format_music_duration, seed_replaces_prefix, stage_display_name, CandidateSetState, GenParams, + consumer_only_domain, format_clock, format_music_duration, seed_replaces_prefix, stage_display_name, CandidateSetState, GenParams, Pipeline, PipelineEvent, StageState, EDIT_STRENGTHS, LORA_STRENGTHS, VIDEO_INTERPOLATE, IMAGE_SIZES, IMAGE_STEPS, MESH_FACE_COUNTS, MESH_TEXTURE_SIZES, MUSIC_DEFAULT_SECONDS, MUSIC_LENGTHS, PRESETS, VIDEO_LENGTHS, VIDEO_SIZES, @@ -250,7 +247,7 @@ use makepad_micro_serde::SerJson; use makepad_widgets::*; use makepad_xr::obj::ViewSplat; use std::collections::{HashMap, HashSet, VecDeque}; -use makepad_ai_hub::fleet::BoxSnapshot; +use makepad_asset_ai::fleet::BoxSnapshot; use std::path::{Path, PathBuf}; app_main!(App); @@ -566,153 +563,6 @@ script_mod! { draw_bg +: { color: #xffffff0d } } - // ---- the card grammar --------------------------------------------------- - // ONE way a spawned task renders, everywhere: the Create surface, the - // RUNS panel behind the header chip, and (later) the VJ drawer. A card - // is a title row, ONE aggregate bar, one compact stage strip, and a - // fold. There is never a second progress representation of the same run - // on screen, there are never per-stage bars, and a section with nothing - // under it does not render. - - // One stage of a run. Tone rides the BACKGROUND (a uniform, so each - // chip keeps its own without a per-frame re-apply); the text stays one - // readable grey, because the chip's job is the stage's name and share, - // not a second colour code to learn. - let CardStageChip = RoundedView{ - visible: false - width: Fit height: Fit - padding: Inset{left: 6 right: 6 top: 2 bottom: 2} - draw_bg +: { - tone: uniform(#x8a939d) - border_radius: 2.5 - pixel: fn() { - let sdf = Sdf2d.viewport(self.pos * self.rect_size) - sdf.box(0.5, 0.5, self.rect_size.x - 1.0, self.rect_size.y - 1.0, self.border_radius) - sdf.fill_keep(vec4(self.tone.rgb, 0.13)) - sdf.stroke(vec4(self.tone.rgb, 0.42), 1.0) - return sdf.result - } - } - cs_label := Label{ - draw_text +: { - color: #xc6cfd8 - text_style: theme.font_regular{font_size: 7.5} - } - } - } - - // The outer View exists so the CARD has a name of its own: pressing it - // anywhere is what opens the fold, and a widget only emits finger - // actions when something asks for them by name. - let RunCardBody = View{ - width: Fill height: Fit - flow: Down - card_body := Card{ - width: Fill height: Fit - flow: Down spacing: 5 - padding: Inset{left: 10 right: 8 top: 7 bottom: 7} - // The whole card is the press target for the fold. - cursor: MouseCursor.Hand - // Row 1: what this is, in the person's own words, how long it has - // been going, and its stop. - View{ - width: Fill height: Fit flow: Right spacing: 7 - align: Align{y: 0.5} - // The state marker. A dot and not a glyph: the theme font has - // no play/pause/cancel characters (it has exactly one tick and - // one multiplication sign), and this app already says "state" - // with a coloured dot on every fleet box. - card_dot := SolidView{ - width: 8 height: 8 - draw_bg +: { - tone: uniform(#x3d9bf0) - pixel: fn() { - let sdf = Sdf2d.viewport(self.pos * self.rect_size) - sdf.circle(self.rect_size.x * 0.5, self.rect_size.y * 0.5, self.rect_size.x * 0.35) - sdf.fill(self.tone) - return sdf.result - } - } - } - card_label := Label{ - width: Fit - max_lines: 1 - draw_text +: { - color: #xdfe6ec - text_style: theme.font_bold{font_size: 8.5} - } - } - // The words the PERSON typed, quoted. Never the expanded prompt - // — that is fold material. - card_excerpt := Label{ - width: Fill - max_lines: 1 - text_overflow: TextOverflow.Ellipsis - draw_text +: { - color: #x8a939d - text_style: theme.font_regular{font_size: 8.5} - } - } - card_time := Label{ - draw_text +: { - color: #x6a7178 - text_style: theme.font_regular{font_size: 7.5} - } - } - card_up := GhostButton{ text: "↑" visible: false } - card_cancel := DangerButton{ text: "×" visible: false } - } - // Row 2: THE bar. The percent sits in a fixed box so every card's - // bar ends on the same pixel — the misalignment complaint was about - // right-floating bars that never lined up with their rows. - View{ - width: Fill height: Fit flow: Right spacing: 7 - align: Align{y: 0.5} - card_bar := ProgressBar{ width: Fill height: 5 } - card_pct := Label{ - width: 32 - draw_text +: { - color: #x99a2ac - text_style: theme.font_regular{font_size: 8} - } - } - } - // The humanized word, or — when it failed — WHY, readable without - // unfolding anything. - card_status := Label{ - width: Fill - max_lines: 2 - draw_text +: { - color: #x8a939d - text_style: theme.font_regular{font_size: 8} - } - } - // Row 3: one chip per stage. Only when there is more than one. - card_strip := View{ - visible: false - width: Fill height: Fit - flow: Flow.Right{wrap: true} - spacing: 4 - cs0 := CardStageChip{} - cs1 := CardStageChip{} - cs2 := CardStageChip{} - cs3 := CardStageChip{} - cs4 := CardStageChip{} - cs5 := CardStageChip{} - cs6 := CardStageChip{} - cs7 := CardStageChip{} - } - // Row 4: the fold. Everything diagnostic lives HERE and only here — - // the full sent prompt, params, model and box, job ids, attempts, - // declared-but-unsent bodies, the whole error text. - card_fold := MonoLabel{ - width: Fill - visible: false - } - card_copy := GhostButton{ text: "Copy" visible: false } - } - } - // One-click chains: a tiny group tag column + wrapping chips. let GroupTag = Label{ width: 40 @@ -1369,46 +1219,33 @@ script_mod! { } } } - // Every spawned unit of work, in the one grammar. - CardR := RunCardBody{} StageR := Card{ - flow: Down spacing: 4 + flow: Right spacing: 8 padding: Inset{left: 10 right: 6 top: 6 bottom: 6} - View{ - width: Fill height: Fit flow: Right spacing: 8 - align: Align{y: 0.5} - // The title is the open/close affordance: pressing the - // row is how you read what the stage was handed. - stage_title := ButtonFlatter{ - width: 190 - draw_text +: { - color: #xdfe6ec - text_style: theme.font_regular{font_size: 8.5} - } + align: Align{y: 0.5} + stage_title := Label{ + width: 190 + max_lines: 1 + text_overflow: TextOverflow.Ellipsis + draw_text +: { + color: #xdfe6ec + text_style: theme.font_regular{font_size: 8.5} } - // ONE line, always: a long status wrapping to two lines - // grew the row and made the whole Loading strip bounce - // while an import streamed status updates. - stage_meta := Label{ - width: Fill - max_lines: 1 - text_overflow: TextOverflow.Ellipsis - draw_text +: { - color: #x8a939d - text_style: theme.font_regular{font_size: 8} - } - } - stage_bar := ProgressBar{ width: 140 height: 4 } - stage_copy := GhostButton{ text: "Copy" visible: false } - stage_cancel := DangerButton{ text: "Stop" visible: false } } - // What the stage was GIVEN, in full, once the row is open: - // the exact prompt text that went to the model, wrapped and - // selectable, never truncated — the whole point. - stage_detail := MonoLabel{ + // ONE line, always: a long status wrapping to two lines + // grew the row and made the whole Loading strip bounce + // while an import streamed status updates. + stage_meta := Label{ width: Fill - visible: false + max_lines: 1 + text_overflow: TextOverflow.Ellipsis + draw_text +: { + color: #x8a939d + text_style: theme.font_regular{font_size: 8} + } } + stage_bar := ProgressBar{ width: 140 height: 4 } + stage_cancel := DangerButton{ text: "Stop" visible: false } } QueuedR := Card{ flow: Right spacing: 4 @@ -2179,25 +2016,57 @@ script_mod! { } - // Everything this app has spawned, in the ONE card - // grammar — the same card the RUNS panel draws. This - // replaced three stacked renderings of the same run - // (a "Done in 538.3s" banner card, a column of - // per-stage bars that never lined up with their - // names, and a raw status dump with the routing - // internals inline). Everything diagnostic still - // exists: it is in the fold, one press away. - create_runs_heading := PanelHeading{ text: "Runs" } - create_runs := View{ - width: Fill height: Fit flow: Down spacing: 6 - rc0 := RunCardBody{ visible: false } - rc1 := RunCardBody{ visible: false } - rc2 := RunCardBody{ visible: false } - rc3 := RunCardBody{ visible: false } + PanelHeading{ text: "Now running" } + now_card := Card{ + padding: 8 + spacing: 6 + now_top := View{ + width: Fill height: Fit flow: Right spacing: 4 + align: Align{y: 0.5} + now_head := BrightLabel{ text: "Idle — nothing running" } + cancel_btn := DangerButton{ text: "Stop" visible: false } + } + now_bar := ProgressBar{} + now_detail := DimLabel{ text: "" } } - create_runs_note := HintLabel{ + + PanelHeading{ text: "Up next" } + queue_panel := View{ + width: Fill height: Fit flow: Down spacing: 3 + q1_row := Card{ flow: Right padding: Inset{left: 8 right: 4 top: 3 bottom: 3} spacing: 4 align: Align{y: 0.5} visible: false + q1_label := MonoLabel{} q1_up := GhostButton{ text: "↑" } q1_cancel := DangerButton{ text: "×" } } + q2_row := Card{ flow: Right padding: Inset{left: 8 right: 4 top: 3 bottom: 3} spacing: 4 align: Align{y: 0.5} visible: false + q2_label := MonoLabel{} q2_up := GhostButton{ text: "↑" } q2_cancel := DangerButton{ text: "×" } } + q3_row := Card{ flow: Right padding: Inset{left: 8 right: 4 top: 3 bottom: 3} spacing: 4 align: Align{y: 0.5} visible: false + q3_label := MonoLabel{} q3_up := GhostButton{ text: "↑" } q3_cancel := DangerButton{ text: "×" } } + q4_row := Card{ flow: Right padding: Inset{left: 8 right: 4 top: 3 bottom: 3} spacing: 4 align: Align{y: 0.5} visible: false + q4_label := MonoLabel{} q4_up := GhostButton{ text: "↑" } q4_cancel := DangerButton{ text: "×" } } + q5_row := Card{ flow: Right padding: Inset{left: 8 right: 4 top: 3 bottom: 3} spacing: 4 align: Align{y: 0.5} visible: false + q5_label := MonoLabel{} q5_up := GhostButton{ text: "↑" } q5_cancel := DangerButton{ text: "×" } } + q6_row := Card{ flow: Right padding: Inset{left: 8 right: 4 top: 3 bottom: 3} spacing: 4 align: Align{y: 0.5} visible: false + q6_label := MonoLabel{} q6_up := GhostButton{ text: "↑" } q6_cancel := DangerButton{ text: "×" } } + } + + PanelHeading{ text: "Stage details" } + stage_bars := View{ + width: Fill height: Fit flow: Down spacing: 4 + s1_row := View{ width: Fill height: Fit flow: Right spacing: 6 align: Align{y: 0.5} visible: false + s1_name := DimLabel{ width: 220 } s1_bar := ProgressBar{ height: 4 } } + s2_row := View{ width: Fill height: Fit flow: Right spacing: 6 align: Align{y: 0.5} visible: false + s2_name := DimLabel{ width: 220 } s2_bar := ProgressBar{ height: 4 } } + s3_row := View{ width: Fill height: Fit flow: Right spacing: 6 align: Align{y: 0.5} visible: false + s3_name := DimLabel{ width: 220 } s3_bar := ProgressBar{ height: 4 } } + s4_row := View{ width: Fill height: Fit flow: Right spacing: 6 align: Align{y: 0.5} visible: false + s4_name := DimLabel{ width: 220 } s4_bar := ProgressBar{ height: 4 } } + s5_row := View{ width: Fill height: Fit flow: Right spacing: 6 align: Align{y: 0.5} visible: false + s5_name := DimLabel{ width: 220 } s5_bar := ProgressBar{ height: 4 } } + s6_row := View{ width: Fill height: Fit flow: Right spacing: 6 align: Align{y: 0.5} visible: false + s6_name := DimLabel{ width: 220 } s6_bar := ProgressBar{ height: 4 } } + } + stages_scroll := QuietScrollY{ width: Fill - text: "Nothing running — pick a chain above and Generate." + height: 170 + stages_label := DimLabel{ text: "No pipeline yet — pick a chain above and Generate." } } } // Say it in words and the same run appears in the @@ -2430,25 +2299,6 @@ script_mod! { text_style: theme.font_bold{font_size: 7} } } - // Everything this app has in flight, whoever is - // running it: store pipelines, standalone store - // jobs and this app's own engine, counted once - // and weighed once. Always here — "nothing is - // running" is an answer, and a chip that hides - // itself cannot be pressed to see what just - // finished. Press opens the panel. - runs_chip := ButtonFlatter{ - text: "RUNS · idle" - margin: 0 - padding: Inset{left: 6 right: 6 top: 2 bottom: 2} - draw_text +: { - color: #x6a7178 - color_hover: #xe6ebf0 - color_down: #xffffff - color_focus: #x6a7178 - text_style: theme.font_bold{font_size: 7} - } - } } // Everything below the nav flips between the surfaces. @@ -3054,7 +2904,6 @@ script_mod! { width: Fill height: Fit flow: Right spacing: 6 align: Align{y: 0.5} detail_analyse_btn := GhostButton{ text: "Analyse stems" } - detail_reveal_btn := GhostButton{ text: "Reveal file" } detail_analyse_lyrics := CheckBox{ text: "+ lyrics" active: false @@ -3225,32 +3074,6 @@ script_mod! { HintLabel{ text: "Official Duke Nukem 3D shareware. Local preview in this app only. Not a redistributable grant. Not retail Atomic Edition. Optional HRP stays under the Duke4 HRP license." } } - ea_card := ImportRow{ - flow: Down spacing: 4 - View{ - width: Fill height: Fit flow: Right spacing: 8 - align: Align{y: 0.5} - ea_import_btn := PrimaryButton{ text: "Load" } - BrightLabel{ text: "Command & Conquer classics (EA freeware)" width: 280 } - ea_license_label := HintLabel{ text: "EA freeware · local preview only" } - LinkLabel{ text: "Terms" url: "https://www.ea.com/games/command-and-conquer" } - } - DropField{ - FieldCaption{ text: "Pack" } - ea_pack_drop := FieldDrop{} - } - ea_blurb_label := HintLabel{ - text: "EA's 1995 RTS, freeware since 2007 — terrain, units, structures, sounds and every campaign and multiplayer map convert into RTS maps for the sandbox." - } - View{ - width: Fill height: Fit flow: Right spacing: 8 - align: Align{y: 0.5} - ea_status_label := HintLabel{ width: Fill text: "" } - ea_cancel_btn := DangerButton{ text: "Cancel" visible: false } - } - ea_progress := ProgressBar{ width: Fill height: 5 } - } - quake2_card := ImportRow{ flow: Down spacing: 2 View{ @@ -3379,45 +3202,6 @@ script_mod! { } } - } - kenney_donate_modal := Modal{ - can_dismiss: true - content +: { - width: 460 - height: Fit - RoundedView{ - width: Fill - height: Fit - padding: 20 - spacing: 10 - flow: Down - draw_bg +: { - color: #x16161b - border_color: #xffffff18 - border_size: 1.0 - border_radius: 6.0 - } - BrightLabel{ - text: "Consider donating to Kenney" - draw_text +: { text_style: theme.font_bold{font_size: 12} } - } - DimLabel{ - width: Fill - height: Fit - text: "These kits are free (CC BY 4.0) and made by one person. If they end up in your game, a donation keeps them coming." - } - LinkLabel{ text: "kenney.nl/donate" url: "https://kenney.nl/donate" } - View{ - width: Fill - height: Fit - flow: Right - spacing: 8 - align: Align{x: 1.0 y: 0.5} - kenney_donate_cancel := ChipButton{ text: "Cancel" } - kenney_donate_ok := PrimaryButton{ text: "Import" } - } - } - } } license_modal := Modal{ can_dismiss: false @@ -3621,49 +3405,6 @@ script_mod! { } } } - // Everything in flight, one card each, behind the RUNS - // chip. Pipelines, standalone store jobs and this app's - // own runs land in ONE list in ONE grammar; the card is - // the same object the Create surface draws. - runs_panel_modal := Modal{ - content +: { - width: 760 - height: Fit - RoundedView{ - width: Fill - height: Fit - padding: 16 - spacing: 8 - flow: Down - draw_bg +: { - color: #x16161b - border_color: #xffffff18 - border_size: 1.0 - border_radius: 6.0 - } - View{ - width: Fill - height: Fit - flow: Right - spacing: 8 - align: Align{y: 0.5} - runs_panel_title := BrightLabel{ - width: Fit - text: "Runs" - draw_text +: { text_style: theme.font_bold{font_size: 12} } - } - runs_panel_count := DimLabel{ width: Fill text: "" } - runs_cancel_all := DangerButton{ text: "Cancel all" visible: false } - runs_panel_close := ChipButton{ text: "Close" } - } - runs_panel_note := HintLabel{ width: Fill text: "" } - runs_panel_list := mod.widgets.StoreListPanel{ - width: Fill - height: 470 - } - } - } - } } } } @@ -3791,6 +3532,7 @@ fn repo_path(rel: &str) -> String { format!("{}/../../{}", env!("CARGO_MANIFEST_DIR"), rel) } +const QUEUE_ROWS: usize = 6; /// Box cards in the Fleet box. const FLEET_CARD_SLOTS: usize = 12; @@ -3911,10 +3653,6 @@ struct ActiveRun { group_id: String, group_label: String, prompt: String, - /// Wall clock at spawn. The card list sorts store runs and this app's - /// own runs into ONE order, and an `Instant` cannot be compared with a - /// server's `created_ms`. - created_ms: u64, pipeline: Pipeline, } @@ -3930,7 +3668,7 @@ const VOICES: &[&str] = &[ ]; /// Right-pane surface behind the nav tabs. Create keeps the viewer + -/// History strip; Import is the licensed pack catalog; the others are Asset +/// History strip; Import is the OSS pack catalog; the others are Asset /// Store views. #[derive(Clone, Copy, PartialEq, Eq, Default)] enum Surface { @@ -3957,7 +3695,7 @@ struct AutoRun { /// AI_CONTENT_SURFACE="library"|"import"|"runs"|"admin": start on that /// surface (headless captures of the Asset Store / Import views). surface: Option, - /// ASSET_UI_IMPORT: queue named Import cards once the UI is up. + /// ASSET_UI_IMPORT="duke3d": queue that classic Import card once the UI is up. import: Option, capture: Option, /// AI_CONTENT_CAPTURE_AT_S: capture at a fixed time after startup @@ -3995,22 +3733,11 @@ pub struct App { /// The last run spec, for Retry after a failure. #[rust] last_run: Option, - /// `(run id, stage index)` of every stage row the person has OPENED to - /// read what it was handed. Lives here, not in the list: the rows are - /// rebuilt from scratch several times a second, so an open row has to - /// be remembered by something that outlives them. - #[rust] - open_stages: Vec<(u64, usize)>, - /// Everything in flight ANYWHERE this app can see: store pipelines, - /// standalone store jobs and this app's own runs, merged into the one - /// card grammar behind the header's RUNS chip. - #[rust] - runs_chip: RunsChip, #[rust] fleet_timer: Timer, /// LAN beacon listener; polled on the fleet timer. #[rust] - discovered: Option, + discovered: Option, #[rust] job_timer: Timer, #[rust] @@ -4152,9 +3879,6 @@ pub struct App { /// Box base_url shown in the fleet box popup + its row → model map. #[rust] fleet_modal_box: Option, - /// Kenney Load / Load all waiting on the "consider donating" popup's OK. - #[rust] - kenney_donate_pending: Option, #[rust] fleet_modal_models: Vec, /// Job ids per jobs row in the node column (for Cancel). @@ -4222,10 +3946,6 @@ pub struct App { /// One running import plus a user-editable wait list. #[rust] import_queue: ImportQueue, - /// The store's vision-annotation queue: this app hosts its worker and - /// draws its progress. An asset published without a description is - /// invisible to every text search the AI level builder makes. - #[rust] /// Landings waiting to be written a few at a time so the UI stays live. #[rust] import_landings: Vec, @@ -4333,19 +4053,6 @@ impl App { } // One shared, real Asset Server session. Discovery/auth/retry happen // off-thread; this call only starts the lifecycle. - // GPU boxes join via the LAN beacon. Asset-ui stays on the `gen` - // fleet so the sandbox `game` box (.123) never lands in this UI. - // Resolved BEFORE the store starts: the embedded server's chat broker - // listens for the same fleet name (start_embedded_asset_server_at), - // and it used to be pinned to `default` while this panel showed the - // `gen` boxes 2/2 up — every game chat got "no fleet nodes configured". - if std::env::var_os("MAKEPAD_AI_FLEET").is_none() { - std::env::set_var("MAKEPAD_AI_FLEET", "gen"); - } - log!( - "fleet: listening for '{}' beacons (MAKEPAD_AI_FLEET)", - makepad_ai_hub::discovery::wanted_fleet() - ); // The store hosts the embedded Asset Server; hand it the library it // must publish. Library::open ran above, so the product backfill is // already on disk when the watcher's first poll reads index.json. @@ -4363,7 +4070,12 @@ impl App { // empty library changes nothing. self.refresh_gallery(cx, true); - self.discovered = Some(makepad_ai_hub::discovery::start_listener()); + // GPU boxes join via the LAN beacon. Asset-ui stays on the `gen` + // fleet so the sandbox `game` box (.123) never lands in this UI. + if std::env::var_os("MAKEPAD_AI_FLEET").is_none() { + std::env::set_var("MAKEPAD_AI_FLEET", "gen"); + } + self.discovered = Some(makepad_asset_ai::discovery::start_listener()); self.fleet = Some(FleetPoll::new()); self.maybe_connect_chat(cx); self.fleet_timer = cx.start_interval(3.0); @@ -4380,16 +4092,6 @@ impl App { self.ui .combo_box(cx, ids!(preset_drop)) .set_labels(cx, labels); - self.ui.combo_box(cx, ids!(ea_pack_drop)).set_labels( - cx, - crate::import_classic::EA_PACK_LABELS - .iter() - .map(|label| (*label).to_string()) - .collect(), - ); - self.ui - .combo_box(cx, ids!(ea_pack_drop)) - .set_selected_item(cx, 0); self.ui .combo_box(cx, ids!(box_drop)) .set_labels(cx, vec!["auto (affinity)".to_string()]); @@ -4777,7 +4479,7 @@ impl App { }; } } - if let Some(license) = makepad_ai_hub::registry::license_for_model(model_id) { + if let Some(license) = makepad_asset_ai::registry::license_for_model(model_id) { let identity = license.identity(); return LicensePrompt { model_id: model_id.to_string(), @@ -4856,15 +4558,6 @@ impl App { ids } - /// Kenney kits are free: every Load / Load all first asks for a donation - /// (kenney.nl/donate). OK queues the held job, Cancel/dismiss drops it. - fn open_kenney_donate_modal(&mut self, cx: &mut Cx, job: ImportJob) { - let label = if matches!(job, ImportJob::KenneyAll) { "Import all" } else { "Import" }; - self.ui.button(cx, ids!(kenney_donate_ok)).set_text(cx, label); - self.kenney_donate_pending = Some(job); - self.ui.modal(cx, ids!(kenney_donate_modal)).open(cx); - } - fn open_license_modal(&mut self, cx: &mut Cx, prompt: LicensePrompt) { let kind = match prompt.restriction.as_str() { "non-commercial" => "Non-commercial weights. Personal / research use only.", @@ -5005,17 +4698,7 @@ impl App { if let Some(job) = running { let pct = (job.progress.unwrap_or(0.0) * 100.0).round() as u32; let what = job.model.clone().unwrap_or_else(|| "job".to_string()); - let mut stage = job.stage.clone().unwrap_or_else(|| job.state.clone()); - // A chat turn asks for "unlimited tokens" (u32::MAX) and the - // box's stage echoes it — "decode 48/4294967295" is noise. - // Show the count alone when the cap is plainly boundless. - if let Some(rest) = stage.strip_prefix("decode ") { - if let Some((k, n)) = rest.split_once('/') { - if n.trim().parse::().map_or(false, |n| n > 100_000_000) { - stage = format!("decode {} tok", k.trim()); - } - } - } + let stage = job.stage.clone().unwrap_or_else(|| job.state.clone()); let more = pending.saturating_sub(1); let tail = if more > 0 { format!(" +{more} queued") } else { String::new() }; return (format!("{what} · {stage} {pct}%{tail}"), BUSY); @@ -5101,7 +4784,7 @@ impl App { }; self.ui.label(cx, ids!(fleet_box_status)).set_text(cx, &status); // Live jobs (running first, then queued) — other clients' included. - let jobs: Vec = self + let jobs: Vec = self .fleet .as_ref() .and_then(|fleet| { @@ -5128,17 +4811,8 @@ impl App { view.set_visible(cx, true); let pct = (job.progress.unwrap_or(0.0) * 100.0).round() as u32; let who = if ours.iter().any(|id| id == &job.job_id) { "ours" } else { "other client" }; - // For a job of OUR OWN, say what it was asked for: the box - // reports a model and a stage but never the prompt, so this is - // the only side that knows. - let asked = self - .runs - .iter() - .find_map(|run| run.pipeline.sent_prompt_for_job(&job.job_id)) - .map(|prompt| format!(" · \u{201c}{}\u{201d}", truncate(prompt, 64))) - .unwrap_or_default(); let text = format!( - "{} · {} · {}{} · {} · {}{asked}", + "{} · {} · {}{} · {} · {}", job.model.clone().unwrap_or_else(|| "?".to_string()), job.state, job.stage.clone().unwrap_or_default(), @@ -5623,7 +5297,6 @@ impl App { .selected_item() .min(ENHANCE_FACTORS.len() - 1)], enhance_flow: self.ui.check_box(cx, ids!(enh_flow_toggle)).active(cx), - video_loop: false, } } @@ -5871,7 +5544,7 @@ impl App { return; }; let first_domain = PRESETS[preset_index].domains[0]; - if makepad_ai_hub::fleet::pick_for_domain(&self.routing_snapshots(), first_domain).is_none() { + if makepad_asset_ai::fleet::pick_for_domain(&self.routing_snapshots(), first_domain).is_none() { return; // wait for discovery } self.auto.fired = true; @@ -5924,12 +5597,7 @@ impl App { fn current_run_spec(&mut self, cx: &mut Cx) -> Result { let mut prompt = self.ui.text_input(cx, ids!(prompt_input)).text(); if prompt.trim().is_empty() { - // A demo prompt stands in for an empty box, but SILENTLY - // substituting one reads as "my prompt did not go in" — which is - // exactly what it is. prompt = "a weathered fishing trawler at dawn, misty harbor".to_string(); - log!("input: prompt box was empty — using the demo prompt"); - self.set_caption(cx, "INPUT", "prompt box was empty — used the demo prompt"); } let preset = self.current_preset_index(cx); let model_overrides = @@ -6143,7 +5811,6 @@ impl App { .selected_item() .min(ENHANCE_FACTORS.len() - 1)], enhance_flow: self.ui.check_box(cx, ids!(enh_flow_toggle)).active(cx), - video_loop: false, }, input, }) @@ -6176,7 +5843,7 @@ impl App { self.open_license_modal(cx, prompt); return; } - let request = makepad_ai_hub::protocol::GenerateRequestJson { + let request = makepad_asset_ai::protocol::GenerateRequestJson { model: model.clone(), pull_only: Some(true), queue_policy: Some("queue".to_string()), @@ -6334,8 +6001,8 @@ impl App { // A big-enough occupied GPU remains a capable queue target; // a physically undersized GPU does not. let admission = match &pinned_model { - Some(model) => makepad_ai_hub::fleet::model_admission(snapshot, model), - None => makepad_ai_hub::fleet::domain_admission(snapshot, &domain), + Some(model) => makepad_asset_ai::fleet::model_admission(snapshot, model), + None => makepad_asset_ai::fleet::domain_admission(snapshot, &domain), }; let capable = admission.is_some_and(|state| state.is_hardware_compatible()); let vram_waiting = admission.is_some_and(|state| state.is_waiting()); @@ -6491,20 +6158,6 @@ impl App { .chain(PRESETS[run.preset].pins.iter().map(|(_, model)| model.to_string())) .collect(); let snapshots = self.routing_snapshots_keeping(&keep); - // Every run says WHAT it was asked for, in the person's own words, - // the moment it starts. A run whose prompt appears nowhere is a run - // that cannot be found again when something goes wrong with it. - log!( - "run: {} starting {:?} — prompt \"{}\"", - run.group_id, - PRESETS[run.preset].name, - truncate(run.prompt.trim(), 160) - ); - // Loop-ness lives on the preset row, not the stored spec — a saved - // spec pointing at a loop preset loops, and can never carry a stale - // flag the other way. - let mut gen = run.gen.clone(); - gen.video_loop = PRESETS[run.preset].video_loop; let mut pipeline = Pipeline::new( &run.prompt, run.domains(), @@ -6512,7 +6165,7 @@ impl App { run.model_overrides.clone(), run.box_override.clone(), run.voice.clone(), - gen, + run.gen.clone(), ); let skip = run.input.as_ref().map_or(0, |seed| seed.skip); if let Some(seed) = &run.input { @@ -6571,10 +6224,6 @@ impl App { group_id: run.group_id.clone(), group_label: run.group_label.clone(), prompt: run.prompt.clone(), - created_ms: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|since| since.as_millis() as u64) - .unwrap_or(0), pipeline, }); self.last_run = Some(run); @@ -6616,72 +6265,149 @@ impl App { .or_else(|| self.runs.last()) } - /// The run cards on the Create surface, the header chip, the spinner - /// and Retry. - /// - /// ONE grammar: every spawned unit of work — a store pipeline, a - /// standalone store job, or a run of this app's own engine — is a card - /// built by `runs_chip.rs` and drawn by `store_views::paint_card`, here - /// and in the RUNS panel alike. What was here before was the same run - /// drawn three times over (a "Done in 538.3s" banner, a column of - /// per-stage bars, a raw status dump with the routing internals inline) - /// and that is what this replaced. + /// NOW card + queue rows + spinner + retry visibility. fn refresh_run_ui(&mut self, cx: &mut Cx) { let active_running = self.any_run_running(); + let concurrent = self.active_run_count(); self.ui .widget(cx, ids!(spinner)) .set_visible(cx, active_running); - let failed = self - .display_run() - .is_some_and(|run| { - run.pipeline - .stages - .iter() - .any(|s| matches!(s.state, StageState::Failed(_))) - }); + let display_pipeline = self.display_run().map(|run| &run.pipeline); + let failed = display_pipeline.is_some_and(|p| { + p.stages + .iter() + .any(|s| matches!(s.state, StageState::Failed(_))) + }); self.ui .widget(cx, ids!(retry_btn)) .set_visible(cx, failed && !active_running && self.last_run.is_some()); - let cards = self.run_cards(); - // The chip is always readable, whatever surface is open. - self.ui - .button(cx, ids!(runs_chip)) - .set_text(cx, &crate::runs_chip::chip_text(&cards)); - - // Create surface: the newest few, in full. The rest are one press - // away in the panel — a left panel is not a place to scroll a - // hundred runs. - let slots = [ids!(rc0), ids!(rc1), ids!(rc2), ids!(rc3)]; - for (index, slot) in slots.iter().enumerate() { - let widget = self.ui.widget(cx, *slot); - match cards.get(index) { - Some(card) => { - widget.set_visible(cx, true); - crate::store_views::paint_card(cx, &widget, card); - } - None => widget.set_visible(cx, false), + // NOW card: the newest active stage front and center — what's + // running, where, its live detail, and a real bar. Concurrent runs + // are counted here and itemized on the Runs surface. + let (head, detail, fraction) = match display_pipeline { + Some(p) if p.is_running() => { + let s = &p.stages[p.current]; + let stage_name = if s.domain == "music" { + format!( + "{} ({} target)", + stage_display_name(&s.domain), + format_music_duration(p.gen.music_seconds), + ) + } else { + stage_display_name(&s.domain).to_string() + }; + let where_ = if s.box_url.is_empty() { + String::new() + } else { + format!( + " — {} @ {}", + s.model, + s.box_url.trim_start_matches("http://") + ) + }; + let elapsed_s = s.started.map(|t0| t0.elapsed().as_secs_f64()).unwrap_or(0.0); + let frac = s.progress.clamp(0.0, 1.0); + let eta = if frac > 0.02 && frac < 0.999 && elapsed_s > 1.0 { + let left = elapsed_s * (1.0 - frac) / frac; + format!(" · ~{} left", format_clock(left)) + } else { + String::new() + }; + let elapsed = if elapsed_s > 0.0 { + format!(" · {}", format_clock(elapsed_s)) + } else { + String::new() + }; + let state = match &s.state { + StageState::Waiting => "waiting".to_string(), + StageState::FanOut => s.detail.clone(), + StageState::AwaitingChoice => s.detail.clone(), + StageState::Submitting => "submitting…".to_string(), + StageState::Polling => s.detail.clone(), + StageState::Fetching => "fetching artifacts…".to_string(), + StageState::Done => "done".to_string(), + StageState::Failed(e) => format!("FAILED: {e}"), + }; + let others = if concurrent > 1 { + format!(" · {concurrent} runs live", ) + } else { + String::new() + }; + ( + format!( + "Stage {}/{} · {} · {:>5.1}%{}{}{}{others}", + p.current + 1, + p.stages.len(), + stage_name, + frac * 100.0, + where_, + elapsed, + eta + ), + state, + frac, + ) } - } - self.ui - .widget(cx, ids!(create_runs_heading)) - .set_visible(cx, !cards.is_empty()); - let note = if cards.is_empty() { - "Nothing running — pick a chain above and Generate.".to_string() - } else if cards.len() > slots.len() { - format!( - "+{} more — press RUNS in the header for all of them.", - cards.len() - slots.len() - ) - } else { - String::new() + Some(p) if failed => { + let e = p + .stages + .iter() + .find_map(|s| match &s.state { + StageState::Failed(e) => Some(e.clone()), + _ => None, + }) + .unwrap_or_default(); + ("Failed".to_string(), format!("FAILED: {e}"), 0.0) + } + Some(p) => { + let total: f64 = p + .stages + .iter() + .filter_map(|s| match (s.started, s.finished) { + (Some(t0), Some(t1)) => Some((t1 - t0).as_secs_f64()), + _ => None, + }) + .sum(); + (format!("Done in {total:.1}s"), String::new(), 1.0) + } + None => ("Idle — nothing running".to_string(), String::new(), 0.0), }; - let note_label = self.ui.label(cx, ids!(create_runs_note)); - note_label.set_visible(cx, !note.is_empty()); - note_label.set_text(cx, ¬e); + self.ui.label(cx, ids!(now_head)).set_text(cx, &head); + self.ui.label(cx, ids!(now_detail)).set_text(cx, &detail); + self.ui + .widget(cx, ids!(cancel_btn)) + .set_visible(cx, active_running); + self.ui + .view(cx, ids!(now_bar)) + .set_uniform(cx, live_id!(progress), &[fraction as f32]); - if self.runs_chip.panel_open { - self.refresh_runs_modal(cx, &cards); + // Queue rows: waiting runs only — the active run lives in the card. + let rows = [ + (ids!(q1_row), ids!(q1_label)), + (ids!(q2_row), ids!(q2_label)), + (ids!(q3_row), ids!(q3_label)), + (ids!(q4_row), ids!(q4_label)), + (ids!(q5_row), ids!(q5_label)), + (ids!(q6_row), ids!(q6_label)), + ]; + let texts: Vec = self + .run_queue + .iter() + .map(|run| { + format!( + "{} — \"{}\"", + PRESETS[run.preset].name, + truncate(&run.prompt, 28) + ) + }) + .collect(); + for (k, (row, label)) in rows.iter().enumerate() { + let visible = k < texts.len(); + self.ui.widget(cx, *row).set_visible(cx, visible); + if visible { + self.ui.label(cx, *label).set_text(cx, &texts[k]); + } } if self.surface == Surface::Runs { self.refresh_runs_panel(cx); @@ -6689,115 +6415,6 @@ impl App { self.ui.redraw(cx); } - /// Every spawned unit of work this app can see, merged into one list in - /// one order. The store's own runs come from the poll thread; the app's - /// own engine is lent to the builder by reference, so nothing is copied - /// several times a second. - fn run_cards(&mut self) -> Vec { - let local: Vec = self - .runs - .iter() - .map(|run| crate::runs_chip::LocalRun { - id: run.id, - label: run.group_label.as_str(), - prompt: run.prompt.as_str(), - created_ms: run.created_ms, - pipeline: &run.pipeline, - }) - .collect(); - let queued: Vec = self - .run_queue - .iter() - .enumerate() - .map(|(index, run)| crate::runs_chip::LocalQueued { - index, - label: PRESETS[run.preset].name, - prompt: run.prompt.as_str(), - }) - .collect(); - self.runs_chip.cards(&local, &queued) - } - - /// The RUNS panel: the same cards, all of them, with a cancel-all. - fn refresh_runs_modal(&mut self, cx: &mut Cx, cards: &[crate::runs_chip::RunCard]) { - let active = cards.iter().filter(|card| card.is_active()).count(); - self.ui - .label(cx, ids!(runs_panel_count)) - .set_text(cx, &crate::runs_chip::chip_text(cards)); - self.ui - .button(cx, ids!(runs_cancel_all)) - .set_visible(cx, active > 0); - let note = String::new(); - let note_label = self.ui.label(cx, ids!(runs_panel_note)); - note_label.set_visible(cx, !note.is_empty()); - note_label.set_text(cx, ¬e); - - let rows: Vec = if cards.is_empty() { - vec![StoreRow::Note( - "Nothing has been spawned yet. A run started anywhere — here, the chat, the VJ — shows up in this list the moment it is enqueued.".into(), - )] - } else { - cards - .iter() - .map(|card| StoreRow::Card(Box::new(card.clone()))) - .collect() - }; - if let Some(mut list) = self - .ui - .widget(cx, ids!(runs_panel_list)) - .borrow_mut::() - { - list.set_rows(cx, rows); - } - } - - fn open_runs_panel(&mut self, cx: &mut Cx) { - self.runs_chip.set_panel_open(true); - self.ui.modal(cx, ids!(runs_panel_modal)).open(cx); - let cards = self.run_cards(); - self.refresh_runs_modal(cx, &cards); - } - - fn close_runs_panel(&mut self, cx: &mut Cx) { - self.runs_chip.set_panel_open(false); - self.ui.modal(cx, ids!(runs_panel_modal)).close(cx); - } - - /// Stop one spawned unit, whichever engine holds it. The store does the - /// heavy lifting for its own (a pipeline cancel drops every non-terminal - /// stage job in one closure); a local run gets the engine's own cancel; - /// a queued local run simply never starts. - /// What a press on one card body means. The × and ↑ are checked FIRST: - /// they sit inside the card, and the card itself is the fold's press - /// target, so a stop must never also open the fold. - fn card_press( - &self, - cx: &mut Cx, - item: &WidgetRef, - card: &crate::runs_chip::RunCard, - actions: &Actions, - ) -> Option { - if card.can_cancel && item.button(cx, ids!(card_cancel)).clicked(actions) { - return Some(RowAction::CancelCard(card.key.clone())); - } - if card.can_promote && item.button(cx, ids!(card_up)).clicked(actions) { - return Some(RowAction::PromoteCard(card.key.clone())); - } - if card.open && item.button(cx, ids!(card_copy)).clicked(actions) { - return Some(RowAction::CopyCard(card.key.clone())); - } - item.view(cx, ids!(card_body)) - .finger_down(actions) - .map(|_| RowAction::ToggleCard(card.key.clone())) - } - - fn cancel_card(&mut self, cx: &mut Cx, key: &crate::runs_chip::CardKey) { - match key { - crate::runs_chip::CardKey::Local(run_id) => self.cancel_run(cx, *run_id), - crate::runs_chip::CardKey::LocalQueued(index) => self.cancel_row(cx, *index), - } - } - fn cancel_row(&mut self, cx: &mut Cx, row: usize) { if row < self.run_queue.len() { self.run_queue.remove(row); @@ -6805,6 +6422,23 @@ impl App { self.refresh_run_ui(cx); } + /// Stop button on the NOW card: cancels the run the card is showing + /// (the newest running one). Queued service jobs drop immediately; + /// running jobs raise the cancel flag and unwind within seconds. + fn cancel_active(&mut self, cx: &mut Cx) { + let newest_running = self + .runs + .iter() + .rev() + .find(|run| run.pipeline.is_running()) + .map(|run| run.id); + if let Some(run_id) = newest_running { + self.cancel_run(cx, run_id); + } else { + self.refresh_run_ui(cx); + } + } + /// Per-run Stop from the Runs surface — each concurrent run cancels /// independently; the others keep their slots and progress. fn cancel_run(&mut self, cx: &mut Cx, run_id: u64) { @@ -7041,6 +6675,105 @@ impl App { } } + fn refresh_stages(&mut self, cx: &mut Cx) { + // Slim per-stage bars: one row per chain stage, accent fill (red on + // failure). The text log below keeps the full routing detail. + let rows = [ + (ids!(s1_row), ids!(s1_name), ids!(s1_bar)), + (ids!(s2_row), ids!(s2_name), ids!(s2_bar)), + (ids!(s3_row), ids!(s3_name), ids!(s3_bar)), + (ids!(s4_row), ids!(s4_name), ids!(s4_bar)), + (ids!(s5_row), ids!(s5_name), ids!(s5_bar)), + (ids!(s6_row), ids!(s6_name), ids!(s6_bar)), + ]; + let stages: Vec<(String, f64, bool)> = self + .display_run() + .map(|run| { + run.pipeline + .stages + .iter() + .enumerate() + .map(|(i, s)| { + let (fraction, is_failed) = match &s.state { + StageState::Done => (1.0, false), + StageState::AwaitingChoice => (1.0, false), + StageState::Failed(_) => (1.0, true), + _ => (s.progress, false), + }; + let activity = match &s.state { + StageState::Waiting if !s.detail.is_empty() => s.detail.as_str(), + StageState::Waiting => "waiting", + StageState::FanOut => s.detail.as_str(), + StageState::AwaitingChoice => s.detail.as_str(), + StageState::Submitting => "submitting", + StageState::Polling if !s.detail.is_empty() => s.detail.as_str(), + StageState::Polling => s.service_state.as_str(), + StageState::Fetching => "fetching artifacts", + StageState::Done => "done", + StageState::Failed(_) => "FAILED", + }; + let elapsed = match (s.started, s.finished) { + (Some(t0), Some(t1)) => { + format!("{:.1}s", (t1 - t0).as_secs_f64()) + } + (Some(t0), None) => format!("{:.0}s", t0.elapsed().as_secs_f64()), + _ => String::new(), + }; + let stage_name = if s.domain == "music" { + format!( + "{} ({} target)", + stage_display_name(&s.domain), + format_music_duration(run.pipeline.gen.music_seconds), + ) + } else { + stage_display_name(&s.domain).to_string() + }; + let mut label = format!( + "{} · {} · {:>5.1}% · {}", + i + 1, + stage_name, + fraction.clamp(0.0, 1.0) * 100.0, + truncate(activity, 22), + ); + if !elapsed.is_empty() { + label.push_str(&format!(" · {elapsed}")); + } + (label, fraction, is_failed) + }) + .collect() + }) + .unwrap_or_default(); + for (k, (row, name, bar)) in rows.iter().enumerate() { + let visible = k < stages.len(); + self.ui.widget(cx, *row).set_visible(cx, visible); + if !visible { + continue; + } + let (label, fraction, is_failed) = &stages[k]; + let fill: [f32; 4] = if *is_failed { + [0.85, 0.35, 0.32, 1.0] + } else { + [0.24, 0.61, 0.94, 1.0] + }; + self.ui.label(cx, *name).set_text(cx, label); + let bar = self.ui.view(cx, *bar); + bar.set_uniform(cx, live_id!(progress), &[*fraction as f32]); + bar.set_uniform(cx, live_id!(color_fill), &fill); + } + if let Some(run) = self.display_run() { + let others = self.active_run_count().saturating_sub( + usize::from(run.pipeline.is_running()), + ); + let mut text = format!("run: {}\n{}", run.group_label, run.pipeline.status_text()); + if others > 0 { + text.push_str(&format!( + "\n+{others} more running — see RUNS + WORKERS for each" + )); + } + self.ui.label(cx, ids!(stages_label)).set_text(cx, &text); + } + } + fn on_run_events(&mut self, cx: &mut Cx, run_id: u64, events: Vec) { let mut done_or_failed = false; for event in events { @@ -7324,6 +7057,7 @@ impl App { } } } + self.refresh_stages(cx); self.refresh_run_ui(cx); if done_or_failed { if self.auto.capture.is_some() @@ -8270,7 +8004,7 @@ impl App { return; }; let rgba = webcam::bgra_to_rgba8(&frame.bgra); - let Ok(png) = makepad_ai_hub::testpattern::encode_png_rgba(&rgba, frame.width, frame.height) + let Ok(png) = makepad_asset_ai::testpattern::encode_png_rgba(&rgba, frame.width, frame.height) else { self.set_webcam_status(cx, "snapshot PNG encode failed"); return; @@ -8373,7 +8107,7 @@ impl App { (px >> 24) as u8, ]); } - makepad_ai_hub::testpattern::encode_png_rgba(&rgba, image.width, image.height) + makepad_asset_ai::testpattern::encode_png_rgba(&rgba, image.width, image.height) .ok() }) { Some(png) => png, @@ -8717,7 +8451,7 @@ impl App { .count(); if differing * 200 > width * height { if let Ok(png) = - makepad_ai_hub::testpattern::encode_png_rgba(&bgra, width, height) + makepad_asset_ai::testpattern::encode_png_rgba(&bgra, width, height) { match self .library @@ -11428,9 +11162,8 @@ impl App { &fleet.snapshots, &fleet.latency_ms, &self.store, - &self.open_stages, ), - None => runs_rows(&run_views, &queued, &[], &[], &self.store, &self.open_stages), + None => runs_rows(&run_views, &queued, &[], &[], &self.store), }; if let Some(mut list) = self .ui @@ -11451,12 +11184,6 @@ impl App { } } - fn selected_ea_source(&self, cx: &mut Cx) -> ClassicSource { - crate::import_classic::ea_source_for_index( - self.ui.combo_box(cx, ids!(ea_pack_drop)).selected_item(), - ) - } - fn refresh_import_ui(&mut self, cx: &mut Cx) { let _modules = crate::import_classic::PACK_MODULES_WITH_CLASSIC; let labels = crate::import::kenney_pack_labels(); @@ -11568,43 +11295,6 @@ impl App { "Load" }, ); - let ea_index = self - .ui - .combo_box(cx, ids!(ea_pack_drop)) - .selected_item() - .min(crate::import_classic::EA_MODULES.len() - 1); - let ea_source = crate::import_classic::ea_source_for_index(ea_index); - let ea_module = &crate::import_classic::EA_MODULES[ea_index]; - let ea_card = self.classic_import_page.card(ea_source); - let ea_job = ImportJob::EaClassic { - source: ea_source, - path: String::new(), - }; - self.ui.button(cx, ids!(ea_import_btn)).set_text( - cx, - if self.import_queue.is_active(&ea_job) && ea_card.compiling() { - "Loading…" - } else if self.import_queue.has_job(&ea_job) { - "Waiting" - } else { - "Load" - }, - ); - self.ui - .label(cx, ids!(ea_blurb_label)) - .set_text(cx, ea_module.blurb); - self.ui - .label(cx, ids!(ea_license_label)) - .set_text(cx, ea_module.license); - self.ui - .label(cx, ids!(ea_status_label)) - .set_text(cx, &ea_card.status_line(self.store.connected())); - self.ui - .view(cx, ids!(ea_progress)) - .set_uniform(cx, live_id!(progress), &[ea_card.progress_fraction()]); - self.ui - .button(cx, ids!(ea_cancel_btn)) - .set_visible(cx, self.import_queue.has_job(&ea_job)); let q2_job = ImportJob::Quake2 { path: String::new() }; self.ui.button(cx, ids!(quake2_import_btn)).set_text( cx, @@ -11780,15 +11470,6 @@ impl App { crate::import::ImportPhase::Failed { .. } ), ), - ImportJob::EaClassic { source, .. } => { - let card = self.classic_import_page.card(*source); - ( - active.job.title(), - card.status_line(self.store.connected()), - card.progress_fraction(), - matches!(card.phase, crate::import::ImportPhase::Failed { .. }), - ) - } ImportJob::Quake2 { .. } => ( active.job.title(), self.classic_import_page @@ -11842,9 +11523,6 @@ impl App { progress, failed, cancel: Some(RowAction::StopImport), - detail: String::new(), - expand: None, - copy: None, }); } for item in &self.import_queue.pending { @@ -11865,9 +11543,6 @@ impl App { progress: queue.progress_fraction(), failed: false, cancel: None, - detail: String::new(), - expand: None, - copy: None, }); } } @@ -12004,10 +11679,6 @@ impl App { .classic_import_page .duke3d .start_import(cx, path.clone(), server), - ImportJob::EaClassic { source, path } => self - .classic_import_page - .card_mut(*source) - .start_import(cx, path.clone(), server), ImportJob::Quake2 { path } => self .classic_import_page .quake2 @@ -12064,9 +11735,6 @@ impl App { Some(ImportJob::Duke3d { .. }) => { self.classic_import_page.duke3d.request_stop(cx); } - Some(ImportJob::EaClassic { source, .. }) => { - self.classic_import_page.card_mut(*source).request_stop(cx); - } Some(ImportJob::Quake2 { .. }) => { self.classic_import_page.quake2.request_stop(cx); } @@ -12147,7 +11815,6 @@ impl App { } } - fn import_server_session(&self) -> Option { let from_session = (|| { let endpoints = self.store.endpoints?; @@ -13103,18 +12770,10 @@ impl MatchEvent for App { self.refresh_import_ui(cx); } } - if self - .ui - .combo_box(cx, ids!(ea_pack_drop)) - .changed(actions) - .is_some() - && self.surface == Surface::Import - { - self.refresh_import_ui(cx); - } if self.ui.button(cx, ids!(kenney_import_btn)).clicked(actions) { let (pack, _) = self.import_page.selected_pack_id(); - self.open_kenney_donate_modal( + log!("import: queue Kenney {pack}"); + self.enqueue_import( cx, ImportJob::Kenney { pack, @@ -13124,30 +12783,8 @@ impl MatchEvent for App { ); } if self.ui.button(cx, ids!(kenney_import_all_btn)).clicked(actions) { - self.open_kenney_donate_modal(cx, ImportJob::KenneyAll); - } - let donate_modal = self.ui.modal(cx, ids!(kenney_donate_modal)); - if self.ui.button(cx, ids!(kenney_donate_ok)).clicked(actions) { - donate_modal.close(cx); - match self.kenney_donate_pending.take() { - Some(ImportJob::Kenney { pack, pack_index, path }) => { - log!("import: queue Kenney {pack}"); - self.enqueue_import(cx, ImportJob::Kenney { pack, pack_index, path }); - } - Some(job) => { - log!("import: queue Kenney all"); - self.enqueue_import(cx, job); - } - None => {} - } - } - if self.ui.button(cx, ids!(kenney_donate_cancel)).clicked(actions) - || donate_modal.dismissed(actions) - { - if self.kenney_donate_pending.take().is_some() { - log!("import: Kenney load cancelled at the donate prompt"); - } - donate_modal.close(cx); + log!("import: queue Kenney all"); + self.enqueue_import(cx, ImportJob::KenneyAll); } if self.ui.button(cx, ids!(queue_clear_btn)).clicked(actions) { self.import_queue.clear_pending(); @@ -13200,36 +12837,6 @@ impl MatchEvent for App { }, ); } - if self.ui.button(cx, ids!(ea_import_btn)).clicked(actions) { - let source = self.selected_ea_source(cx); - log!("import: queue {}", source.title()); - self.enqueue_import( - cx, - ImportJob::EaClassic { - source, - path: String::new(), - }, - ); - } - if self.ui.button(cx, ids!(ea_cancel_btn)).clicked(actions) { - let source = self.selected_ea_source(cx); - let job = ImportJob::EaClassic { - source, - path: String::new(), - }; - if self.import_queue.is_active(&job) { - self.stop_active_import(cx); - } else if let Some(id) = self - .import_queue - .pending - .iter() - .find(|item| item.job.conflicts(&job)) - .map(|item| item.id) - { - self.import_queue.remove(id); - self.refresh_import_ui(cx); - } - } if self.ui.button(cx, ids!(quake2_import_btn)).clicked(actions) { log!("import: queue Quake II shareware"); self.enqueue_import( @@ -13322,9 +12929,7 @@ impl MatchEvent for App { FileDialogAction::FolderCancelled => { self.music_import_page.picking = false; } - // This screen drives folder selection only; the platform's - // file and save panels answer elsewhere. - _ => {} + FileDialogAction::None => {} } if self.surface == Surface::Import { self.refresh_import_ui(cx); @@ -13380,7 +12985,7 @@ impl MatchEvent for App { // Tool chips in the chat expand/collapse on click. self.ui .widget(cx, ids!(chat_list)) - .borrow_mut::() + .borrow_mut::() .map(|mut list| list.handle_actions(cx, actions)); // Library filters re-run on every keystroke / dropdown pick and go // straight onto the server query. @@ -13587,21 +13192,6 @@ impl MatchEvent for App { self.enqueue_analysis(cx, vec![(analysis::BakeTarget::Asset(asset), title)], lyrics); } } - // "Reveal file": the selected track's materialised payload, selected - // in Finder — from there it drags into a chat app or a DAW. - if self.ui.button(cx, ids!(detail_reveal_btn)).clicked(actions) { - if let Some(asset) = self.store.selected { - let file = store_file_id(&asset); - match self.catalog_work.get(&file).and_then(|item| item.payload.clone()) { - Some(path) => { - let _ = std::process::Command::new("open").arg("-R").arg(&path).spawn(); - } - None => { - log!("library: no materialised file yet for {file} — select it and let the preview load, then reveal"); - } - } - } - } if self .ui .check_box(cx, ids!(detail_analyse_lyrics)) @@ -13677,108 +13267,17 @@ impl MatchEvent for App { self.store.gc_collect(retain); self.refresh_gc_ui(cx); } - // The header chip opens the panel; the panel is the same cards. - if self.ui.button(cx, ids!(runs_chip)).clicked(actions) { - if self.runs_chip.panel_open { - self.close_runs_panel(cx); - } else { - self.open_runs_panel(cx); - } - } - if self.ui.button(cx, ids!(runs_panel_close)).clicked(actions) - || self.ui.modal(cx, ids!(runs_panel_modal)).dismissed(actions) - { - self.close_runs_panel(cx); - } - if self.ui.button(cx, ids!(runs_cancel_all)).clicked(actions) { - // Everything still owed, in one gesture. Each key goes to - // whichever engine holds it; nothing is left orphaned, and a - // finished run is left alone. - let keys: Vec = self - .run_cards() - .iter() - .filter(|card| card.can_cancel) - .map(|card| card.key.clone()) - .collect(); - log!("runs: cancel all — {} still owed", keys.len()); - for key in keys { - self.cancel_card(cx, &key); - } - } - // Run cards, in the panel's list and in the Create surface's slots: - // ONE grammar means one handler. - let mut card_action: Option = None; - let panel_widget = self.ui.widget(cx, ids!(runs_panel_list)); - let panel_portal = panel_widget.portal_list(cx, ids!(list)); - for (row_id, item) in panel_portal.items_with_actions(actions) { - let Some(StoreRow::Card(card)) = panel_widget - .borrow::() - .and_then(|panel| panel.row_at(row_id)) - else { - continue; - }; - if let Some(action) = self.card_press(cx, &item, &card, actions) { - card_action = Some(action); - break; - } - } - if card_action.is_none() { - let cards = self.run_cards(); - for (index, slot) in [ids!(rc0), ids!(rc1), ids!(rc2), ids!(rc3)] - .iter() - .enumerate() - { - let Some(card) = cards.get(index) else { break }; - let item = self.ui.widget(cx, *slot); - if let Some(action) = self.card_press(cx, &item, card, actions) { - card_action = Some(action); - break; - } - } - } - match card_action { - Some(RowAction::CancelCard(key)) => self.cancel_card(cx, &key), - Some(RowAction::ToggleCard(key)) => { - self.runs_chip.toggle_open(&key); - self.refresh_run_ui(cx); - } - Some(RowAction::CopyCard(key)) => { - // The fold is long by design; the clipboard is how it leaves. - if let Some(card) = self.run_cards().iter().find(|card| card.key == key) { - cx.copy_to_clipboard(&card.fold); - log!("runs: the whole run copied to the clipboard"); - } - } - Some(RowAction::PromoteCard(crate::runs_chip::CardKey::LocalQueued(index))) => { - self.move_row_up(cx, index) - } - _ => {} - } // Runs list: cancel the active stage / drop a queued run. let runs_widget = self.ui.widget(cx, ids!(runs_list)); let runs_portal = runs_widget.portal_list(cx, ids!(list)); let mut runs_action = None; - let mut runs_open: Option = None; - let mut runs_copy: Option = None; for (row_id, item) in runs_portal.items_with_actions(actions) { - let row = || { - runs_widget - .borrow::() - .and_then(|panel| panel.row_at(row_id)) - }; if item.button(cx, ids!(stage_cancel)).clicked(actions) || item.button(cx, ids!(queued_cancel)).clicked(actions) { - runs_action = row(); - break; - } - // Pressing the stage's name opens it: what went into the model. - if item.button(cx, ids!(stage_title)).clicked(actions) { - runs_open = row(); - break; - } - if item.button(cx, ids!(stage_copy)).clicked(actions) { - runs_copy = row(); + runs_action = runs_widget + .borrow::() + .and_then(|panel| panel.row_at(row_id)); break; } } @@ -13793,25 +13292,6 @@ impl MatchEvent for App { }) => self.cancel_row(cx, index), _ => {} } - if let Some(StoreRow::Stage { - expand: Some(RowAction::ToggleStage(run_id, index)), - .. - }) = runs_open - { - let key = (run_id, index); - match self.open_stages.iter().position(|open| *open == key) { - Some(at) => { - self.open_stages.remove(at); - } - None => self.open_stages.push(key), - } - self.refresh_runs_panel(cx); - } - if let Some(StoreRow::Stage { detail, copy: Some(_), .. }) = runs_copy { - // The text is long by design; the clipboard is how it leaves. - cx.copy_to_clipboard(&detail); - log!("runs: stage input copied to the clipboard"); - } // Server catalog rows (only ever populated by a real transport). let server_widget = self.ui.widget(cx, ids!(lib_server_list)); let server_portal = server_widget.portal_list(cx, ids!(list)); @@ -13871,6 +13351,9 @@ impl MatchEvent for App { { self.start_generate(cx); } + if self.ui.button(cx, ids!(cancel_btn)).clicked(actions) { + self.cancel_active(cx); + } if self.ui.button(cx, ids!(alpha_btn)).clicked(actions) { self.alpha_view = !self.alpha_view; let v = if self.alpha_view { 1.0 } else { 0.0 }; @@ -14427,6 +13910,26 @@ impl MatchEvent for App { self.refresh_fleet_cards(cx); } } + // Run-queue rows: cancel / move up. + for (k, (cancel, up)) in [ + (ids!(q1_cancel), ids!(q1_up)), + (ids!(q2_cancel), ids!(q2_up)), + (ids!(q3_cancel), ids!(q3_up)), + (ids!(q4_cancel), ids!(q4_up)), + (ids!(q5_cancel), ids!(q5_up)), + (ids!(q6_cancel), ids!(q6_up)), + ] + .iter() + .enumerate() + { + if self.ui.button(cx, *cancel).clicked(actions) { + self.cancel_row(cx, k); + } + if self.ui.button(cx, *up).clicked(actions) { + self.move_row_up(cx, k); + } + } + let _ = QUEUE_ROWS; } } @@ -14446,7 +13949,7 @@ impl AppMain for App { crate::thumbnail_renderer::script_mod(vm); // The shared chat pane (also the sandbox's): transcript list, // tool chips, think dots. - makepad_chat_ui::script_mod(vm); + makepad_asset_chat_ui::script_mod(vm); self::script_mod(vm) } @@ -14557,11 +14060,6 @@ impl AppMain for App { self.adopt_catalog_asset(cx, asset, false); } } - // A declared run just ended, said so by the server on the - // event feed. That is the ONE end-of-run signal — a publish is - // per-asset and coincidental, and a failed run publishes - // nothing at all — so the card settles on the event rather - // than on the next tick of a poll clock. self.maybe_open_gc_confirm(cx); let kenney_poll = self.import_page.poll(); let classic_poll = self.classic_import_page.poll(); @@ -14635,7 +14133,7 @@ impl AppMain for App { } if classic_poll { log!( - "import classic: freedoom={} librequake={} duke3d={} cnc={} ra={} ts={} d2k={} quake2={} quake3={}", + "import classic: freedoom={} librequake={} duke3d={} quake2={} quake3={}", self.classic_import_page .freedoom .status_line(self.store.connected()), @@ -14645,10 +14143,6 @@ impl AppMain for App { self.classic_import_page .duke3d .status_line(self.store.connected()), - self.classic_import_page.cnc.status_line(self.store.connected()), - self.classic_import_page.ra.status_line(self.store.connected()), - self.classic_import_page.ts.status_line(self.store.connected()), - self.classic_import_page.d2k.status_line(self.store.connected()), self.classic_import_page .quake2 .status_line(self.store.connected()), @@ -14757,7 +14251,7 @@ impl AppMain for App { } // Live elapsed timers while any run is active. if self.any_run_running() { - self.refresh_run_ui(cx); + self.refresh_stages(cx); if self.surface == Surface::Runs { self.refresh_runs_panel(cx); } @@ -14832,29 +14326,12 @@ impl AppMain for App { } let job = match lower.as_str() { "duke3d" | "duke" => Some(ImportJob::Duke3d { path: empty() }), - "cnc" | "tiberian" | "td" => Some(ImportJob::EaClassic { - source: ClassicSource::Cnc, - path: empty(), - }), - "ra" | "redalert" => Some(ImportJob::EaClassic { - source: ClassicSource::RedAlert, - path: empty(), - }), - "ts" | "tiberiansun" => Some(ImportJob::EaClassic { - source: ClassicSource::TiberianSun, - path: empty(), - }), - "d2k" | "dune" => Some(ImportJob::EaClassic { - source: ClassicSource::Dune2000, - path: empty(), - }), "quake3" | "quakeiii" | "q3" => Some(ImportJob::Quake3 { path: empty() }), "quake2" | "q2" => Some(ImportJob::Quake2 { path: empty() }), "quake" | "q1" => Some(ImportJob::Quake { path: empty() }), "doom" => Some(ImportJob::Doom { path: empty() }), "freedoom" => Some(ImportJob::Freedoom { path: empty() }), "librequake" => Some(ImportJob::LibreQuake { path: empty() }), - "darkmod" => Some(ImportJob::DarkMod { path: empty() }), "kenney" | "kenney-all" => Some(ImportJob::KenneyAll), _ => None, }; @@ -15680,6 +15157,7 @@ mod search_box_tests { #[cfg(test)] mod library_view_tests { use super::*; + use makepad_asset_client::FacetKind; /// A changed filter is a changed RESULT SET, and both Library bodies go /// back to the top when it changes — a narrowed filter must not leave diff --git a/apps/asset-ui/src/mask_paint.rs b/apps/asset-ui/src/mask_paint.rs index 459d50c9e..698b54bca 100644 --- a/apps/asset-ui/src/mask_paint.rs +++ b/apps/asset-ui/src/mask_paint.rs @@ -221,7 +221,7 @@ impl MaskPaint { return None; } let rgba = bgra_to_rgba8(&self.canvas); - makepad_ai_hub::testpattern::encode_png_rgba(&rgba, self.width, self.height).ok() + makepad_asset_ai::testpattern::encode_png_rgba(&rgba, self.width, self.height).ok() } /// The mask as an opaque gray PNG (white = repaint). @@ -233,7 +233,7 @@ impl MaskPaint { for &m in &self.mask { rgba.extend_from_slice(&[m, m, m, 255]); } - makepad_ai_hub::testpattern::encode_png_rgba(&rgba, self.width, self.height).ok() + makepad_asset_ai::testpattern::encode_png_rgba(&rgba, self.width, self.height).ok() } fn ensure_textures(&mut self, cx: &mut Cx) { diff --git a/apps/asset-ui/src/mesh_view.rs b/apps/asset-ui/src/mesh_view.rs index b7445a7da..5cdab92f1 100644 --- a/apps/asset-ui/src/mesh_view.rs +++ b/apps/asset-ui/src/mesh_view.rs @@ -1020,8 +1020,6 @@ impl MeshView { self.extra_instances.push(ModelInstance { model: id, transform: trs_yaw(spec.pos, spec.yaw, 1.0), - tint: vec4(1.0, 1.0, 1.0, 1.0), - color_adjust: vec4(0.0, 1.0, 1.0, 0.0), dynamic: true, depth_order: 0.0, part_poses: Vec::new(), @@ -1495,8 +1493,6 @@ impl MeshView { self.instance = Some(ModelInstance { model: id, transform: Mat4f::identity(), - tint: vec4(1.0, 1.0, 1.0, 1.0), - color_adjust: vec4(0.0, 1.0, 1.0, 0.0), dynamic: true, depth_order: 0.0, part_poses: Vec::new(), @@ -1515,8 +1511,6 @@ impl MeshView { 0.35, scale, ), - tint: vec4(1.0, 1.0, 1.0, 1.0), - color_adjust: vec4(0.0, 1.0, 1.0, 0.0), // Realtime CSM only collects `dynamic` movers. dynamic: true, depth_order: 0.0, @@ -1920,8 +1914,6 @@ impl Widget for MeshView { pos: vec4(s.pos.x, s.pos.y, s.pos.z, self.orbit_yaw), size: vec4(s.width, s.height, 0.0, 0.0), uv: vec4(0.0, 0.0, 1.0, 1.0), - tint: vec4(1.0, 1.0, 1.0, 1.0), - color_adjust: vec4(0.0, 1.0, 1.0, 0.0), }) }) .collect(); diff --git a/apps/asset-ui/src/pbr_preview.rs b/apps/asset-ui/src/pbr_preview.rs index 570da0e0d..28b201f8c 100644 --- a/apps/asset-ui/src/pbr_preview.rs +++ b/apps/asset-ui/src/pbr_preview.rs @@ -772,7 +772,7 @@ pub fn studio_equirect_png() -> Vec { rgba[i + 3] = 255; } } - makepad_ai_hub::testpattern::encode_png_rgba(&rgba, W, H).expect("studio equirect encodes") + makepad_asset_ai::testpattern::encode_png_rgba(&rgba, W, H).expect("studio equirect encodes") } #[cfg(test)] diff --git a/apps/asset-ui/src/pipeline.rs b/apps/asset-ui/src/pipeline.rs index a3c07e0fc..e19489a50 100644 --- a/apps/asset-ui/src/pipeline.rs +++ b/apps/asset-ui/src/pipeline.rs @@ -9,14 +9,14 @@ //! - every fetched artifact also routes to the matching viewer. //! //! Box choice per stage = the fleet affinity scheduler -//! (`makepad_ai_hub::fleet`): loaded > ready > downloading > absent, +//! (`makepad_asset_ai::fleet`): loaded > ready > downloading > absent, //! tiebreak queue depth, evaluated at stage START (a chain's later stages //! see fresh snapshots). Text expansion has one deliberate policy layer: //! a ready Qwen3.8-27B outranks the smaller fallback, but an absent or still //! downloading 3.8 never displaces the already-ready qwen3.5-9b lane. -use makepad_ai_hub::fleet::{self, BoxSnapshot}; -use makepad_ai_hub::protocol::{ +use makepad_asset_ai::fleet::{self, BoxSnapshot}; +use makepad_asset_ai::protocol::{ ArtifactRefJson, GenerateRequestJson, GenerateResponseJson, JobStatusJson, NamedInputJson, LoraRefJson}; use makepad_micro_serde::{DeJson, SerJson}; use makepad_widgets::*; @@ -26,12 +26,44 @@ use std::collections::{HashMap, HashSet}; // Presets // --------------------------------------------------------------------------- -pub use makepad_asset_creator::presets::{ - EDIT_STRENGTHS, ENHANCE_FACTORS, EXPAND_FALLBACK_NOTE, IMAGE_SIZES, IMAGE_STEPS, - LORA_STRENGTHS, MESH_FACE_COUNTS, MESH_TEXTURE_SIZES, MUSIC_DEFAULT_SECONDS, - MUSIC_LENGTHS, MUSIC_MAX_SECONDS, MUSIC_MIN_SECONDS, VIDEO_INTERPOLATE, VIDEO_LENGTHS, - VIDEO_SIZES, -}; +/// Image canvas presets; entry 0 is the chain default. Flux wants /16 dims. +pub const IMAGE_SIZES: &[(u32, u32)] = &[ + (512, 512), + (768, 768), + (1024, 1024), + (768, 512), + (512, 768), + (1024, 576), +]; +/// Image step presets; the dropdown's extra first entry means "model +/// default" (schnell 4, dev-class ~20). +pub const IMAGE_STEPS: &[u32] = &[4, 8, 12, 20, 28, 50]; +/// img2img strength choices for edit chains: 1.0 = a full instruction edit +/// (reference tokens only); lower = the sampler starts from the VAE-encoded +/// input at sigma index floor((1-strength)*steps), keeping more of it. +pub const EDIT_STRENGTHS: &[f32] = &[1.0, 0.85, 0.7, 0.55, 0.4, 0.25]; +/// LoRA strength choices for the image stage. +pub const LORA_STRENGTHS: &[f32] = &[1.0, 0.8, 0.6, 0.4, 1.2]; +/// RIFE interpolation factors offered for video (1 = off). +pub const VIDEO_INTERPOLATE: &[u32] = &[1, 2, 4]; +/// Enhance-stage factor choices, shared by the uprez and tween pickers. +pub const ENHANCE_FACTORS: &[u32] = &[1, 2, 4]; +/// TRELLIS UV-atlas presets. 1024 preserves the current fast default; the +/// larger atlases trade bake time and device memory for sharper materials. +pub const MESH_TEXTURE_SIZES: &[u32] = &[1024, 2048, 4096]; +/// QEM face-count presets. Index 0 is Auto (12k objects / 20k characters). +pub const MESH_FACE_COUNTS: &[u32] = &[0, 12_000, 20_000, 40_000, 80_000, 160_000]; +/// Video canvas presets; entry 0 is the small default. +pub const VIDEO_SIZES: &[(u32, u32)] = &[(640, 352), (864, 480), (960, 544)]; +/// Video (frames, steps) presets at 16 fps; entry 0 is the default. +pub const VIDEO_LENGTHS: &[(u32, u32)] = &[(39, 30), (65, 30), (97, 40), (129, 50)]; +/// Full-song targets offered by the UI. Music3 accepts any duration from +/// five seconds through five minutes; these minute-aligned presets keep the +/// common choice legible and make a three-minute song the honest default. +pub const MUSIC_LENGTHS: &[u32] = &[60, 120, 180, 240, 300]; +pub const MUSIC_DEFAULT_SECONDS: u32 = 180; +pub const MUSIC_MIN_SECONDS: u32 = 5; +pub const MUSIC_MAX_SECONDS: u32 = 300; /// Human-facing clock label used by the duration picker and run details. pub fn format_music_duration(seconds: u32) -> String { @@ -116,12 +148,6 @@ pub struct GenParams { /// Enhance stage: append the motion-vector `mkfl` box for /// arbitrary-rate GPU playback. pub enhance_flow: bool, - /// Video stage: send the keyframe as BOTH the first frame (`input_b64`) - /// and the H3 wire's `last_frame` named input, so the FL2VA conditioning - /// lands the clip back on its opening image — a seamless loop (verified - /// on fasth3-4step: endpoint delta ≈ adjacent-frame noise). Derived from - /// the preset row at dispatch, never persisted in saved specs. - pub video_loop: bool, } impl Default for GenParams { @@ -144,7 +170,6 @@ impl Default for GenParams { enhance_upscale: 2, enhance_interpolate: 2, enhance_flow: true, - video_loop: false, } } } @@ -160,9 +185,6 @@ pub struct Preset { /// fan-out followed by an explicit human choice gate. The chosen /// artifact is the only output promoted into the linear chain. pub fan_out_stage: Option, - /// The video stage of this chain loops: its keyframe rides as first AND - /// last frame (see [`GenParams::video_loop`]). - pub video_loop: bool, } impl Preset { @@ -176,22 +198,6 @@ impl Preset { domains, pins, fan_out_stage: None, - video_loop: false, - } - } - - /// A linear chain whose video stage is a seamless loop. - const fn looped( - name: &'static str, - domains: &'static [&'static str], - pins: &'static [(&'static str, &'static str)], - ) -> Self { - Self { - name, - domains, - pins, - fan_out_stage: None, - video_loop: true, } } @@ -206,7 +212,6 @@ impl Preset { domains, pins, fan_out_stage: Some(stage), - video_loop: false, } } } @@ -230,18 +235,6 @@ const CHARACTER_RIG_MODEL: &str = "skintokens"; const CHARACTER_MOTION_MODEL: &str = "hy-motion"; /// Instruction image editing (reference image + "change …" prompt). const EDIT_MODEL: &str = "flux2-klein-4b"; -/// Sprite enhancement runs on the 32B dev DiT, NOT the 4-step distilled -/// klein that `EDIT_MODEL` pins for interactive edits. Measured on the Doom -/// imp hero frame (2026-08-31): klein 4-step renders a smoothed version of -/// the original; dev at 20-30 steps redraws it with real anatomy, claws and -/// teeth. The distillation, not the prompt, was the ceiling. -/// -/// `flux2-dev-q4-24g` is the same DiT quantized for the 24GB class. It is a -/// DIFFERENT numerics class ("expect its own look at the same seed"), so a -/// single asset must be enhanced entirely on one tier or its cells will not -/// match each other. -const SPRITE_ENHANCE_MODEL: &str = "flux2-dev"; -pub const SPRITE_ENHANCE_MODEL_24G: &str = "flux2-dev-q4-24g"; /// General image 4x upscaling (RealESRGAN x4plus). Pinned — the domain has /// exactly one model, so no dropdown. const UPSCALE_MODEL: &str = "realesrgan-x4plus"; @@ -317,8 +310,8 @@ fn pick_ready_model_target( model.available && matches!( model.state.as_str(), - makepad_ai_hub::protocol::MODEL_STATE_READY - | makepad_ai_hub::protocol::MODEL_STATE_LOADED + makepad_asset_ai::protocol::MODEL_STATE_READY + | makepad_asset_ai::protocol::MODEL_STATE_LOADED ) }) }) @@ -499,14 +492,6 @@ pub const PRESETS: &[Preset] = &[ &[("enhance", "video-enhance")], ), Preset::linear("edit selected image (instruction)", &["edit"], &[("edit", EDIT_MODEL)]), - // Classic sprite enhancement: re-render an old game's artwork at modern - // quality. Pinned to the 32B dev tier because the distilled klein only - // smooths (see SPRITE_ENHANCE_MODEL). - Preset::linear( - "sprite → enhance (hi-res)", - &["edit"], - &[("edit", SPRITE_ENHANCE_MODEL)], - ), // Native RealESRGAN x4plus: select a picture, get it back at 4x // resolution. Consumer-only like `edit` — no prompt-only mode, refused // without a selected image. @@ -604,8 +589,6 @@ pub const PRESETS: &[Preset] = &[ ("motion", CHARACTER_MOTION_MODEL), ], ), - Preset::looped("image → video loop", &["image", "video"], &[]), - Preset::looped("expand → image → video loop", &["text", "image", "video"], &[]), ]; /// Which upstream payload class a stage's request relays as its binary @@ -662,67 +645,6 @@ pub fn seed_replaces_prefix(domains: &[&str], seed_content_type: &str) -> Option /// Human-facing stage name. In particular, call the text stage what it is: /// a local model inference step, rather than making it look like string /// templating in the UI. -/// The parameters a submitted request carries besides its prompt, one -/// `key=value` per line — read off the request that is actually being sent, -/// so an opened run shows what the box got rather than what the UI meant. -/// The prompt is kept separately and in full; an input payload is named by -/// size and type, never by its base64. -pub fn sent_params(request: &GenerateRequestJson) -> String { - let mut lines: Vec = vec![format!("model={}", request.model)]; - let mut put = |key: &str, value: String| lines.push(format!("{key}={value}")); - if let Some(text) = &request.negative_prompt { - put("negative_prompt", text.clone()); - } - if let Some(text) = &request.lyrics { - put("lyrics", text.clone()); - } - if let Some(text) = &request.text { - put("text", text.clone()); - } - if let Some(voice) = &request.voice { - put("voice", voice.clone()); - } - for (key, value) in [ - ("width", request.width), - ("height", request.height), - ("steps", request.steps), - ("frames", request.frames), - ("interpolate", request.interpolate), - ("upscale", request.upscale), - ("max_tokens", request.max_tokens), - ] { - if let Some(value) = value { - put(key, value.to_string()); - } - } - if let Some(seed) = request.seed { - put("seed", seed.to_string()); - } - for (key, value) in [ - ("guidance", request.guidance), - ("seconds", request.seconds), - ("speed", request.speed), - ] { - if let Some(value) = value { - put(key, format!("{value}")); - } - } - if let Some(strength) = request.strength { - put("strength", format!("{strength}")); - } - if let Some(bytes) = &request.input_b64 { - put( - "input", - format!( - "{} b64 chars {}", - bytes.len(), - request.input_content_type.as_deref().unwrap_or("?") - ), - ); - } - lines.join("\n") -} - pub fn stage_display_name(domain: &str) -> &str { match domain { "text" => "LLM prompt expansion", @@ -875,16 +797,6 @@ pub struct StageRun { /// always have one; other pipelines retain the backend's existing seed /// behavior. pub seed: Option, - /// THE TEXT THIS STAGE ACTUALLY SENT, captured at submit, in full. - /// - /// Not `Pipeline::prompt`: an expansion stage rewrites what the next - /// model sees, a music stage carries its lyrics, and a character chain - /// composes its own brief — so the only honest answer to "what did the - /// model get?" is the string that went on the wire. Kept so the run can - /// be opened and read. - pub sent_prompt: String, - /// The parameters that went with it, `key=value` per line. - pub sent_params: String, pub started: Option, pub finished: Option, /// Fetched artifacts: (content_type, bytes). @@ -1037,8 +949,6 @@ impl Pipeline { reason: String::new(), service_state: String::new(), seed: None, - sent_prompt: String::new(), - sent_params: String::new(), started: None, finished: None, outputs: Vec::new(), @@ -1182,17 +1092,6 @@ impl Pipeline { /// Service job ids this run has in flight on `base_url` (linear stage /// + fan-out candidates) — lets the fleet panel tell "ours" from other /// clients' jobs. - /// The text a job of this pipeline was handed, by the box's own job id. - /// Lets a fleet-box view show WHAT a job it is running was asked for — - /// the box itself never reports the prompt back. - pub fn sent_prompt_for_job(&self, job_id: &str) -> Option<&str> { - self.stages - .iter() - .find(|stage| stage.job_id == job_id) - .map(|stage| stage.sent_prompt.as_str()) - .filter(|text| !text.is_empty()) - } - pub fn job_ids_on(&self, base_url: &str) -> Vec { let mut ids: Vec = self .candidate_sets @@ -1298,35 +1197,27 @@ impl Pipeline { /// identity anchor supplied on its request: `yoshi` can be elaborated, /// never replaced. fn prompt_for_stage(&self, stage: usize) -> Result { - for (index, earlier) in self.stages[..stage].iter().enumerate().rev() { + for earlier in self.stages[..stage].iter().rev() { if earlier.domain == "text" { - // An expansion that came back with nothing usable is a - // missing improvement, not a missing input: the person's own - // prompt still says what they want. A CHARACTER chain is the - // exception — its later stages are gated on the brief, so - // there the refusal stands (see `expander_is_optional`). - let optional = self.expander_is_optional(index); - let unusable = |reason: &str| -> Result { - if optional { - log!("pipeline: {EXPAND_FALLBACK_NOTE} ({reason})"); - Ok(self.prompt.clone()) - } else { - Err(format!("LLM prompt expansion {reason}; refusing terse-prompt fallback")) - } - }; let Some((_, bytes)) = earlier .outputs .iter() .find(|(ct, _)| ct.starts_with("text/plain")) else { - return unusable("produced no text/plain artifact"); - }; - let Ok(text) = std::str::from_utf8(bytes) else { - return unusable("artifact is not UTF-8"); + return Err( + "LLM prompt expansion produced no text/plain artifact; refusing terse-prompt fallback" + .to_string(), + ); }; + let text = std::str::from_utf8(bytes).map_err(|_| { + "LLM prompt expansion artifact is not UTF-8; refusing terse-prompt fallback" + .to_string() + })?; let text = text.trim(); if text.is_empty() { - return unusable("was empty"); + return Err( + "LLM prompt expansion was empty; refusing terse-prompt fallback".to_string(), + ); } if self.is_character_pipeline() { let words = text.split_whitespace().count(); @@ -1604,7 +1495,7 @@ impl Pipeline { // Music3 can stop earlier when it emits its end-of-audio token. "music" => { let (description, lyrics) = - makepad_ai_hub::music3_backend::split_music_prompt(&prompt); + makepad_asset_ai::music3_backend::split_music_prompt(&prompt); // An expansion stage promises the template's `Lyrics:` // section (instrumental requests still carry it, holding // only [Instrumental]). Its absence means the expander @@ -1705,20 +1596,6 @@ impl Pipeline { request.input_b64 = Some(b64); request.input_content_type = Some(content_type); } - // Loop chains: the SAME keyframe rides as both the first frame - // (input_b64, above) and the H3 wire's `last_frame` named input, so - // the clip ends where it began. No keyframe = a hard error, never a - // silent non-looping clip. - if domain == "video" && self.gen.video_loop { - let (b64, content_type) = self.input_for_stage(stage).ok_or_else(|| { - "video loop needs a keyframe image from an earlier stage or seed".to_string() - })?; - request.inputs = Some(vec![NamedInputJson { - name: "last_frame".to_string(), - content_type, - data_b64: b64, - }]); - } if domain == "inpaint" { let (b64, content_type) = self .input_for_stage(stage) @@ -2279,11 +2156,6 @@ impl Pipeline { Ok(request) => request, Err(error) => return self.fail_stage(stage, error, events), }; - // Remember what is about to go on the wire, before it goes: this is - // what an opened run shows, and it is the only place the composed - // text still exists as one string. - self.stages[stage].sent_prompt = request_json.prompt.clone().unwrap_or_default(); - self.stages[stage].sent_params = sent_params(&request_json); let url = format!("{}/generate", self.stages[stage].box_url); let mut request = crate::http::request(url, HttpMethod::POST); request.set_header("Content-Type".to_string(), "application/json".to_string()); @@ -2453,54 +2325,10 @@ impl Pipeline { if let Some(retry_stage) = self.prepare_character_mesh_retry(stage, &error) { events.push(PipelineEvent::Changed); events.extend(self.start_stage(cx, retry_stage, snapshots, avoid)); - return events; + events + } else { + self.fail_stage(stage, error, events) } - self.fail_stage_or_skip_expander(cx, stage, error, snapshots, avoid, events) - } - - /// Is `stage` an expansion the run can do WITHOUT? - /// - /// A `text` stage in front of other stages is a rewording courtesy: its - /// product is a better prompt, and the person already supplied a - /// perfectly usable one. Two cases are NOT optional and stay hard - /// failures: a chain whose only stage is the expansion (the text IS the - /// product), and a character chain, whose later stages are gated on the - /// brief keeping the named identity — see `prompt_for_stage`. - fn expander_is_optional(&self, stage: usize) -> bool { - self.stages[stage].domain == "text" - && stage + 1 < self.stages.len() - && !self.is_character_pipeline() - } - - /// End the run — UNLESS the stage that failed was an optional expander, - /// in which case the run carries on from the prompt the person typed. - /// - /// A lost expansion used to lose the whole run: the text box hiccuped - /// (busy, evicted, timed out) and a queued video that had nothing to do - /// with the expander simply never happened, with the reason buried in a - /// failed stage nobody was looking at. - fn fail_stage_or_skip_expander( - &mut self, - cx: &mut Cx, - stage: usize, - error: String, - snapshots: &[BoxSnapshot], - avoid: &[String], - mut events: Vec, - ) -> Vec { - if !self.expander_is_optional(stage) { - return self.fail_stage(stage, error, events); - } - log!("pipeline: {EXPAND_FALLBACK_NOTE} ({error})"); - self.stages[stage].state = - StageState::Failed(format!("{EXPAND_FALLBACK_NOTE} ({error})")); - self.stages[stage].detail = EXPAND_FALLBACK_NOTE.to_string(); - self.stages[stage].finished = Some(std::time::Instant::now()); - self.stages[stage].progress = 0.0; - events.push(PipelineEvent::StageFailed { stage }); - events.push(PipelineEvent::Changed); - events.extend(self.start_stage(cx, stage + 1, snapshots, avoid)); - events } /// Issue the next /job poll if the current stage is waiting on one. @@ -3394,7 +3222,7 @@ impl Pipeline { ); }; if let Err(error) = - makepad_ai_hub::client::verify_artifact_bytes(&bytes, &artifact) + makepad_asset_ai::client::verify_artifact_bytes(&bytes, &artifact) { return self.candidate_failed( cx, @@ -3514,8 +3342,7 @@ impl Pipeline { if Self::is_vram_admission_error(&message) { return self.wait_after_vram_rejection(stage, &message, events); } - return self - .fail_stage_or_skip_expander(cx, stage, message, snapshots, avoid, events); + return self.fail_stage(stage, message, events); } } } @@ -3525,12 +3352,9 @@ impl Pipeline { .and_then(|r| r.get_string_body()) .is_some_and(|body| body.contains("no such job")) { - return self.fail_stage_or_skip_expander( - cx, + return self.fail_stage( stage, "box lost the job (service restarted or the job expired)".to_string(), - snapshots, - avoid, events, ); } @@ -3591,12 +3415,9 @@ impl Pipeline { .filter(|r| !failed && r.status_code == 200) .and_then(|r| r.body.clone()); let Some(bytes) = bytes else { - return self.fail_stage_or_skip_expander( - cx, + return self.fail_stage( stage, format!("artifact {} fetch failed", artifact.id), - snapshots, - avoid, events, ); }; @@ -3821,22 +3642,6 @@ impl Pipeline { state, elapsed )); - // WHAT THIS STAGE WAS HANDED, in full, indented under it. The - // music stage's prompt carries its lyrics and a video brief is a - // paragraph, so this is the one place the composed text can - // actually be read — the panel it lives in scrolls. - if !stage.sent_prompt.is_empty() { - out.push_str(" prompt sent:\n"); - for line in stage.sent_prompt.lines() { - out.push_str(&format!(" {line}\n")); - } - } - if !stage.sent_params.is_empty() { - out.push_str(" params:\n"); - for line in stage.sent_params.lines() { - out.push_str(&format!(" {line}\n")); - } - } } out } @@ -3947,10 +3752,10 @@ mod tests { } fn image_snapshot(url: &str, node_key: &str) -> BoxSnapshot { - use makepad_ai_hub::protocol::{HealthJson, ModelInfoJson, MODEL_STATE_LOADED}; + use makepad_asset_ai::protocol::{HealthJson, ModelInfoJson, MODEL_STATE_LOADED}; BoxSnapshot { base_url: url.to_string(), - health: Some(HealthJson { realtime: None, + health: Some(HealthJson { service: "test".to_string(), version: "1".to_string(), gpu: Some("GPU".to_string()), @@ -3992,10 +3797,10 @@ mod tests { } fn text_snapshot(url: &str, models: &[(&str, &str)]) -> BoxSnapshot { - use makepad_ai_hub::protocol::{HealthJson, ModelInfoJson, MODEL_STATE_LOADED}; + use makepad_asset_ai::protocol::{HealthJson, ModelInfoJson, MODEL_STATE_LOADED}; BoxSnapshot { base_url: url.to_string(), - health: Some(HealthJson { realtime: None, + health: Some(HealthJson { service: "test".to_string(), version: "1".to_string(), gpu: Some("24 GB GPU".to_string()), @@ -4049,7 +3854,7 @@ mod tests { #[test] fn qwen38_expand_preference_is_ready_gated_and_falls_back() { - use makepad_ai_hub::protocol::{ + use makepad_asset_ai::protocol::{ MODEL_STATE_ABSENT, MODEL_STATE_DOWNLOADING, MODEL_STATE_LOADED, MODEL_STATE_READY, }; @@ -4369,78 +4174,6 @@ mod tests { })); } - /// AN EXPANSION CAN NEVER LOSE A RUN. The expander is a rewording - /// courtesy; when it comes back with nothing the video still gets made, - /// from the words the person typed. - #[test] - fn a_useless_expansion_falls_back_to_the_prompt_the_person_typed() { - let mut pipeline = Pipeline::new( - "scanning electron microscope art", - &["text", "video"], - &[], - vec![], - None, - None, - GenParams::default(), - ); - assert!(pipeline.expander_is_optional(0)); - - // Answered with nothing at all. - assert_eq!( - pipeline.request_for_stage(1).unwrap().prompt.as_deref(), - Some("scanning electron microscope art") - ); - // Answered with whitespace. - put_output(&mut pipeline, 0, "text/plain; charset=utf-8", b" \n "); - assert_eq!( - pipeline.request_for_stage(1).unwrap().prompt.as_deref(), - Some("scanning electron microscope art") - ); - // A real expansion is still what wins when there is one. - pipeline.stages[0].outputs.clear(); - put_output( - &mut pipeline, - 0, - "text/plain; charset=utf-8", - b"a false-colour scanning electron micrograph of a pollen grain", - ); - assert_eq!( - pipeline.request_for_stage(1).unwrap().prompt.as_deref(), - Some("a false-colour scanning electron micrograph of a pollen grain") - ); - } - - /// The two chains where the expansion is NOT optional keep refusing: a - /// text-only run has no other product, and a character chain's later - /// stages are gated on the brief holding the named identity. - #[test] - fn an_expansion_that_is_the_product_still_refuses_to_be_skipped() { - let text_only = Pipeline::new( - "scanning electron microscope art", - &["text"], - &[], - vec![], - None, - None, - GenParams::default(), - ); - assert!(!text_only.expander_is_optional(0)); - - let mut character = Pipeline::new( - "Boba Fett", - &["text", "image", "mesh", "rig", "motion"], - &[], - vec![], - None, - None, - GenParams::default(), - ); - assert!(character.is_character_pipeline()); - assert!(!character.expander_is_optional(0)); - put_output(&mut character, 0, "text/plain; charset=utf-8", b" "); - assert!(character.request_for_stage(1).is_err()); - } - #[test] fn expanded_music_routes_description_and_lyrics_to_distinct_fields() { let mut pipeline = Pipeline::new( @@ -4900,13 +4633,13 @@ Arrangement: Pulsing bass, gated drums and widening analog pads." .contains("dropped identity anchor")); } - fn registry() -> makepad_ai_hub::registry::Registry { + fn registry() -> makepad_asset_ai::registry::Registry { let text = std::fs::read_to_string(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../libs/ai/hub/registry.json" + "/../../libs/asset/ai/registry.json" )) .expect("registry.json readable"); - makepad_ai_hub::registry::Registry::parse(&text).expect("registry parses") + makepad_asset_ai::registry::Registry::parse(&text).expect("registry parses") } #[test] @@ -4984,9 +4717,6 @@ Arrangement: Pulsing bass, gated drums and widening analog pads." ("speech", "kokoro"), ("text", "qwen3.8-27b"), ("upscale", "realesrgan-x4plus"), - ("vision", "qwen3.8-27b-vision"), - ("video", "fasth3-4step"), - ("video", "fasth3-4step-q4-24g"), ("video", "minimax-h3"), ("video", "minimax-h3-bf16-96g"), ("video", "minimax-h3-nvfp4-32g"), diff --git a/apps/asset-ui/src/runs_chip.rs b/apps/asset-ui/src/runs_chip.rs deleted file mode 100644 index eda4dc55d..000000000 --- a/apps/asset-ui/src/runs_chip.rs +++ /dev/null @@ -1,531 +0,0 @@ -//! The RUNS chip, the panel behind it, and the ONE card every spawned unit -//! of work is drawn as. -//! -//! All generation runs in THIS app now (aicore §9): the engine in -//! `pipeline.rs` talks to fleet boxes directly, and the store no longer has -//! a queue to poll. So this module owns [`RunCard`] — the card grammar of -//! F1 §5.7 (a title row, ONE aggregate bar, one compact stage strip, and a -//! fold holding the whole truth: sent prompts, params, box tags, errors) — -//! built from the local engine's runs and its waiting queue. -//! -//! The bar is never computed here: [`aggregate_permille`] is the client -//! crate's one implementation. What IS held here is the per-card high-water -//! mark — a stage retry legitimately re-starts one stage's bar, and a bar -//! that goes backwards reads as a bug even when it is honest. - -use makepad_asset_client::{aggregate_permille, default_stage_weight}; -use std::collections::HashMap; - -use crate::pipeline::{format_clock, stage_display_name, Pipeline, StageState}; - -/// Longest prompt excerpt a card title carries. -const EXCERPT: usize = 60; - -// --------------------------------------------------------------------------- -// The card grammar -// --------------------------------------------------------------------------- - -/// Which spawned unit a card is; the panel's fold/cancel state keys on it. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum CardKey { - /// A run of this app's own engine, by run id. - Local(u64), - /// A run waiting in this app's queue, by queue position. - LocalQueued(usize), -} - -impl CardKey { - pub fn as_text(&self) -> String { - match self { - Self::Local(id) => format!("local:{id}"), - Self::LocalQueued(index) => format!("queued:{index}"), - } - } -} - -/// The five states a spawned unit reads as, in the order a list sorts them. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CardState { - Running, - Queued, - Done, - Failed, - Cancelled, -} - -impl CardState { - /// Running work first, then what is waiting, then what is over. - fn rank(self) -> u8 { - match self { - Self::Running => 0, - Self::Queued => 1, - Self::Done | Self::Failed | Self::Cancelled => 2, - } - } - - pub fn is_terminal(self) -> bool { - matches!(self, Self::Done | Self::Failed | Self::Cancelled) - } - - /// The state dot's colour. ONE accent (#3d9bf0) and it belongs to the - /// thing that is alive; everything else is grey, and only a failure - /// earns red. The state WORD is always on the card too (row 2), so the - /// dot is never the only signal. - pub fn dot(self) -> [f32; 4] { - match self { - Self::Running => [0.239, 0.608, 0.941, 1.0], - Self::Queued => [0.29, 0.32, 0.36, 1.0], - Self::Done => [0.35, 0.42, 0.48, 1.0], - Self::Failed => [0.851, 0.345, 0.310, 1.0], - Self::Cancelled => [0.29, 0.32, 0.36, 1.0], - } - } - - /// The one bar's fill. Same hue family as the dot, for the same reason. - pub fn fill(self) -> [f32; 4] { - match self { - Self::Running => [0.239, 0.608, 0.941, 1.0], - Self::Queued => [0.20, 0.28, 0.36, 1.0], - Self::Done => [0.173, 0.373, 0.533, 1.0], - Self::Failed => [0.851, 0.345, 0.310, 1.0], - Self::Cancelled => [0.29, 0.32, 0.36, 1.0], - } - } -} - -/// One chip of the stage strip. No bar — row 2 is the only bar on the card. -#[derive(Clone, Debug, PartialEq)] -pub struct StageChip { - /// `expand · 15s`, `music · 62%`, `publish`. - pub text: String, - pub tone: StageTone, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum StageTone { - Pending, - Running, - Done, - Failed, - /// An `on_fail: skip` expander that failed — the run went on with the - /// raw prompt, and that is a fact worth its own colour. - Skipped, -} - -impl StageTone { - pub fn color(self) -> [f32; 4] { - match self { - Self::Pending => [0.35, 0.38, 0.42, 1.0], - Self::Running => [0.239, 0.608, 0.941, 1.0], - Self::Done => [0.60, 0.64, 0.69, 1.0], - Self::Failed => [0.851, 0.345, 0.310, 1.0], - Self::Skipped => [0.85, 0.65, 0.35, 1.0], - } - } -} - -/// ONE spawned unit of work, however it was spawned. F1 §5.7 verbatim: -/// row 1 title, row 2 the only bar, row 3 the stage strip, row 4 the fold. -#[derive(Clone, Debug, PartialEq)] -pub struct RunCard { - pub key: CardKey, - pub state: CardState, - /// Where this ran: the store, or this app's own engine. - pub origin: &'static str, - /// The preset / pipeline label. - pub label: String, - /// The words the PERSON typed, quoted and truncated. Never the - /// expanded prompt — that lives in the fold. - pub excerpt: String, - /// `m:ss`, live while running, frozen when terminal. This REPLACES the - /// "Done in 538.3s" banner. - pub elapsed: String, - /// The aggregate, 0..=1000, already high-water clamped. - pub permille: u16, - /// The humanized current-stage word, or the failure reason. - pub status: String, - /// Empty for a single-stage unit — a heading with nothing under it does - /// not render, and neither does a strip of one. - pub stages: Vec, - /// The whole truth, only when the card is open. - pub fold: String, - /// Whether the × is offered (pending / running / publishing). - pub can_cancel: bool, - /// Whether this queued LOCAL run can still be moved up the queue. - pub can_promote: bool, - pub open: bool, - /// Newest first inside a state bucket. - created_ms: u64, -} - -impl RunCard { - pub fn percent(&self) -> u32 { - (self.permille as u32 + 5) / 10 - } - - pub fn fraction(&self) -> f32 { - (self.permille as f32 / 1000.0).clamp(0.0, 1.0) - } - - /// `RUNS · 3 running · 61%` needs to know which cards are still work. - pub fn is_active(&self) -> bool { - !self.state.is_terminal() - } -} - -/// A run of this app's own engine, lent to the card builder. -pub struct LocalRun<'a> { - pub id: u64, - pub label: &'a str, - pub prompt: &'a str, - pub created_ms: u64, - pub pipeline: &'a Pipeline, -} - -/// A run waiting in this app's queue — spawned, visible, cancellable, but -/// nothing has been sent yet. -pub struct LocalQueued<'a> { - pub index: usize, - pub label: &'a str, - pub prompt: &'a str, -} - - -// --------------------------------------------------------------------------- -// Text -// --------------------------------------------------------------------------- - -fn excerpt(prompt: &str) -> String { - let one_line = prompt.replace('\n', " "); - let trimmed = one_line.trim(); - if trimmed.is_empty() { - return String::new(); - } - format!("\u{201c}{}\u{201d}", crate::store_views::truncate(trimmed, EXCERPT)) -} - - -/// The job-kind spelling a local stage domain corresponds to, so the ONE -/// shared weight table (`default_stage_weight`) covers local runs too and a -/// local card's bar cannot mean something different from a store card's. -pub fn local_stage_kind(domain: &str) -> &'static str { - match domain { - "text" => "text.expand", - "image" => "image.generate", - "edit" => "image.edit", - "inpaint" => "image.inpaint", - "control" => "image.control", - "upscale" => "image.upscale", - "video" => "video.generate", - "enhance" => "video.enhance", - "music" | "speech" | "sfx" => "music.generate", - "mesh" | "splat" | "paint" | "rig" => "mesh.generate", - _ => "", - } -} - -fn local_stage_weight(domain: &str) -> u16 { - default_stage_weight(local_stage_kind(domain)) -} - -// --------------------------------------------------------------------------- -// Cards from this app's own engine -// --------------------------------------------------------------------------- - -/// The local engine's per-stage share of the bar, in the same 0..=1000 the -/// store speaks — so `aggregate_permille` can weigh them together. -/// -/// A FAILED stage keeps the fraction it died at. The old rendering filled it -/// to 1.0 and drew "100% · FAILED" beside it, which is the exact lie this -/// design exists to remove. -fn local_stage_permille(state: &StageState, progress: f64) -> u16 { - match state { - StageState::Done | StageState::AwaitingChoice => 1000, - _ => (progress.clamp(0.0, 1.0) * 1000.0) as u16, - } -} - -fn local_chip(pipeline: &Pipeline, index: usize) -> StageChip { - let stage = &pipeline.stages[index]; - let name = stage.domain.clone(); - let (tone, tail) = match &stage.state { - StageState::Waiting => (StageTone::Pending, String::new()), - StageState::Failed(_) => (StageTone::Failed, String::new()), - StageState::Done | StageState::AwaitingChoice => ( - StageTone::Done, - match (stage.started, stage.finished) { - (Some(t0), Some(t1)) => format!(" \u{b7} {}", format_clock((t1 - t0).as_secs_f64())), - _ => String::new(), - }, - ), - _ => ( - StageTone::Running, - format!(" \u{b7} {}%", (stage.progress.clamp(0.0, 1.0) * 100.0).round() as u32), - ), - }; - StageChip { text: format!("{name}{tail}"), tone } -} - -fn local_status(pipeline: &Pipeline) -> String { - if let Some((index, error)) = pipeline.stages.iter().enumerate().find_map(|(i, s)| match &s.state - { - StageState::Failed(error) => Some((i, error.clone())), - _ => None, - }) { - return format!( - "failed at {} \u{2014} {}", - pipeline.stages[index].domain, - crate::store_views::truncate(error.trim(), 120) - ); - } - if !pipeline.is_running() { - return "done".to_string(); - } - let stage = &pipeline.stages[pipeline.current.min(pipeline.stages.len() - 1)]; - match &stage.state { - StageState::Waiting if !stage.detail.is_empty() => stage.detail.clone(), - StageState::Waiting => "queued".to_string(), - StageState::FanOut | StageState::AwaitingChoice => stage.detail.clone(), - StageState::Submitting => "submitting".to_string(), - StageState::Polling if !stage.detail.is_empty() => stage.detail.clone(), - StageState::Polling if !stage.service_state.is_empty() => stage.service_state.clone(), - StageState::Polling => "rendering".to_string(), - StageState::Fetching => "fetching artifacts".to_string(), - StageState::Done => "done".to_string(), - StageState::Failed(error) => error.clone(), - } -} - -/// The fold of a local run: every stage's inspect block, the SAME text the -/// RUNS surface's opened stage rows already show (`store_views::stage_detail` -/// is the one implementation), with the routing reasoning this engine knows -/// and nothing else does. -fn local_fold(run: &LocalRun) -> String { - let mut out = format!("PROMPT\n{}\n", run.prompt); - for (index, stage) in run.pipeline.stages.iter().enumerate() { - out.push_str(&format!( - "\n\u{2500}\u{2500} {} \u{b7} {} \u{b7} {}\n", - index + 1, - stage_display_name(&stage.domain), - match &stage.state { - StageState::Failed(_) => "failed", - StageState::Done => "succeeded", - StageState::Waiting => "pending", - _ => "running", - } - )); - if !stage.box_url.is_empty() { - out.push_str(&format!( - "MODEL\n{} @ {}\n\n", - stage.model, - stage.box_url.trim_start_matches("http://") - )); - } - if !stage.reason.is_empty() { - out.push_str(&format!("ROUTED\n{}\n\n", stage.reason)); - } - out.push_str(&crate::store_views::stage_detail(stage)); - out.push_str("\n\n"); - if let StageState::Failed(error) = &stage.state { - out.push_str(&format!("ERROR\n{error}\n\n")); - } - } - while out.ends_with('\n') { - out.pop(); - } - out -} - -fn local_card(run: &LocalRun, open: bool) -> RunCard { - let failed = run - .pipeline - .stages - .iter() - .any(|s| matches!(s.state, StageState::Failed(_))); - let state = if failed { - CardState::Failed - } else if run.pipeline.is_running() { - if run - .pipeline - .stages - .iter() - .all(|s| s.state == StageState::Waiting) - { - CardState::Queued - } else { - CardState::Running - } - } else { - CardState::Done - }; - let permille = aggregate_permille(run.pipeline.stages.iter().map(|stage| { - ( - local_stage_weight(&stage.domain), - local_stage_permille(&stage.state, stage.progress), - ) - })); - let elapsed: f64 = run - .pipeline - .stages - .iter() - .filter_map(|s| match (s.started, s.finished) { - (Some(t0), Some(t1)) => Some((t1 - t0).as_secs_f64()), - (Some(t0), None) => Some(t0.elapsed().as_secs_f64()), - _ => None, - }) - .sum(); - RunCard { - key: CardKey::Local(run.id), - state, - origin: "LOCAL", - label: run.label.to_string(), - excerpt: excerpt(run.prompt), - elapsed: format_clock(elapsed), - permille, - status: local_status(run.pipeline), - stages: if run.pipeline.stages.len() > 1 { - (0..run.pipeline.stages.len()) - .map(|index| local_chip(run.pipeline, index)) - .collect() - } else { - Vec::new() - }, - fold: if open { local_fold(run) } else { String::new() }, - can_cancel: run.pipeline.is_running(), - can_promote: false, - open, - created_ms: run.created_ms, - } -} - -fn queued_card(queued: &LocalQueued, open: bool) -> RunCard { - RunCard { - key: CardKey::LocalQueued(queued.index), - state: CardState::Queued, - origin: "LOCAL", - label: queued.label.to_string(), - excerpt: excerpt(queued.prompt), - elapsed: String::new(), - permille: 0, - status: format!("waiting for a free slot \u{b7} #{}", queued.index + 1), - stages: Vec::new(), - fold: if open { - format!( - "not sent yet \u{2014} this run has not started\n\nPROMPT\n{}", - queued.prompt - ) - } else { - String::new() - }, - can_cancel: true, - can_promote: queued.index > 0, - open, - created_ms: 0, - } -} - -// --------------------------------------------------------------------------- -// The chip line -// --------------------------------------------------------------------------- - -/// The header chip beside SEARCHABLE. Always present: "nothing is running" -/// is itself an answer, and a chip that disappears cannot be clicked to see -/// what just finished. -pub fn chip_text(cards: &[RunCard]) -> String { - let running = cards - .iter() - .filter(|card| card.state == CardState::Running) - .count(); - let queued = cards - .iter() - .filter(|card| card.state == CardState::Queued) - .count(); - if running == 0 && queued == 0 { - return "RUNS \u{b7} idle".to_string(); - } - // The percent covers everything still owed, queued runs included at 0 — - // "how far is the work I fired off", not "how far is the busiest box". - let permille = aggregate_permille( - cards - .iter() - .filter(|card| card.is_active()) - .map(|card| (1u16, card.permille)), - ); - if running == 0 { - return format!("RUNS \u{b7} {queued} queued"); - } - let mut line = format!("RUNS \u{b7} {running} running"); - if queued > 0 { - line.push_str(&format!(" \u{b7} {queued} queued")); - } - line.push_str(&format!(" \u{b7} {}%", (permille as u32 + 5) / 10)); - line -} - - -// --------------------------------------------------------------------------- -// The chip state -// --------------------------------------------------------------------------- - -/// Everything the app knows about work in flight — all of it local now. -#[derive(Default)] -pub struct RunsChip { - /// The cards the person has unfolded. - open: Vec, - /// Per-card high-water mark. A stage retry honestly restarts one - /// stage's bar; a bar that walks backwards reads as a broken app. - high_water: HashMap, - pub panel_open: bool, -} - -impl RunsChip { - pub fn set_panel_open(&mut self, open: bool) { - self.panel_open = open; - } - - pub fn is_open(&self, key: &CardKey) -> bool { - self.open.contains(key) - } - - /// Unfold / refold one card. - pub fn toggle_open(&mut self, key: &CardKey) { - match self.open.iter().position(|held| held == key) { - Some(at) => { - self.open.remove(at); - } - None => self.open.push(key.clone()), - } - } - - /// Every spawned unit, one card each, in the one order: what is running, - /// what is waiting, what is over — newest first inside each. - pub fn cards(&mut self, local: &[LocalRun], queued: &[LocalQueued]) -> Vec { - let mut cards = Vec::new(); - for run in local { - let key = CardKey::Local(run.id); - cards.push(local_card(run, self.is_open(&key))); - } - for entry in queued { - let key = CardKey::LocalQueued(entry.index); - cards.push(queued_card(entry, self.is_open(&key))); - } - - // The high-water mark, held per card: a stage that retries restarts - // its own bar honestly, and the aggregate must still not walk back. - for card in &mut cards { - let seen = self.high_water.entry(card.key.clone()).or_insert(0); - *seen = (*seen).max(card.permille); - card.permille = *seen; - } - let live: Vec = cards.iter().map(|card| card.key.clone()).collect(); - self.high_water.retain(|key, _| live.contains(key)); - - cards.sort_by(|a, b| { - a.state - .rank() - .cmp(&b.state.rank()) - .then(b.created_ms.cmp(&a.created_ms)) - }); - cards - } -} diff --git a/apps/asset-ui/src/store_views.rs b/apps/asset-ui/src/store_views.rs index b2c998f90..534c50bc2 100644 --- a/apps/asset-ui/src/store_views.rs +++ b/apps/asset-ui/src/store_views.rs @@ -15,8 +15,7 @@ use crate::library::LibraryMeta; use crate::pipeline::{ format_clock, format_music_duration, stage_display_name, CandidateSet, Pipeline, StageState, }; -use crate::runs_chip::{CardKey, RunCard}; -use makepad_ai_hub::fleet::BoxSnapshot; +use makepad_asset_ai::fleet::BoxSnapshot; use makepad_asset_widgets::{AssetThumb, ThumbMedia}; use makepad_widgets::*; use std::collections::{HashMap, HashSet}; @@ -1542,18 +1541,6 @@ pub enum RowAction { StopImport, /// Drop a waiting import by queue id. RemoveQueuedImport(u64), - /// Open (or close) stage `index` of run `id` to read what it was given. - ToggleStage(u64, usize), - /// Put that stage's full input on the clipboard. - CopyStage(u64, usize), - /// Unfold (or refold) one run card. - ToggleCard(CardKey), - /// Stop the spawned unit this card is, whichever engine holds it. - CancelCard(CardKey), - /// Put the card's whole fold on the clipboard. - CopyCard(CardKey), - /// Move a queued local run one place up its queue. - PromoteCard(CardKey), } /// One concurrent pipeline run as the Runs surface renders it. @@ -1576,14 +1563,6 @@ pub enum StoreRow { progress: f32, failed: bool, cancel: Option, - /// What this stage was HANDED — the full prompt text and the - /// parameters beside it — shown while the row is open. Empty when - /// the row is closed, so a closed list stays a list. - detail: String, - /// Opening/closing this row, when there is something to open. - expand: Option, - /// Copying `detail` to the clipboard, when the row is open. - copy: Option, }, /// App-side queued run (waiting for the active pipeline to finish). Queued { title: String, cancel: RowAction }, @@ -1606,12 +1585,6 @@ pub enum StoreRow { }, /// Honest big empty-state block for missing server data. Disconnected { title: String, detail: String }, - /// ONE spawned unit of work in the one card grammar (`runs_chip.rs`) — - /// a store pipeline, a standalone store job, or a run of this app's own - /// engine. Boxed: a card is much larger than every other row, and a - /// `Vec` should not pay for that on rows that are three - /// strings. - Card(Box), } /// PortalList renderer over a caller-provided `Vec`. One widget @@ -1684,21 +1657,10 @@ impl Widget for StoreListPanel { progress, failed, cancel, - detail, - copy, - .. } => { let item = list.item(cx, item_id, id!(StageR)); - item.button(cx, ids!(stage_title)).set_text(cx, &title); + item.label(cx, ids!(stage_title)).set_text(cx, &title); item.label(cx, ids!(stage_meta)).set_text(cx, &meta); - // The detail block only exists while the row is - // open; a closed row is exactly the row it was. - let detail_label = item.label(cx, ids!(stage_detail)); - detail_label.set_visible(cx, !detail.is_empty()); - if !detail.is_empty() { - detail_label.set_text(cx, &detail); - } - item.button(cx, ids!(stage_copy)).set_visible(cx, copy.is_some()); let bar = item.view(cx, ids!(stage_bar)); bar.set_uniform(cx, live_id!(progress), &[progress]); let fill: [f32; 4] = if failed { @@ -1767,11 +1729,6 @@ impl Widget for StoreListPanel { item.label(cx, ids!(disc_detail)).set_text(cx, &detail); item } - StoreRow::Card(card) => { - let item = list.item(cx, item_id, id!(CardR)); - paint_card(cx, &item, &card); - item - } }; item.draw_all_unscoped(cx); } @@ -1803,76 +1760,6 @@ pub fn format_bytes(bytes: u64) -> String { } } -/// The stage-strip chips a card body holds, and the label inside each. Eight -/// is the store's own stage ceiling; a longer local chain folds its tail -/// into the last chip rather than growing the row. -const CARD_CHIPS: [&[LiveId]; 8] = [ - ids!(cs0), - ids!(cs1), - ids!(cs2), - ids!(cs3), - ids!(cs4), - ids!(cs5), - ids!(cs6), - ids!(cs7), -]; -const CARD_CHIP_LABELS: [&[LiveId]; 8] = [ - ids!(cs0.cs_label), - ids!(cs1.cs_label), - ids!(cs2.cs_label), - ids!(cs3.cs_label), - ids!(cs4.cs_label), - ids!(cs5.cs_label), - ids!(cs6.cs_label), - ids!(cs7.cs_label), -]; - -/// Draw ONE card into one `RunCardBody` instance — the same function for a -/// row of the RUNS panel's list and for a slot on the Create surface, which -/// is what makes them the same card rather than two things that look alike. -pub fn paint_card(cx: &mut Cx, item: &WidgetRef, card: &RunCard) { - item.label(cx, ids!(card_label)).set_text(cx, &card.label); - item.label(cx, ids!(card_excerpt)).set_text(cx, &card.excerpt); - item.label(cx, ids!(card_time)).set_text(cx, &card.elapsed); - item.label(cx, ids!(card_pct)) - .set_text(cx, &format!("{}%", card.percent())); - item.label(cx, ids!(card_status)).set_text(cx, &card.status); - item.view(cx, ids!(card_dot)) - .set_uniform(cx, live_id!(tone), &card.state.dot()); - // THE bar — the only one on the card. - let bar = item.view(cx, ids!(card_bar)); - bar.set_uniform(cx, live_id!(progress), &[card.fraction()]); - bar.set_uniform(cx, live_id!(color_fill), &card.state.fill()); - item.button(cx, ids!(card_cancel)) - .set_visible(cx, card.can_cancel); - item.button(cx, ids!(card_up)) - .set_visible(cx, card.can_promote); - // The stage strip: one chip per stage, one style, never a bar. A single - // stage has no strip — a strip of one is decoration. - item.view(cx, ids!(card_strip)) - .set_visible(cx, !card.stages.is_empty()); - for (index, chip_id) in CARD_CHIPS.iter().enumerate() { - let chip = item.view(cx, *chip_id); - let Some(stage) = card.stages.get(index) else { - chip.set_visible(cx, false); - continue; - }; - chip.set_visible(cx, true); - chip.set_uniform(cx, live_id!(tone), &stage.tone.color()); - item.label(cx, CARD_CHIP_LABELS[index]) - .set_text(cx, &stage.text); - } - // The fold only EXISTS while the card is open: a closed list stays a - // list, and nothing diagnostic is ever on the face of a card. - let fold = item.label(cx, ids!(card_fold)); - fold.set_visible(cx, !card.fold.is_empty()); - if !card.fold.is_empty() { - fold.set_text(cx, &card.fold); - } - item.button(cx, ids!(card_copy)) - .set_visible(cx, !card.fold.is_empty()); -} - pub fn truncate(text: &str, max: usize) -> String { if text.chars().count() <= max { text.to_string() @@ -1882,32 +1769,7 @@ pub fn truncate(text: &str, max: usize) -> String { } } -/// What one stage was given, as a person reads it. The prompt first and in -/// FULL — it is the reason the row opens — then the parameters that rode -/// with it. -pub(crate) fn stage_detail(stage: &crate::pipeline::StageRun) -> String { - if stage.sent_prompt.is_empty() && stage.sent_params.is_empty() { - return "not sent yet — this stage has not started".to_string(); - } - let mut out = String::new(); - if !stage.sent_prompt.is_empty() { - out.push_str("PROMPT SENT\n"); - out.push_str(&stage.sent_prompt); - } - if !stage.sent_params.is_empty() { - if !out.is_empty() { - out.push_str("\n\n"); - } - out.push_str("PARAMS\n"); - out.push_str(&stage.sent_params); - } - if !stage.job_id.is_empty() { - out.push_str(&format!("\n\nJOB\n{}", stage.job_id)); - } - out -} - -fn stage_row(run_id: u64, pipeline: &Pipeline, index: usize, open: bool) -> StoreRow { +fn stage_row(run_id: u64, pipeline: &Pipeline, index: usize) -> StoreRow { let stage = &pipeline.stages[index]; let where_ = if stage.box_url.is_empty() { String::new() @@ -1966,22 +1828,12 @@ fn stage_row(run_id: u64, pipeline: &Pipeline, index: usize, open: bool) -> Stor .collect::>() .join(" · "); StoreRow::Stage { - // The marker says the row has more to say — the same [+]/[−] the - // chat tool chips use, so it reads as the same gesture. - title: format!( - "{} {} · {}", - if open { "[-]" } else { "[+]" }, - index + 1, - stage_display_name(&stage.domain) - ), + title: format!("{} · {}", index + 1, stage_display_name(&stage.domain)), meta, progress: progress as f32, failed, cancel: (index == pipeline.current && pipeline.can_cancel_current()) .then_some(RowAction::CancelRun(run_id)), - detail: if open { stage_detail(stage) } else { String::new() }, - expand: Some(RowAction::ToggleStage(run_id, index)), - copy: open.then_some(RowAction::CopyStage(run_id, index)), } } @@ -1994,11 +1846,6 @@ pub fn runs_rows( fleet: &[BoxSnapshot], latency_ms: &[Option], store: &AssetStore, - // `open_stages` is `(run id, stage index)` of every stage the person has - // opened. It is held by the APP, not the widget: these rows are rebuilt - // from scratch several times a second, so an open row has to be - // remembered by something that outlives them. - open_stages: &[(u64, usize)], ) -> Vec { let mut rows = vec![StoreRow::Section("THIS APP · LOCAL RUNS".into())]; if runs.is_empty() { @@ -2018,8 +1865,7 @@ pub fn runs_rows( truncate(&run.pipeline.prompt, 72) ))); for index in 0..run.pipeline.stages.len() { - let open = open_stages.contains(&(run.id, index)); - rows.push(stage_row(run.id, run.pipeline, index, open)); + rows.push(stage_row(run.id, run.pipeline, index)); } } if !queued.is_empty() { @@ -2035,7 +1881,7 @@ pub fn runs_rows( rows.push(StoreRow::Section("FLEET WORKERS · LAN".into())); if fleet.is_empty() { rows.push(StoreRow::Note( - "No GPU boxes on the LAN — start a makepad-ai-hub fleet service.".into(), + "No GPU boxes on the LAN — start a makepad-asset-ai fleet service.".into(), )); } // One row per PHYSICAL host; several service instances on one box list @@ -2196,15 +2042,6 @@ pub fn admin_rows(store: &AssetStore) -> Vec { .clone() .or_else(|| event.asset_id.map(|id| id.to_string())) .or_else(|| event.game_id.map(|id| id.to_string())) - // A finished run's subject is the RUN, and how it ended — the - // namespace alone would say nothing about which one it was. - .or_else(|| { - let pipeline = event.pipeline?; - Some(match event.pipeline_state { - Some(state) => format!("{pipeline} · {}", state.as_str()), - None => pipeline.to_string(), - }) - }) .unwrap_or_else(|| event.namespace.clone()); rows.push(StoreRow::Record { title: format!("#{} · {}", event.seq, event.kind.as_str()), @@ -2374,61 +2211,6 @@ mod tests { ) } - /// The user's question — "what text went into the music model?" — has - /// an answer in the RUNS list: press the stage, read the whole thing. - #[test] - fn an_opened_stage_shows_the_whole_text_it_sent() { - let mut pipeline = local_pipeline(); - let prompt = "warm analog house, 120 bpm\n\n[verse]\nthe city hums at dusk"; - pipeline.stages[0].sent_prompt = prompt.to_string(); - pipeline.stages[0].sent_params = "model=minimax-music3\nseconds=60".to_string(); - let runs = [RunView { id: 7, label: "music", pipeline: &pipeline }]; - let store = AssetStore::default(); - - // Closed, the list is still a list: no row carries the detail. - let closed = runs_rows(&runs, &[], &[], &[], &store, &[]); - assert!(closed.iter().all(|row| !matches!( - row, - StoreRow::Stage { detail, .. } if !detail.is_empty() - ))); - assert!(closed.iter().any(|row| matches!( - row, - StoreRow::Stage { title, copy: None, .. } if title.starts_with("[+]") - ))); - - // Opened, the row carries the text EXACTLY as it was sent — line - // breaks and all, never truncated — plus what rode with it. - let open = runs_rows(&runs, &[], &[], &[], &store, &[(7, 0)]); - let detail = open - .iter() - .find_map(|row| match row { - StoreRow::Stage { detail, .. } if !detail.is_empty() => Some(detail.clone()), - _ => None, - }) - .expect("the opened stage"); - assert!(detail.contains(prompt), "{detail}"); - assert!(detail.contains("seconds=60"), "{detail}"); - assert!(open.iter().any(|row| matches!( - row, - StoreRow::Stage { title, copy: Some(RowAction::CopyStage(7, 0)), .. } - if title.starts_with("[-]") - ))); - // Only the stage that was opened: opening one row is not opening - // the run. - let opened = open - .iter() - .filter(|row| matches!(row, StoreRow::Stage { detail, .. } if !detail.is_empty())) - .count(); - assert_eq!(opened, 1); - - // A stage that has not run yet says so instead of showing nothing. - let other = runs_rows(&runs, &[], &[], &[], &store, &[(7, 1)]); - assert!(other.iter().any(|row| matches!( - row, - StoreRow::Stage { detail, .. } if detail.contains("not sent yet") - ))); - } - #[test] fn runs_rows_show_each_concurrent_run_and_honest_server_state() { let first = local_pipeline(); @@ -2452,7 +2234,6 @@ mod tests { &fleet, &[None], &AssetStore::default(), - &[], ); // One Stage row per stage PER RUN — concurrent runs itemize // independently instead of collapsing into one "active" pipeline. diff --git a/apps/asset-ui/src/thumbnail_renderer.rs b/apps/asset-ui/src/thumbnail_renderer.rs index 1677b5638..38f6f29dc 100644 --- a/apps/asset-ui/src/thumbnail_renderer.rs +++ b/apps/asset-ui/src/thumbnail_renderer.rs @@ -819,8 +819,6 @@ impl ThumbnailRenderer { ThumbnailSubject::Statue(ModelInstance { model: id, transform: frame.transform, - tint: vec4(1.0, 1.0, 1.0, 1.0), - color_adjust: vec4(0.0, 1.0, 1.0, 0.0), dynamic: true, depth_order: 0.0, part_poses: Vec::new(), @@ -947,7 +945,7 @@ impl ThumbnailRenderer { } let encoded = rgba.and_then(|rgba| { - makepad_ai_hub::testpattern::encode_png_rgba(&rgba, THUMBNAIL_SIZE, THUMBNAIL_SIZE) + makepad_asset_ai::testpattern::encode_png_rgba(&rgba, THUMBNAIL_SIZE, THUMBNAIL_SIZE) .map_err(|error| log!("thumbnail {file}: PNG encode failed: {error}")) .ok() }); diff --git a/apps/finance/Cargo.toml b/apps/finance/Cargo.toml deleted file mode 100644 index 584b7bddb..000000000 --- a/apps/finance/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -# Personal finance: a ledger, budgets and reports over a SQLite file, with -# bank-CSV import. The database is the file format; everything the screens -# read is an in-memory cache rebuilt from it. - -[package] -name = "makepad-app-finance" -version = "0.1.0" -edition = "2021" -description = "Personal finance: ledger, budgets, reports and CSV import" -license = "MIT OR Apache-2.0" -default-run = "finance" - -[[bin]] -name = "finance" -path = "src/main.rs" - -[dependencies] -makepad-widgets = { path = "../../widgets" } -mp-theme = { path = "../../libs/mp_theme" } -makepad-sqlite = { path = "../../libs/sqlite_query" } diff --git a/apps/finance/src/chart.rs b/apps/finance/src/chart.rs deleted file mode 100644 index 7c01c7c1e..000000000 --- a/apps/finance/src/chart.rs +++ /dev/null @@ -1,601 +0,0 @@ -//! The charts, drawn as shaders. -//! -//! One widget, three forms, because a finance dashboard only ever needs -//! three: a series over time (net worth, balance), a comparison across a -//! handful of periods (income against spending), and the same series -//! shrunk into a table row (a sparkline). Each is a handful of instanced -//! quads with an SDF in the pixel shader — no geometry pass, no texture, -//! and the whole chart batches into a few draw calls, which is why a -//! sparkline per row of a scrolling ledger costs nothing. -//! -//! The visual rules come from the dataviz guidance and are deliberate: -//! -//! * **No gridlines by default.** A line over a dark surface reads on its -//! own; a grid competes with it. Two faint rules mark the extremes, and -//! that is all the scale anyone reads off a trend. -//! * **Selective labels.** The first, last and extreme values are labelled -//! — never every point. -//! * **The fill is a gradient to nothing.** A flat fill under a line reads -//! as a solid shape and hides the line; the fade keeps the line the -//! subject. -//! * **Bars sit on the baseline with a rounded top and a 2px gap.** The -//! gap is the surface showing through, which is what separates adjacent -//! bars without a stroke around each one. - -use makepad_widgets::*; - -/// The area under a series: one quad per sample interval, each shading the -/// slice between the curve and the baseline. -/// -/// The top edge is interpolated ACROSS the quad (`top_left` → `top_right`), -/// so a slice is a trapezoid rather than a staircase, and the edge is -/// antialiased against the fill rather than left to the rasteriser. -#[derive(Script, ScriptHook)] -#[repr(C)] -pub struct DrawAreaFill { - #[deref] - draw_super: DrawQuad, - /// Height of the curve at this quad's left edge, 0 = top of the plot. - #[live] - pub top_left: f32, - #[live] - pub top_right: f32, - /// Colour at the curve, fading to fully transparent at the baseline. - #[live] - pub color_top: Vec4f, - /// How far down the fade reaches: 1.0 fades across the whole plot. - #[live(1.0)] - pub fade: f32, -} - -/// One segment of the line, as an SDF capsule so the joins are round and -/// the edges are antialiased at any angle. -#[derive(Script, ScriptHook)] -#[repr(C)] -pub struct DrawLineSeg { - #[deref] - draw_super: DrawQuad, - #[live] - pub y0: f32, - #[live] - pub y1: f32, - #[live] - pub color_line: Vec4f, - #[live(2.0)] - pub thickness: f32, - /// A soft outer glow, which is what stops a 2px line looking thin on a - /// dark surface without having to thicken it. - #[live(0.0)] - pub glow: f32, -} - -/// A bar, anchored to the baseline with a rounded top. -#[derive(Script, ScriptHook)] -#[repr(C)] -pub struct DrawBar { - #[deref] - draw_super: DrawQuad, - #[live] - pub color_bar: Vec4f, - /// 0 at the baseline, 1 at the top of the plot. - #[live] - pub height_frac: f32, - #[live(4.0)] - pub radius: f32, - /// Bars that hang below the baseline round the other way. - #[live(0.0)] - pub downward: f32, -} - -/// A proportion bar — track and fill in one quad. -/// -/// Drawn rather than laid out on purpose: a fill sized by layout needs the -/// track's measured width, which does not exist until after a layout pass, -/// so the first frame draws empty bars and every resize needs a re-measure. -/// A shader that takes the fraction as an instance has neither problem, and -/// it is one quad instead of three widgets per row. -#[derive(Script, ScriptHook)] -#[repr(C)] -pub struct DrawMeter { - #[deref] - draw_super: DrawQuad, - /// 0..1 of the track. - #[live] - pub fraction: f32, - #[live] - pub color_track: Vec4f, - #[live] - pub color_fill: Vec4f, - /// A second, dimmer mark on the same track — what was budgeted, or the - /// same period last year. Negative hides it. - #[live(-1.0)] - pub marker: f32, - #[live] - pub color_marker: Vec4f, -} - -/// The point at the end of a series — "you are here". -#[derive(Script, ScriptHook)] -#[repr(C)] -pub struct DrawDot { - #[deref] - draw_super: DrawQuad, - #[live] - pub color_dot: Vec4f, - #[live] - pub color_ring: Vec4f, -} - -script_mod! { - use mod.prelude.widgets_internal.* - use mod.widgets.* - - // The `#[live]` fields on each struct become the shader's instances; - // the script block only carries the pixel program. - set_type_default() do #(DrawAreaFill::script_shader(vm)){ - ..mod.draw.DrawQuad - pixel: fn() { - // Where the curve sits at this pixel's x, 0 = top of the plot. - let top = mix(self.top_left, self.top_right, self.pos.x) - let y = self.pos.y - let feather = 1.0 / max(self.rect_size.y, 1.0) - let inside = smoothstep(top - feather, top + feather, y) - if inside <= 0.0 { - return #0000 - } - // Fade to nothing on the way down, so the fill never reads as - // a solid block and the line stays the subject. - let depth = (y - top) / max(self.fade * (1.0 - top), 0.001) - let strength = clamp(1.0 - depth, 0.0, 1.0) - let alpha = inside * strength * strength * self.color_top.a - return vec4(self.color_top.rgb * alpha, alpha) - } - } - - set_type_default() do #(DrawLineSeg::script_shader(vm)){ - ..mod.draw.DrawQuad - pixel: fn() { - let p = self.pos * self.rect_size - let a = vec2(0.0, self.y0 * self.rect_size.y) - let b = vec2(self.rect_size.x, self.y1 * self.rect_size.y) - // Distance to the segment: the capsule SDF, so joins are round - // and the edge is antialiased at any angle. - let pa = p - a - let ba = b - a - let h = clamp(dot(pa, ba) / max(dot(ba, ba), 0.0001), 0.0, 1.0) - let d = length(pa - ba * h) - let half = self.thickness * 0.5 - let line = clamp(1.0 - smoothstep(half - 0.75, half + 0.75, d), 0.0, 1.0) - let halo = clamp(1.0 - smoothstep(half, half + max(self.glow, 0.001), d), 0.0, 1.0) - let alpha = clamp(line + halo * 0.3 * (1.0 - line), 0.0, 1.0) * self.color_line.a - return vec4(self.color_line.rgb * alpha, alpha) - } - } - - set_type_default() do #(DrawBar::script_shader(vm)){ - ..mod.draw.DrawQuad - pixel: fn() { - let size = self.rect_size - let sdf = Sdf2d.viewport(self.pos * size) - // Round the far end only. A bar rounded at both ends reads as a - // floating pill instead of a measurement standing on an axis, - // so the baseline end is pushed outside the quad and clipped - // square. (Sdf2d takes the DIAMETER as its corner argument.) - let r = min(self.radius, size.x * 0.5) - if self.downward > 0.5 { - sdf.box(0.0, 0.0 - r * 2.0, size.x, size.y + r * 2.0, r * 0.5) - } else { - sdf.box(0.0, 0.0, size.x, size.y + r * 2.0, r * 0.5) - } - sdf.fill(self.color_bar) - return sdf.result - } - } - - set_type_default() do #(DrawMeter::script_shader(vm)){ - ..mod.draw.DrawQuad - pixel: fn() { - let size = self.rect_size - let sdf = Sdf2d.viewport(self.pos * size) - let r = size.y * 0.25 - sdf.box(0.0, 0.0, size.x, size.y, r) - sdf.fill(self.color_track) - let w = max(self.fraction * size.x, size.y) - sdf.box(0.0, 0.0, w, size.y, r) - sdf.fill(self.color_fill) - if self.marker >= 0.0 { - // A hairline where the target sits, drawn over the fill so - // it reads whether you are under or over it. - let x = self.marker * size.x - sdf.box(x - 1.0, 0.0 - 1.0, 2.0, size.y + 2.0, 0.0) - sdf.fill(self.color_marker) - } - return sdf.result - } - } - - set_type_default() do #(DrawDot::script_shader(vm)){ - ..mod.draw.DrawQuad - pixel: fn() { - let size = self.rect_size - let sdf = Sdf2d.viewport(self.pos * size) - let r = min(size.x, size.y) * 0.5 - sdf.circle(size.x * 0.5, size.y * 0.5, r) - sdf.fill(self.color_ring) - sdf.circle(size.x * 0.5, size.y * 0.5, r * 0.42) - sdf.fill(self.color_dot) - return sdf.result - } - } - - mod.widgets.MeterBase = #(Meter::register_widget(vm)) - mod.widgets.Meter = set_type_default() do mod.widgets.MeterBase{ - width: Fill - height: 6 - draw_meter +: { - color_track: #x272a35 - color_fill: #x5e6ad2 - color_marker: #xa2a8b8 - } - } - - mod.widgets.FinanceChartBase = #(FinanceChart::register_widget(vm)) - mod.widgets.FinanceChart = set_type_default() do mod.widgets.FinanceChartBase{ - width: Fill - height: Fill - color_line: #x3987e5 - color_fill: #x3987e5 - color_second: #xd95926 - color_axis: #x6b7784 - color_rule: #x2a323d - draw_text +: { - color: #x9aa7b4 - text_style: theme.font_regular{font_size: 7.5} - } - } -} - -/// What a chart is drawing. -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum Form { - /// A series over time, filled to the baseline. - Area, - /// Two series compared per period, as paired bars. - Bars, - /// A series with no chrome at all, for a table cell. - Spark, -} - -impl Default for Form { - fn default() -> Form { - Form::Area - } -} - -/// Padding inside the plot, so a line at the maximum is not clipped by the -/// widget's own edge and labels have somewhere to sit. -const PAD_TOP: f64 = 14.0; -const PAD_BOTTOM: f64 = 18.0; -const PAD_RIGHT: f64 = 54.0; - -#[derive(Script, ScriptHook, Widget)] -pub struct FinanceChart { - #[uid] - uid: WidgetUid, - #[walk] - walk: Walk, - #[redraw] - #[live] - draw_area: DrawAreaFill, - #[live] - draw_line: DrawLineSeg, - #[live] - draw_bar: DrawBar, - #[live] - draw_dot: DrawDot, - #[live] - draw_rule: DrawColor, - #[live] - draw_text: DrawText, - - #[live] - color_line: Vec4f, - #[live] - color_fill: Vec4f, - #[live] - color_second: Vec4f, - #[live] - color_axis: Vec4f, - #[live] - color_rule: Vec4f, - - #[rust] - form: Form, - #[rust] - series: Vec, - #[rust] - second: Vec, - /// Labels under the bars, and the value labels' formatter output. - #[rust] - labels: Vec, - #[rust] - value_labels: Vec<(f64, String)>, - #[rust] - area: Area, -} - -impl FinanceChart { - pub fn set_area(&mut self, values: &[f64], marks: Vec<(f64, String)>) { - self.form = Form::Area; - self.series = values.to_vec(); - self.value_labels = marks; - } - - pub fn set_spark(&mut self, values: &[f64]) { - self.form = Form::Spark; - self.series = values.to_vec(); - } - - pub fn set_bars(&mut self, first: &[f64], second: &[f64], labels: Vec) { - self.form = Form::Bars; - self.series = first.to_vec(); - self.second = second.to_vec(); - self.labels = labels; - } - - /// The value range to draw against. - /// - /// Zero is included for bars (a bar chart that does not start at zero - /// lies about proportion) but NOT for a trend line, where the story is - /// the change and a forced zero flattens it into a straight line. - fn bounds(&self) -> (f64, f64) { - let mut low = f64::MAX; - let mut high = f64::MIN; - for value in self.series.iter().chain(self.second.iter()) { - low = low.min(*value); - high = high.max(*value); - } - if low > high { - return (0.0, 1.0); - } - if self.form == Form::Bars { - low = low.min(0.0); - high = high.max(0.0); - } - if (high - low).abs() < 1e-9 { - high = low + 1.0; - } - // A little headroom, so the peak is not welded to the top edge. - let margin = (high - low) * 0.08; - (low - margin, high + margin) - } -} - -impl Widget for FinanceChart { - fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { - let rect = cx.walk_turtle(walk); - self.area = Area::Empty; - if rect.size.x < 2.0 || rect.size.y < 2.0 || self.series.len() < 2 { - return DrawStep::done(); - } - let spark = self.form == Form::Spark; - let plot = Rect { - pos: dvec2(rect.pos.x, rect.pos.y + if spark { 1.0 } else { PAD_TOP }), - size: dvec2( - rect.size.x - if spark { 0.0 } else { PAD_RIGHT }, - rect.size.y - if spark { 2.0 } else { PAD_TOP + PAD_BOTTOM }, - ), - }; - if plot.size.x < 2.0 || plot.size.y < 2.0 { - return DrawStep::done(); - } - let (low, high) = self.bounds(); - let span = high - low; - let y_of = |value: f64| -> f64 { ((high - value) / span).clamp(0.0, 1.0) }; - - match self.form { - Form::Area | Form::Spark => { - let count = self.series.len(); - let step = plot.size.x / (count - 1) as f64; - - if !spark { - // Two faint rules at the extremes: enough scale to read - // against, far less than a grid. - self.draw_rule.color = self.color_rule; - for fraction in [0.0, 1.0] { - self.draw_rule.draw_abs( - cx, - Rect { - pos: dvec2(plot.pos.x, plot.pos.y + plot.size.y * fraction), - size: dvec2(plot.size.x, 1.0), - }, - ); - } - } - - // The fill, one slice per interval. - self.draw_area.color_top = Vec4f { - w: if spark { 0.35 } else { 0.55 }, - ..self.color_fill - }; - self.draw_area.fade = 1.0; - for index in 0..count - 1 { - self.draw_area.top_left = y_of(self.series[index]) as f32; - self.draw_area.top_right = y_of(self.series[index + 1]) as f32; - self.draw_area.draw_abs( - cx, - Rect { - pos: dvec2(plot.pos.x + step * index as f64, plot.pos.y), - size: dvec2(step + 0.5, plot.size.y), - }, - ); - } - - // The line on top. - self.draw_line.color_line = self.color_line; - self.draw_line.thickness = if spark { 1.5 } else { 2.0 }; - self.draw_line.glow = if spark { 0.0 } else { 5.0 }; - for index in 0..count - 1 { - self.draw_line.y0 = y_of(self.series[index]) as f32; - self.draw_line.y1 = y_of(self.series[index + 1]) as f32; - self.draw_line.draw_abs( - cx, - Rect { - pos: dvec2(plot.pos.x + step * index as f64, plot.pos.y), - size: dvec2(step, plot.size.y), - }, - ); - } - - if !spark { - // "You are here", and the only labelled points: the - // last value, and whatever the caller marked. - let last = *self.series.last().unwrap(); - let dot = dvec2( - plot.pos.x + plot.size.x, - plot.pos.y + y_of(last) * plot.size.y, - ); - self.draw_dot.color_ring = self.color_line; - self.draw_dot.color_dot = Vec4f { x: 1.0, y: 1.0, z: 1.0, w: 1.0 }; - self.draw_dot.draw_abs( - cx, - Rect { pos: dot - dvec2(4.0, 4.0), size: dvec2(8.0, 8.0) }, - ); - self.draw_text.color = self.color_axis; - for (value, text) in &self.value_labels { - let y = plot.pos.y + y_of(*value) * plot.size.y; - self.draw_text.draw_abs( - cx, - dvec2(plot.pos.x + plot.size.x + 8.0, y - 6.0), - text, - ); - } - } - } - Form::Bars => { - let count = self.series.len().max(1); - let slot = plot.size.x / count as f64; - // Two bars per period with a 2px gap between them, and a - // wider gap between periods so the pairs read as pairs. - let gap = 2.0; - let group = (slot - 6.0).max(4.0); - let bar = ((group - gap) * 0.5).max(2.0); - let zero = y_of(0.0); - - self.draw_rule.color = self.color_rule; - self.draw_rule.draw_abs( - cx, - Rect { - pos: dvec2(plot.pos.x, plot.pos.y + zero * plot.size.y), - size: dvec2(plot.size.x, 1.0), - }, - ); - - for index in 0..count { - let left = plot.pos.x + slot * index as f64 + (slot - group) * 0.5; - for (offset, value, color) in [ - (0.0, self.series.get(index).copied().unwrap_or(0.0), self.color_line), - ( - bar + gap, - self.second.get(index).copied().unwrap_or(0.0), - self.color_second, - ), - ] { - let top = y_of(value); - let baseline = plot.pos.y + zero * plot.size.y; - let height = ((zero - top).abs() * plot.size.y).max(2.0); - let downward = value < 0.0; - // Both directions start ON the baseline, so the two - // series of a pair meet exactly at the axis. - let y = if downward { baseline } else { baseline - height }; - self.draw_bar.color_bar = color; - self.draw_bar.downward = if downward { 1.0 } else { 0.0 }; - self.draw_bar.draw_abs( - cx, - Rect { pos: dvec2(left + offset, y), size: dvec2(bar, height) }, - ); - } - // Period labels, thinned out so they never collide. - if let Some(label) = self.labels.get(index) { - let every = ((count as f64 * 42.0) / plot.size.x).ceil().max(1.0) as usize; - if index % every == 0 { - self.draw_text.color = self.color_axis; - self.draw_text.draw_abs( - cx, - dvec2(left, plot.pos.y + plot.size.y + 4.0), - label, - ); - } - } - } - } - } - cx.add_aligned_rect_area(&mut self.area, rect); - DrawStep::done() - } - - fn handle_event(&mut self, _cx: &mut Cx, _event: &Event, _scope: &mut Scope) {} -} - -impl FinanceChartRef { - pub fn set_area(&self, cx: &mut Cx, values: &[f64], marks: Vec<(f64, String)>) { - if let Some(mut inner) = self.borrow_mut() { - inner.set_area(values, marks); - inner.redraw(cx); - } - } - - pub fn set_spark(&self, cx: &mut Cx, values: &[f64]) { - if let Some(mut inner) = self.borrow_mut() { - inner.set_spark(values); - inner.redraw(cx); - } - } - - pub fn set_bars(&self, cx: &mut Cx, first: &[f64], second: &[f64], labels: Vec) { - if let Some(mut inner) = self.borrow_mut() { - inner.set_bars(first, second, labels); - inner.redraw(cx); - } - } -} - -/// A proportion bar. One quad, one instance value. -#[derive(Script, ScriptHook, Widget)] -pub struct Meter { - #[uid] - uid: WidgetUid, - #[walk] - walk: Walk, - #[redraw] - #[live] - draw_meter: DrawMeter, - #[rust] - area: Area, -} - -impl Widget for Meter { - fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { - let rect = cx.walk_turtle(walk); - self.draw_meter.draw_abs(cx, rect); - cx.add_aligned_rect_area(&mut self.area, rect); - DrawStep::done() - } - - fn handle_event(&mut self, _cx: &mut Cx, _event: &Event, _scope: &mut Scope) {} -} - -impl MeterRef { - /// `fraction` is 0..1; `color` overrides the fill (for a status colour); - /// `marker` places the target hairline, or hides it when negative. - pub fn set(&self, cx: &mut Cx, fraction: f64, color: Option, marker: f64) { - if let Some(mut inner) = self.borrow_mut() { - inner.draw_meter.fraction = fraction.clamp(0.0, 1.0) as f32; - inner.draw_meter.marker = marker as f32; - if let Some(color) = color { - inner.draw_meter.color_fill = color; - } - inner.redraw(cx); - } - } -} diff --git a/apps/finance/src/csv.rs b/apps/finance/src/csv.rs deleted file mode 100644 index 245e159d6..000000000 --- a/apps/finance/src/csv.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! A CSV reader that survives what banks actually export. -//! -//! RFC 4180 is a page long and describes maybe half of the files a bank -//! will hand you. The rest of this module is the other half: a UTF-8 BOM -//! that would otherwise make the first header `\u{feff}Date`; semicolons -//! because the country uses a comma for decimals; tabs; CRLF; quoted fields -//! with embedded newlines; `""` escapes inside quotes; a preamble of -//! account-header junk above the real header row; and ragged rows. -//! -//! Nothing here allocates per field beyond the field itself, and the whole -//! file is read into memory on purpose — the largest statement export -//! anyone has is a few megabytes, and random access to the rows is what the -//! import preview needs. - -/// The delimiter a file turned out to use. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Delimiter { - Comma, - Semicolon, - Tab, - Pipe, -} - -impl Delimiter { - pub fn byte(self) -> u8 { - match self { - Delimiter::Comma => b',', - Delimiter::Semicolon => b';', - Delimiter::Tab => b'\t', - Delimiter::Pipe => b'|', - } - } - - pub fn label(self) -> &'static str { - match self { - Delimiter::Comma => "Comma", - Delimiter::Semicolon => "Semicolon", - Delimiter::Tab => "Tab", - Delimiter::Pipe => "Pipe", - } - } - - pub const ALL: [Delimiter; 4] = - [Delimiter::Comma, Delimiter::Semicolon, Delimiter::Tab, Delimiter::Pipe]; -} - -/// A parsed file: rows of fields, plus what we had to work out to read it. -#[derive(Clone, Debug)] -pub struct Csv { - pub rows: Vec>, - pub delimiter: Delimiter, - /// Rows skipped above the header (bank preamble). - pub preamble: usize, -} - -impl Csv { - /// The row we believe holds column names. - pub fn header(&self) -> &[String] { - self.rows.first().map(|r| r.as_slice()).unwrap_or(&[]) - } - - /// Everything below the header. - pub fn records(&self) -> &[Vec] { - self.rows.get(1..).unwrap_or(&[]) - } - - /// One column of the records, for sniffing formats. Short rows yield - /// an empty string rather than being skipped, so the row index and the - /// value index stay in step. - pub fn column(&self, index: usize) -> impl Iterator { - self.records() - .iter() - .map(move |row| row.get(index).map(|s| s.as_str()).unwrap_or("")) - } - - pub fn width(&self) -> usize { - self.rows.iter().map(|r| r.len()).max().unwrap_or(0) - } -} - -/// Split text into rows of fields with a known delimiter. -/// -/// The state machine is RFC 4180's, with the tolerances real files need: a -/// quote inside an unquoted field is a literal quote (not an error), a -/// field that never closes its quote ends at end of input, and CRLF, LF and -/// a lone CR all end a row. -pub fn parse_with(text: &str, delimiter: Delimiter) -> Vec> { - let delim = delimiter.byte() as char; - let mut rows: Vec> = Vec::new(); - let mut row: Vec = Vec::new(); - let mut field = String::new(); - let mut in_quotes = false; - let mut chars = text.chars().peekable(); - // A BOM would otherwise become part of the first header name. - if text.starts_with('\u{feff}') { - chars.next(); - } - let mut any = false; - - while let Some(ch) = chars.next() { - any = true; - if in_quotes { - if ch == '"' { - if chars.peek() == Some(&'"') { - chars.next(); - field.push('"'); // "" is one literal quote - } else { - in_quotes = false; - } - } else { - field.push(ch); - } - continue; - } - match ch { - '"' if field.is_empty() => in_quotes = true, - c if c == delim => { - row.push(std::mem::take(&mut field)); - } - '\r' => { - if chars.peek() == Some(&'\n') { - chars.next(); - } - row.push(std::mem::take(&mut field)); - rows.push(std::mem::take(&mut row)); - } - '\n' => { - row.push(std::mem::take(&mut field)); - rows.push(std::mem::take(&mut row)); - } - c => field.push(c), - } - } - if any && (!field.is_empty() || !row.is_empty()) { - row.push(field); - rows.push(row); - } - // A trailing newline leaves one empty row; so does a blank line in the - // middle of a bank's preamble. Neither is a record. - rows.retain(|row| !(row.len() == 1 && row[0].trim().is_empty())); - rows -} - -/// Work out the delimiter by trying each one and asking which gives the -/// most consistent row width. -/// -/// Counting occurrences is not enough: a file full of `"Smith, John"` -/// payees has plenty of commas inside quotes, and a German file has both -/// semicolons and commas. Parsing under each candidate and scoring the -/// result is what tells them apart — the right delimiter yields many -/// columns AND the same number in nearly every row. -pub fn sniff_delimiter(text: &str) -> Delimiter { - let sample: String = text.lines().take(30).collect::>().join("\n"); - let mut best = (Delimiter::Comma, -1.0f64); - for candidate in Delimiter::ALL { - let rows = parse_with(&sample, candidate); - if rows.len() < 2 { - continue; - } - let widths: Vec = rows.iter().map(|r| r.len()).collect(); - let modal = modal_width(&widths); - if modal < 2 { - continue; // one column means this character is not the delimiter - } - let consistent = - widths.iter().filter(|w| **w == modal).count() as f64 / widths.len() as f64; - // Consistency first, then column count as the tie-break: a file - // read under the wrong delimiter is ragged, and a file read under - // the right one usually has more columns than a partial split. - let score = consistent * 100.0 + modal as f64; - if score > best.1 { - best = (candidate, score); - } - } - best.0 -} - -fn modal_width(widths: &[usize]) -> usize { - let mut counts: Vec<(usize, usize)> = Vec::new(); - for width in widths { - match counts.iter_mut().find(|(w, _)| w == width) { - Some((_, n)) => *n += 1, - None => counts.push((*width, 1)), - } - } - counts.sort_by_key(|(width, count)| (std::cmp::Reverse(*count), *width)); - counts.first().map(|(w, _)| *w).unwrap_or(0) -} - -/// Read a file: sniff the delimiter, drop any preamble above the header, -/// and pad ragged rows to the header's width. -/// -/// The preamble is the reason this is not two lines. Plenty of banks print -/// "Account: 1234", a blank line and a date range above the actual table; -/// the header is the first row whose width matches the width most rows -/// have. Everything above it is dropped, and remembered so the import -/// screen can say so. -pub fn parse(text: &str) -> Csv { - let delimiter = sniff_delimiter(text); - let mut rows = parse_with(text, delimiter); - let widths: Vec = rows.iter().map(|r| r.len()).collect(); - let modal = modal_width(&widths); - let preamble = rows - .iter() - .position(|row| row.len() == modal && row.iter().any(|f| !f.trim().is_empty())) - .unwrap_or(0); - if preamble > 0 { - rows.drain(..preamble); - } - for row in rows.iter_mut() { - while row.len() < modal { - row.push(String::new()); - } - } - Csv { rows, delimiter, preamble } -} - -/// Quote a field for writing: only when it has to be, the way every other -/// tool does it, so a round trip through this module is a no-op. -pub fn escape_field(field: &str, delimiter: Delimiter) -> String { - let needs = field.contains(delimiter.byte() as char) - || field.contains('"') - || field.contains('\n') - || field.contains('\r') - || field.starts_with(' ') - || field.ends_with(' '); - if !needs { - return field.to_string(); - } - let mut out = String::with_capacity(field.len() + 2); - out.push('"'); - for ch in field.chars() { - if ch == '"' { - out.push('"'); - } - out.push(ch); - } - out.push('"'); - out -} - -/// Write rows back out as RFC 4180 (CRLF, as the spec says). -pub fn write(rows: &[Vec], delimiter: Delimiter) -> String { - let mut out = String::new(); - for row in rows { - for (i, field) in row.iter().enumerate() { - if i > 0 { - out.push(delimiter.byte() as char); - } - out.push_str(&escape_field(field, delimiter)); - } - out.push_str("\r\n"); - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reads_rfc4180_including_the_awkward_parts() { - let text = "a,b,c\r\n1,\"two, with comma\",3\r\n4,\"say \"\"hi\"\"\",6\r\n"; - let csv = parse(text); - assert_eq!(csv.delimiter, Delimiter::Comma); - assert_eq!(csv.header(), ["a", "b", "c"]); - assert_eq!(csv.records()[0][1], "two, with comma"); - assert_eq!(csv.records()[1][1], "say \"hi\""); - } - - #[test] - fn a_quoted_field_may_contain_a_newline() { - let text = "date,memo\n2024-01-01,\"line one\nline two\"\n"; - let csv = parse(text); - assert_eq!(csv.records().len(), 1); - assert_eq!(csv.records()[0][1], "line one\nline two"); - } - - #[test] - fn strips_the_bom_so_the_first_header_is_usable() { - let text = "\u{feff}Date,Amount\n2024-01-01,10.00\n"; - let csv = parse(text); - assert_eq!(csv.header()[0], "Date"); - } - - #[test] - fn tells_a_semicolon_file_from_a_comma_one() { - // German: semicolon delimited, commas INSIDE the numbers. - let german = "Datum;Beschreibung;Betrag\n04.03.2024;Miete;-1.234,56\n05.03.2024;Lohn;2.500,00\n"; - assert_eq!(parse(german).delimiter, Delimiter::Semicolon); - assert_eq!(parse(german).records()[0][2], "-1.234,56"); - - // Comma delimited with commas inside quoted payees. - let anglo = "Date,Payee,Amount\n2024-03-04,\"Smith, John\",-25.00\n2024-03-05,\"Doe, Jane\",30.00\n"; - assert_eq!(parse(anglo).delimiter, Delimiter::Comma); - assert_eq!(parse(anglo).records()[0][1], "Smith, John"); - - let tabbed = "Date\tPayee\tAmount\n2024-03-04\tRent\t-100\n"; - assert_eq!(parse(tabbed).delimiter, Delimiter::Tab); - } - - #[test] - fn drops_the_junk_a_bank_prints_above_the_table() { - let text = "Account Statement\n\nAccount:,1234567890\nPeriod:,Jan 2024\n\n\ - Date,Description,Amount,Balance\n\ - 2024-01-02,Coffee,-4.50,995.50\n\ - 2024-01-03,Salary,2000.00,2995.50\n"; - let csv = parse(text); - assert_eq!(csv.header(), ["Date", "Description", "Amount", "Balance"]); - assert_eq!(csv.records().len(), 2); - assert!(csv.preamble > 0); - } - - #[test] - fn ragged_rows_are_padded_so_column_access_never_panics() { - let text = "a,b,c\n1,2,3\n4,5\n"; - let csv = parse(text); - assert_eq!(csv.records()[1].len(), 3); - assert_eq!(csv.column(2).collect::>(), ["3", ""]); - } - - #[test] - fn round_trips_through_write() { - let rows = vec![ - vec!["Date".into(), "Payee".into(), "Amount".into()], - vec!["2024-03-04".into(), "Smith, John".into(), "-25.00".into()], - vec!["2024-03-05".into(), "say \"hi\"".into(), "30.00".into()], - ]; - let text = write(&rows, Delimiter::Comma); - let back = parse(&text); - assert_eq!(back.rows, rows); - } - - #[test] - fn an_empty_or_single_line_file_does_not_panic() { - assert!(parse("").rows.is_empty()); - assert_eq!(parse("just one line\n").rows.len(), 1); - } -} diff --git a/apps/finance/src/date.rs b/apps/finance/src/date.rs deleted file mode 100644 index b10e48f08..000000000 --- a/apps/finance/src/date.rs +++ /dev/null @@ -1,506 +0,0 @@ -//! Civil dates as a day number, and the fight to read the ones banks write. -//! -//! A ledger only ever needs whole days: no clocks, no zones, no leap -//! seconds. So a date is an `i32` count of days from 1970-01-01, which -//! sorts, subtracts and indexes into a month bucket without a calendar -//! library, and is four bytes in a row of a hundred thousand. -//! -//! The hard part is not arithmetic, it is `03/04/2024`. That is the 3rd of -//! April in Europe and the 4th of March in America, and the file rarely -//! says which. Guessing per row silently scatters transactions across -//! months. So [`sniff_date_format`] reads the WHOLE column and only then -//! decides — a single row with a day above 12 settles it for every other -//! row, and when nothing settles it the caller is told, so the import -//! screen can ask instead of inventing an answer. - -use std::fmt; - -/// Days since 1970-01-01. Negative reaches back before it. -pub type Day = i32; - -/// Days from the civil date. Howard Hinnant's `days_from_civil`, which is -/// exact for the whole proleptic Gregorian calendar. -pub fn from_ymd(year: i32, month: u32, day: u32) -> Day { - let y = if month <= 2 { year - 1 } else { year }; - let era = if y >= 0 { y } else { y - 399 } / 400; - let yoe = (y - era * 400) as i64; // [0, 399] - let mp = ((month as i64 + 9) % 12) as i64; // Mar = 0 - let doy = (153 * mp + 2) / 5 + day as i64 - 1; // [0, 365] - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096] - (era as i64 * 146097 + doe - 719468) as Day -} - -/// The civil date of a day number. -pub fn to_ymd(day: Day) -> (i32, u32, u32) { - let z = day as i64 + 719468; - let era = if z >= 0 { z } else { z - 146096 } / 146097; - let doe = z - era * 146097; // [0, 146096] - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399] - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] - let mp = (5 * doy + 2) / 153; // [0, 11], Mar = 0 - let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] - let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] - ((if m <= 2 { y + 1 } else { y }) as i32, m as u32, d as u32) -} - -pub fn year_of(day: Day) -> i32 { - to_ymd(day).0 -} - -pub fn month_of(day: Day) -> u32 { - to_ymd(day).1 -} - -/// 0 = Monday. (1970-01-01 was a Thursday.) -pub fn weekday(day: Day) -> u32 { - (day.rem_euclid(7) as u32 + 3) % 7 -} - -pub fn is_weekend(day: Day) -> bool { - weekday(day) >= 5 -} - -pub fn days_in_month(year: i32, month: u32) -> u32 { - match month { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 => 29, - 2 => 28, - _ => 30, - } -} - -/// First day of the month containing `day`. -pub fn month_start(day: Day) -> Day { - let (y, m, _) = to_ymd(day); - from_ymd(y, m, 1) -} - -/// Last day of the month containing `day`. -pub fn month_end(day: Day) -> Day { - let (y, m, _) = to_ymd(day); - from_ymd(y, m, days_in_month(y, m)) -} - -/// Move whole months, clamping the day of month — 31 January plus one -/// month is 28 February, which is what a monthly bill on the 31st does. -pub fn add_months(day: Day, months: i32) -> Day { - let (y, m, d) = to_ymd(day); - let total = y * 12 + (m as i32 - 1) + months; - let (ny, nm) = (total.div_euclid(12), total.rem_euclid(12) as u32 + 1); - from_ymd(ny, nm, d.min(days_in_month(ny, nm))) -} - -/// A month as a sortable integer key, `year * 12 + (month - 1)` — what -/// budgets and monthly rollups are keyed by. -pub type MonthKey = i32; - -pub fn month_key(day: Day) -> MonthKey { - let (y, m, _) = to_ymd(day); - y * 12 + (m as i32 - 1) -} - -pub fn month_key_start(key: MonthKey) -> Day { - from_ymd(key.div_euclid(12), key.rem_euclid(12) as u32 + 1, 1) -} - -pub const MONTH_NAMES: [&str; 12] = [ - "January", "February", "March", "April", "May", "June", "July", "August", - "September", "October", "November", "December", -]; - -pub const MONTH_ABBR: [&str; 12] = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", -]; - -pub const WEEKDAY_ABBR: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; - -/// `2024-03-04` — the storage form, and the only unambiguous one. -pub fn format_iso(day: Day) -> String { - let (y, m, d) = to_ymd(day); - format!("{y:04}-{m:02}-{d:02}") -} - -/// `4 Mar 2024` — the ledger form: unambiguous to a human, and short. -pub fn format_short(day: Day) -> String { - let (y, m, d) = to_ymd(day); - format!("{d} {} {y}", MONTH_ABBR[(m - 1) as usize]) -} - -/// `Mar 2024` — column headers on a budget. -pub fn format_month(key: MonthKey) -> String { - let year = key.div_euclid(12); - let month = key.rem_euclid(12) as usize; - format!("{} {year}", MONTH_ABBR[month]) -} - -/// How the dates in an imported column are written. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub struct DateFormat { - /// The order of the numeric fields. - pub order: FieldOrder, - /// Two-digit years, which need a century guess. - pub two_digit_year: bool, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum FieldOrder { - /// `2024-03-04`, ISO 8601. The default because it is the only order - /// that cannot be misread. - #[default] - Ymd, - /// `04/03/2024` — most of the world. - Dmy, - /// `03/04/2024` — the United States. - Mdy, -} - -/// What a column of dates turned out to be, and whether we are sure. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct DateSniff { - pub format: DateFormat, - /// False when every row was ambiguous (no day above 12 anywhere), so - /// the order is a guess and the import screen must offer the choice. - pub certain: bool, - /// Rows that parsed under the chosen format. - pub parsed: usize, - /// Rows that did not parse at all. - pub failed: usize, -} - -/// Read a whole column of dates and work out how it is written. -/// -/// The rule that does the work: in `a/b/c`, if any row has `a > 12` the -/// first field must be a day, and if any row has `b > 12` the second must -/// be. One such row decides the column. If none does — a statement whose -/// every transaction lands in the first twelve days of a month — the order -/// stays a guess, `certain` is false, and the caller asks. -pub fn sniff_date_format<'a>(cells: impl Iterator) -> DateSniff { - let mut first_over_12 = false; - let mut second_over_12 = false; - let mut iso = 0usize; - let mut two_digit = 0usize; - let mut numeric = 0usize; - let mut total = 0usize; - let mut samples: Vec<[u32; 3]> = Vec::new(); - - for cell in cells { - let cell = cell.trim(); - if cell.is_empty() { - continue; - } - total += 1; - let Some((fields, year_digits)) = split_numeric_date(cell) else { - // Named-month forms ("4 Mar 2024") are self-describing and - // vote for nothing. - continue; - }; - numeric += 1; - if year_digits == 2 { - two_digit += 1; - } - if fields[0] > 31 { - iso += 1; // a 4-digit year leading: 2024-03-04 - } else { - if fields[0] > 12 { - first_over_12 = true; - } - if fields[1] > 12 { - second_over_12 = true; - } - samples.push(fields); - } - } - - let order = if iso > 0 && iso >= numeric / 2 { - FieldOrder::Ymd - } else if first_over_12 { - FieldOrder::Dmy - } else if second_over_12 { - FieldOrder::Mdy - } else { - // Nothing decisive. Day-first is the world's convention and the - // safer default; `certain: false` is what actually matters here. - FieldOrder::Dmy - }; - let format = DateFormat { order, two_digit_year: two_digit > numeric / 2 }; - let certain = matches!(order, FieldOrder::Ymd) || first_over_12 || second_over_12; - DateSniff { format, certain, parsed: numeric, failed: total - numeric } -} - -/// Split `04/03/2024`, `04-03-2024`, `04.03.2024` into its three numbers, -/// with the digit count of the field that looks like a year. -fn split_numeric_date(text: &str) -> Option<([u32; 3], usize)> { - let head: &str = text.split_whitespace().next().unwrap_or(text); - let mut fields = [0u32; 3]; - let mut widths = [0usize; 3]; - let mut index = 0usize; - let mut digits = 0usize; - let mut current = 0u32; - for ch in head.chars() { - if let Some(d) = ch.to_digit(10) { - current = current.checked_mul(10)?.checked_add(d)?; - digits += 1; - } else if matches!(ch, '/' | '-' | '.') { - if index >= 2 || digits == 0 { - return None; - } - fields[index] = current; - widths[index] = digits; - index += 1; - current = 0; - digits = 0; - } else { - return None; - } - } - if index != 2 || digits == 0 { - return None; - } - fields[2] = current; - widths[2] = digits; - let year_digits = if widths[0] == 4 { widths[0] } else { widths[2] }; - Some((fields, year_digits)) -} - -/// Two digits to a century: the 69/70 split every system uses, biased so -/// that a statement from '99 is 1999 and one from '24 is 2024. -fn expand_year(year: u32) -> i32 { - if year >= 100 { - year as i32 - } else if year >= 70 { - 1900 + year as i32 - } else { - 2000 + year as i32 - } -} - -/// Parse one cell under a known format. Also understands ISO and named -/// months regardless of `format`, since those are unambiguous. -pub fn parse_date(text: &str, format: DateFormat) -> Option { - let text = text.trim(); - if text.is_empty() { - return None; - } - if let Some((fields, _)) = split_numeric_date(text) { - let (y, m, d) = if fields[0] > 31 { - (fields[0], fields[1], fields[2]) // leading 4-digit year: ISO - } else { - match format.order { - FieldOrder::Ymd => (fields[0], fields[1], fields[2]), - FieldOrder::Dmy => (fields[2], fields[1], fields[0]), - FieldOrder::Mdy => (fields[2], fields[0], fields[1]), - } - }; - return valid_ymd(expand_year(y), m, d); - } - parse_named_month(text) -} - -/// `4 Mar 2024`, `Mar 4, 2024`, `4 March 2024`, `2024 Mar 4`. -fn parse_named_month(text: &str) -> Option { - let cleaned: String = text - .chars() - .map(|c| if c == ',' { ' ' } else { c }) - .collect(); - let mut month = None; - let mut numbers: Vec = Vec::new(); - for word in cleaned.split_whitespace() { - let lower = word.to_ascii_lowercase(); - if let Some(index) = MONTH_ABBR - .iter() - .position(|m| lower.starts_with(&m.to_ascii_lowercase())) - { - if month.is_none() { - month = Some(index as u32 + 1); - continue; - } - } - let digits: String = word.chars().filter(|c| c.is_ascii_digit()).collect(); - if !digits.is_empty() { - if let Ok(value) = digits.parse::() { - numbers.push(value); - } - } - } - let month = month?; - if numbers.len() < 2 { - return None; - } - // Whichever number could not be a day is the year. - let (day, year) = if numbers[0] > 31 { - (numbers[1], numbers[0]) - } else { - (numbers[0], numbers[1]) - }; - valid_ymd(expand_year(year), month, day) -} - -fn valid_ymd(year: i32, month: u32, day: u32) -> Option { - if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) { - return None; - } - if !(1900..=2200).contains(&year) { - return None; - } - Some(from_ymd(year, month, day)) -} - -/// A closed range of days, which is what every report and filter is. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct DateRange { - pub start: Day, - pub end: Day, -} - -impl DateRange { - pub fn contains(&self, day: Day) -> bool { - day >= self.start && day <= self.end - } - - pub fn days(&self) -> i32 { - self.end - self.start + 1 - } - - pub fn month(key: MonthKey) -> DateRange { - let start = month_key_start(key); - DateRange { start, end: month_end(start) } - } - - /// The last `n` whole months ending with the month of `day`. - pub fn last_months(day: Day, n: i32) -> DateRange { - let end = month_end(day); - let start = month_start(add_months(day, -(n - 1))); - DateRange { start, end } - } - - pub fn year(year: i32) -> DateRange { - DateRange { start: from_ymd(year, 1, 1), end: from_ymd(year, 12, 31) } - } -} - -impl fmt::Display for DateRange { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} – {}", format_short(self.start), format_short(self.end)) - } -} - -/// Today, from the system clock. The one place time enters the app. -pub fn today() -> Day { - let secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - (secs / 86_400) as Day -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn civil_conversion_round_trips_across_centuries() { - assert_eq!(from_ymd(1970, 1, 1), 0); - assert_eq!(to_ymd(0), (1970, 1, 1)); - assert_eq!(from_ymd(2024, 2, 29), 19782); - assert_eq!(to_ymd(19782), (2024, 2, 29)); - assert_eq!(from_ymd(1969, 12, 31), -1); - assert_eq!(to_ymd(-1), (1969, 12, 31)); - // Every day of a leap year and a century year round trips. - for day in from_ymd(1999, 1, 1)..=from_ymd(2001, 12, 31) { - let (y, m, d) = to_ymd(day); - assert_eq!(from_ymd(y, m, d), day); - } - } - - #[test] - fn weekday_and_month_edges() { - assert_eq!(weekday(from_ymd(1970, 1, 1)), 3); // Thursday - assert_eq!(weekday(from_ymd(2024, 3, 4)), 0); // Monday - assert!(is_weekend(from_ymd(2024, 3, 9))); - assert_eq!(month_start(from_ymd(2024, 3, 15)), from_ymd(2024, 3, 1)); - assert_eq!(month_end(from_ymd(2024, 2, 15)), from_ymd(2024, 2, 29)); - assert_eq!(days_in_month(2023, 2), 28); - assert_eq!(days_in_month(2000, 2), 29); - assert_eq!(days_in_month(1900, 2), 28); - } - - #[test] - fn monthly_bills_clamp_to_the_end_of_short_months() { - let jan31 = from_ymd(2024, 1, 31); - assert_eq!(to_ymd(add_months(jan31, 1)), (2024, 2, 29)); - assert_eq!(to_ymd(add_months(jan31, 13)), (2025, 2, 28)); - assert_eq!(to_ymd(add_months(from_ymd(2024, 3, 15), -3)), (2023, 12, 15)); - } - - #[test] - fn month_keys_sort_and_invert() { - let key = month_key(from_ymd(2024, 3, 4)); - assert_eq!(month_key_start(key), from_ymd(2024, 3, 1)); - assert!(month_key(from_ymd(2024, 1, 1)) < month_key(from_ymd(2024, 2, 1))); - assert_eq!(format_month(month_key(from_ymd(2024, 3, 4))), "Mar 2024"); - } - - #[test] - fn the_ambiguous_column_is_settled_by_one_decisive_row() { - // 13 can only be a day: the whole column is day-first. - let eu = ["04/03/2024", "13/03/2024", "01/04/2024"]; - let sniff = sniff_date_format(eu.iter().copied()); - assert_eq!(sniff.format.order, FieldOrder::Dmy); - assert!(sniff.certain); - assert_eq!(parse_date("04/03/2024", sniff.format), Some(from_ymd(2024, 3, 4))); - - // 13 in the second field: month-first. - let us = ["03/04/2024", "03/13/2024", "04/01/2024"]; - let sniff = sniff_date_format(us.iter().copied()); - assert_eq!(sniff.format.order, FieldOrder::Mdy); - assert!(sniff.certain); - // The same eight characters, read the other way round: month 03, - // day 04 — which is the whole reason the column has to vote. - assert_eq!(parse_date("03/04/2024", sniff.format), Some(from_ymd(2024, 3, 4))); - assert_eq!(parse_date("04/01/2024", sniff.format), Some(from_ymd(2024, 4, 1))); - - // Nothing decisive: we guess, but we SAY we guessed. - let ambiguous = ["03/04/2024", "05/06/2024"]; - let sniff = sniff_date_format(ambiguous.iter().copied()); - assert!(!sniff.certain); - - // ISO needs no guessing. - let iso = ["2024-03-04", "2024-03-13"]; - let sniff = sniff_date_format(iso.iter().copied()); - assert_eq!(sniff.format.order, FieldOrder::Ymd); - assert!(sniff.certain); - } - - #[test] - fn parses_the_forms_banks_write() { - let dmy = DateFormat { order: FieldOrder::Dmy, two_digit_year: false }; - assert_eq!(parse_date("04.03.2024", dmy), Some(from_ymd(2024, 3, 4))); - assert_eq!(parse_date("04-03-2024", dmy), Some(from_ymd(2024, 3, 4))); - assert_eq!(parse_date("04/03/24", dmy), Some(from_ymd(2024, 3, 4))); - // ISO and named months parse under any declared order. - assert_eq!(parse_date("2024-03-04", dmy), Some(from_ymd(2024, 3, 4))); - assert_eq!(parse_date("4 Mar 2024", dmy), Some(from_ymd(2024, 3, 4))); - assert_eq!(parse_date("Mar 4, 2024", dmy), Some(from_ymd(2024, 3, 4))); - assert_eq!(parse_date("4 March 2024", dmy), Some(from_ymd(2024, 3, 4))); - // Impossible dates are rejected, not clamped. - assert_eq!(parse_date("31/02/2024", dmy), None); - assert_eq!(parse_date("00/01/2024", dmy), None); - assert_eq!(parse_date("hello", dmy), None); - // Two-digit years split at 70. - assert_eq!(parse_date("01/01/99", dmy), Some(from_ymd(1999, 1, 1))); - assert_eq!(parse_date("01/01/24", dmy), Some(from_ymd(2024, 1, 1))); - } - - #[test] - fn ranges_cover_what_reports_ask_for() { - let day = from_ymd(2024, 3, 15); - let last_3 = DateRange::last_months(day, 3); - assert_eq!(last_3.start, from_ymd(2024, 1, 1)); - assert_eq!(last_3.end, from_ymd(2024, 3, 31)); - assert!(last_3.contains(from_ymd(2024, 2, 29))); - assert!(!last_3.contains(from_ymd(2023, 12, 31))); - assert_eq!(DateRange::year(2024).days(), 366); - } -} diff --git a/apps/finance/src/db.rs b/apps/finance/src/db.rs deleted file mode 100644 index 15970f0ff..000000000 --- a/apps/finance/src/db.rs +++ /dev/null @@ -1,829 +0,0 @@ -//! The SQLite file, and the load that turns it into a [`Ledger`]. -//! -//! The database is the file format — one `.finance` file you can copy, -//! back up, and open with any SQLite tool, which is the whole reason for -//! choosing it over a private binary. But no screen queries it. Everything -//! is read once into memory ([`Ledger`]) and written through on change, -//! because the queries a finance app makes — "every transaction of this -//! account with its running balance", "spend per category per month for -//! two years" — are passes over a few hundred thousand small rows, and -//! that is microseconds in RAM against milliseconds per round trip in SQL. -//! It is also why this app stays fast where the commercial ones famously -//! do not: the reports never touch the disk. -//! -//! Schema changes go in [`MIGRATIONS`], never by editing [`SCHEMA`]: an -//! existing file must survive an upgrade. `user_version` records how far a -//! file has come. - -use crate::date::Day; -use crate::model::*; -use crate::money::{currency_by_code, Currency, USD}; -use makepad_sqlite::{Connection, Value}; -use std::path::Path; -use std::time::Duration; - -/// Bumped whenever [`MIGRATIONS`] grows. -pub const SCHEMA_VERSION: i64 = 1; - -const SCHEMA: &str = " -CREATE TABLE IF NOT EXISTS accounts( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - kind TEXT NOT NULL, - currency TEXT NOT NULL DEFAULT 'USD', - institution TEXT NOT NULL DEFAULT '', - opening_balance INTEGER NOT NULL DEFAULT 0, - opening_date INTEGER NOT NULL DEFAULT 0, - closed INTEGER NOT NULL DEFAULT 0, - off_budget INTEGER NOT NULL DEFAULT 0, - sort_order INTEGER NOT NULL DEFAULT 0, - note TEXT NOT NULL DEFAULT '' -); -CREATE TABLE IF NOT EXISTS categories( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - parent INTEGER, - kind TEXT NOT NULL DEFAULT 'expense', - budgeted INTEGER NOT NULL DEFAULT 1, - rollover INTEGER NOT NULL DEFAULT 0, - sort_order INTEGER NOT NULL DEFAULT 0, - color INTEGER NOT NULL DEFAULT 0, - hidden INTEGER NOT NULL DEFAULT 0 -); -CREATE TABLE IF NOT EXISTS transactions( - id INTEGER PRIMARY KEY, - account INTEGER NOT NULL, - date INTEGER NOT NULL, - payee TEXT NOT NULL DEFAULT '', - memo TEXT NOT NULL DEFAULT '', - amount INTEGER NOT NULL, - category INTEGER, - transfer_group INTEGER, - cleared TEXT NOT NULL DEFAULT 'uncleared', - statement INTEGER, - import_hash INTEGER, - reference TEXT NOT NULL DEFAULT '', - flagged INTEGER NOT NULL DEFAULT 0, - notes TEXT NOT NULL DEFAULT '' -); -CREATE INDEX IF NOT EXISTS transactions_account_date ON transactions(account, date); -CREATE INDEX IF NOT EXISTS transactions_date ON transactions(date); -CREATE INDEX IF NOT EXISTS transactions_import_hash ON transactions(import_hash); -CREATE TABLE IF NOT EXISTS splits( - id INTEGER PRIMARY KEY, - txn INTEGER NOT NULL, - category INTEGER, - amount INTEGER NOT NULL, - memo TEXT NOT NULL DEFAULT '' -); -CREATE INDEX IF NOT EXISTS splits_txn ON splits(txn); -CREATE TABLE IF NOT EXISTS payees( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - default_category INTEGER -); -CREATE TABLE IF NOT EXISTS rules( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL DEFAULT '', - match_on TEXT NOT NULL DEFAULT 'raw', - how TEXT NOT NULL DEFAULT 'contains', - pattern TEXT NOT NULL DEFAULT '', - amount_min INTEGER NOT NULL DEFAULT 0, - amount_max INTEGER NOT NULL DEFAULT 0, - set_category INTEGER, - rename_payee TEXT, - set_memo TEXT, - flag INTEGER NOT NULL DEFAULT 0, - priority INTEGER NOT NULL DEFAULT 0, - enabled INTEGER NOT NULL DEFAULT 1, - hits INTEGER NOT NULL DEFAULT 0 -); -CREATE TABLE IF NOT EXISTS budgets( - category INTEGER NOT NULL, - month INTEGER NOT NULL, - assigned INTEGER NOT NULL DEFAULT 0, - rollover INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY(category, month) -); -CREATE TABLE IF NOT EXISTS scheduled( - id INTEGER PRIMARY KEY, - account INTEGER NOT NULL, - payee TEXT NOT NULL DEFAULT '', - amount INTEGER NOT NULL DEFAULT 0, - category INTEGER, - recurrence TEXT NOT NULL DEFAULT 'monthly', - next_due INTEGER NOT NULL DEFAULT 0, - last_posted INTEGER, - auto_post INTEGER NOT NULL DEFAULT 0, - enabled INTEGER NOT NULL DEFAULT 1, - detected INTEGER NOT NULL DEFAULT 0 -); -CREATE TABLE IF NOT EXISTS import_profiles( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - account INTEGER, - mapping TEXT NOT NULL DEFAULT '', - date_order TEXT NOT NULL DEFAULT 'ymd', - decimal_comma INTEGER NOT NULL DEFAULT 0, - delimiter TEXT NOT NULL DEFAULT ',', - used INTEGER NOT NULL DEFAULT 0 -); -CREATE TABLE IF NOT EXISTS settings( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); -"; - -/// Each entry runs once, in order, on a file whose `user_version` is below -/// its index + 1. Append only — never edit one that has shipped. -const MIGRATIONS: [&str; 0] = []; - -pub struct Db { - conn: Connection, -} - -impl Db { - /// Open (creating if absent) and bring the schema up to date. - pub fn open(path: &Path) -> Result { - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?; - } - let mut conn = Connection::open(path, Duration::from_secs(5)) - .map_err(|e| format!("open {}: {e:?}", path.display()))?; - // A ledger is one table scan wide, not a hundred: the default row - // budget would refuse a decade of transactions in one query. - conn.limits_mut().max_rows = 5_000_000; - conn.limits_mut().max_steps = 500_000_000; - conn.execute_batch(SCHEMA).map_err(|e| format!("schema: {e:?}"))?; - let mut db = Db { conn }; - db.migrate()?; - Ok(db) - } - - fn migrate(&mut self) -> Result<(), String> { - let from = self.conn.user_version().max(0) as usize; - for (index, sql) in MIGRATIONS.iter().enumerate().skip(from) { - self.conn - .execute_batch(sql) - .map_err(|e| format!("migration {}: {e:?}", index + 1))?; - } - if from < MIGRATIONS.len() { - self.conn - .execute(&format!("PRAGMA user_version = {}", MIGRATIONS.len()), &[]) - .map_err(|e| format!("set user_version: {e:?}"))?; - } - Ok(()) - } - - /// True for a file with no accounts — a first run, which is what the - /// demo data offers to fill. - pub fn is_empty(&mut self) -> Result { - let result = self - .conn - .query("SELECT COUNT(*) FROM accounts", &[]) - .map_err(|e| format!("count accounts: {e:?}"))?; - Ok(result.scalar().and_then(|v| v.as_integer()).unwrap_or(0) == 0) - } - - /// Read the whole file into memory. Every screen reads the result; - /// nothing else queries. - pub fn load(&mut self) -> Result { - let mut ledger = Ledger { base_currency: self.base_currency(), ..Ledger::default() }; - - let rows = self - .conn - .query( - "SELECT id, name, kind, currency, institution, opening_balance, opening_date, \ - closed, off_budget, sort_order, note FROM accounts ORDER BY sort_order, id", - &[], - ) - .map_err(|e| format!("load accounts: {e:?}"))?; - for row in &rows.rows { - ledger.accounts.push(Account { - id: int(&row[0]), - name: text(&row[1]), - kind: AccountKind::from_str(&text(&row[2])), - currency: currency_by_code(&text(&row[3])).unwrap_or(USD), - institution: text(&row[4]), - opening_balance: int(&row[5]), - opening_date: int(&row[6]) as Day, - closed: int(&row[7]) != 0, - off_budget: int(&row[8]) != 0, - sort_order: int(&row[9]) as i32, - note: text(&row[10]), - }); - } - - let rows = self - .conn - .query( - "SELECT id, name, parent, kind, budgeted, rollover, sort_order, color, hidden \ - FROM categories ORDER BY sort_order, id", - &[], - ) - .map_err(|e| format!("load categories: {e:?}"))?; - for row in &rows.rows { - ledger.categories.categories.push(Category { - id: int(&row[0]), - name: text(&row[1]), - parent: opt_int(&row[2]), - kind: CategoryKind::from_str(&text(&row[3])), - budgeted: int(&row[4]) != 0, - rollover: int(&row[5]) != 0, - sort_order: int(&row[6]) as i32, - color: int(&row[7]) as u32, - hidden: int(&row[8]) != 0, - }); - } - - let rows = self - .conn - .query( - "SELECT id, account, date, payee, memo, amount, category, transfer_group, \ - cleared, statement, import_hash, reference, flagged, notes \ - FROM transactions ORDER BY date, id", - &[], - ) - .map_err(|e| format!("load transactions: {e:?}"))?; - ledger.transactions.reserve(rows.rows.len()); - for row in &rows.rows { - ledger.transactions.push(Transaction { - id: int(&row[0]), - account: int(&row[1]), - date: int(&row[2]) as Day, - payee: text(&row[3]), - memo: text(&row[4]), - amount: int(&row[5]), - category: opt_int(&row[6]), - splits: Vec::new(), - transfer_group: opt_int(&row[7]), - cleared: Cleared::from_str(&text(&row[8])), - statement: opt_int(&row[9]), - import_hash: opt_int(&row[10]), - reference: text(&row[11]), - flagged: int(&row[12]) != 0, - notes: text(&row[13]), - }); - } - - // Splits come back in one query and are distributed by id, rather - // than a query per transaction. - let rows = self - .conn - .query("SELECT id, txn, category, amount, memo FROM splits ORDER BY txn, id", &[]) - .map_err(|e| format!("load splits: {e:?}"))?; - if !rows.rows.is_empty() { - let mut index: std::collections::HashMap = - std::collections::HashMap::with_capacity(ledger.transactions.len()); - for (position, txn) in ledger.transactions.iter().enumerate() { - index.insert(txn.id, position); - } - for row in &rows.rows { - let Some(position) = index.get(&int(&row[1])) else { continue }; - ledger.transactions[*position].splits.push(Split { - id: int(&row[0]), - category: opt_int(&row[2]), - amount: int(&row[3]), - memo: text(&row[4]), - }); - } - } - - let rows = self - .conn - .query("SELECT category, month, assigned, rollover FROM budgets", &[]) - .map_err(|e| format!("load budgets: {e:?}"))?; - for row in &rows.rows { - ledger.budgets.push(BudgetEntry { - category: int(&row[0]), - month: int(&row[1]) as i32, - assigned: int(&row[2]), - rollover: int(&row[3]) != 0, - }); - } - - let rows = self - .conn - .query( - "SELECT id, name, match_on, how, pattern, amount_min, amount_max, set_category, \ - rename_payee, set_memo, flag, priority, enabled, hits FROM rules \ - ORDER BY priority, id", - &[], - ) - .map_err(|e| format!("load rules: {e:?}"))?; - for row in &rows.rows { - ledger.rules.push(Rule { - id: int(&row[0]), - name: text(&row[1]), - match_on: match_on_from_str(&text(&row[2])), - how: match_how_from_str(&text(&row[3])), - pattern: text(&row[4]), - amount_min: int(&row[5]), - amount_max: int(&row[6]), - set_category: opt_int(&row[7]), - rename_payee: opt_text(&row[8]), - set_memo: opt_text(&row[9]), - flag: int(&row[10]) != 0, - priority: int(&row[11]) as i32, - enabled: int(&row[12]) != 0, - hits: int(&row[13]), - }); - } - - let rows = self - .conn - .query( - "SELECT id, account, payee, amount, category, recurrence, next_due, last_posted, \ - auto_post, enabled, detected FROM scheduled ORDER BY next_due, id", - &[], - ) - .map_err(|e| format!("load scheduled: {e:?}"))?; - for row in &rows.rows { - ledger.scheduled.push(Scheduled { - id: int(&row[0]), - account: int(&row[1]), - payee: text(&row[2]), - amount: int(&row[3]), - category: opt_int(&row[4]), - recurrence: recurrence_from_str(&text(&row[5])), - next_due: int(&row[6]) as Day, - last_posted: opt_int(&row[7]).map(|v| v as Day), - auto_post: int(&row[8]) != 0, - enabled: int(&row[9]) != 0, - detected: int(&row[10]) != 0, - }); - } - - let rows = self - .conn - .query("SELECT id, name, default_category FROM payees ORDER BY name", &[]) - .map_err(|e| format!("load payees: {e:?}"))?; - for row in &rows.rows { - ledger.payees.push(Payee { - id: int(&row[0]), - name: text(&row[1]), - default_category: opt_int(&row[2]), - transactions: 0, - }); - } - - Ok(ledger) - } - - fn base_currency(&mut self) -> Currency { - self.conn - .query("SELECT value FROM settings WHERE key = 'base_currency'", &[]) - .ok() - .and_then(|r| r.scalar().and_then(|v| v.as_text().map(str::to_string))) - .and_then(|code| currency_by_code(&code)) - .unwrap_or(USD) - } - - pub fn set_setting(&mut self, key: &str, value: &str) -> Result<(), String> { - self.conn - .execute( - "INSERT INTO settings(key, value) VALUES(?, ?) \ - ON CONFLICT(key) DO UPDATE SET value = ?", - &[Value::text(key), Value::text(value), Value::text(value)], - ) - .map(|_| ()) - .map_err(|e| format!("set {key}: {e:?}")) - } - - /// Run `body` inside one transaction, rolling back if it fails. Every - /// multi-row write goes through this: a half-written import is worse - /// than a refused one. - pub fn transact( - &mut self, - body: impl FnOnce(&mut Connection) -> Result, - ) -> Result { - self.conn.execute("BEGIN", &[]).map_err(|e| format!("begin: {e:?}"))?; - match body(&mut self.conn) { - Ok(value) => { - self.conn.execute("COMMIT", &[]).map_err(|e| format!("commit: {e:?}"))?; - Ok(value) - } - Err(error) => { - let _ = self.conn.execute("ROLLBACK", &[]); - Err(error) - } - } - } - - /// Insert one account, returning the id the database assigned. - pub fn insert_account(&mut self, account: &Account) -> Result { - self.conn - .execute( - "INSERT INTO accounts(name, kind, currency, institution, opening_balance, \ - opening_date, closed, off_budget, sort_order, note) \ - VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - &[ - Value::text(account.name.as_str()), - Value::text(account.kind.as_str()), - Value::text(account.currency.code), - Value::text(account.institution.as_str()), - Value::Integer(account.opening_balance), - Value::Integer(account.opening_date as i64), - Value::Integer(account.closed as i64), - Value::Integer(account.off_budget as i64), - Value::Integer(account.sort_order as i64), - Value::text(account.note.as_str()), - ], - ) - .map_err(|e| format!("insert account: {e:?}"))?; - self.last_id("accounts") - } - - pub fn insert_category(&mut self, category: &Category) -> Result { - self.conn - .execute( - "INSERT INTO categories(name, parent, kind, budgeted, rollover, sort_order, \ - color, hidden) VALUES(?, ?, ?, ?, ?, ?, ?, ?)", - &[ - Value::text(category.name.as_str()), - category.parent.map(Value::Integer).unwrap_or(Value::Null), - Value::text(category.kind.as_str()), - Value::Integer(category.budgeted as i64), - Value::Integer(category.rollover as i64), - Value::Integer(category.sort_order as i64), - Value::Integer(category.color as i64), - Value::Integer(category.hidden as i64), - ], - ) - .map_err(|e| format!("insert category: {e:?}"))?; - self.last_id("categories") - } - - pub fn insert_transaction(&mut self, txn: &Transaction) -> Result { - insert_transaction_on(&mut self.conn, txn)?; - let id = self.last_id("transactions")?; - for split in &txn.splits { - insert_split_on(&mut self.conn, id, split)?; - } - Ok(id) - } - - pub fn insert_budget(&mut self, entry: &BudgetEntry) -> Result<(), String> { - self.conn - .execute( - "INSERT INTO budgets(category, month, assigned, rollover) VALUES(?, ?, ?, ?) \ - ON CONFLICT(category, month) DO UPDATE SET assigned = ?, rollover = ?", - &[ - Value::Integer(entry.category), - Value::Integer(entry.month as i64), - Value::Integer(entry.assigned), - Value::Integer(entry.rollover as i64), - Value::Integer(entry.assigned), - Value::Integer(entry.rollover as i64), - ], - ) - .map(|_| ()) - .map_err(|e| format!("insert budget: {e:?}")) - } - - pub fn insert_scheduled(&mut self, item: &Scheduled) -> Result { - self.conn - .execute( - "INSERT INTO scheduled(account, payee, amount, category, recurrence, next_due, \ - last_posted, auto_post, enabled, detected) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - &[ - Value::Integer(item.account), - Value::text(item.payee.as_str()), - Value::Integer(item.amount), - item.category.map(Value::Integer).unwrap_or(Value::Null), - Value::text(recurrence_to_str(item.recurrence)), - Value::Integer(item.next_due as i64), - item.last_posted.map(|d| Value::Integer(d as i64)).unwrap_or(Value::Null), - Value::Integer(item.auto_post as i64), - Value::Integer(item.enabled as i64), - Value::Integer(item.detected as i64), - ], - ) - .map_err(|e| format!("insert scheduled: {e:?}"))?; - self.last_id("scheduled") - } - - pub fn insert_rule(&mut self, rule: &Rule) -> Result { - self.conn - .execute( - "INSERT INTO rules(name, match_on, how, pattern, amount_min, amount_max, \ - set_category, rename_payee, set_memo, flag, priority, enabled, hits) \ - VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - &[ - Value::text(rule.name.as_str()), - Value::text(match_on_to_str(rule.match_on)), - Value::text(match_how_to_str(rule.how)), - Value::text(rule.pattern.as_str()), - Value::Integer(rule.amount_min), - Value::Integer(rule.amount_max), - rule.set_category.map(Value::Integer).unwrap_or(Value::Null), - rule.rename_payee - .as_deref() - .map(Value::text) - .unwrap_or(Value::Null), - rule.set_memo.as_deref().map(Value::text).unwrap_or(Value::Null), - Value::Integer(rule.flag as i64), - Value::Integer(rule.priority as i64), - Value::Integer(rule.enabled as i64), - Value::Integer(rule.hits), - ], - ) - .map_err(|e| format!("insert rule: {e:?}"))?; - self.last_id("rules") - } - - /// Update the fields the ledger screen can edit. Splits are replaced - /// wholesale — there are never more than a handful. - pub fn update_transaction(&mut self, txn: &Transaction) -> Result<(), String> { - self.conn - .execute( - "UPDATE transactions SET account = ?, date = ?, payee = ?, memo = ?, amount = ?, \ - category = ?, transfer_group = ?, cleared = ?, reference = ?, flagged = ?, \ - notes = ? WHERE id = ?", - &[ - Value::Integer(txn.account), - Value::Integer(txn.date as i64), - Value::text(txn.payee.as_str()), - Value::text(txn.memo.as_str()), - Value::Integer(txn.amount), - txn.category.map(Value::Integer).unwrap_or(Value::Null), - txn.transfer_group.map(Value::Integer).unwrap_or(Value::Null), - Value::text(txn.cleared.as_str()), - Value::text(txn.reference.as_str()), - Value::Integer(txn.flagged as i64), - Value::text(txn.notes.as_str()), - Value::Integer(txn.id), - ], - ) - .map_err(|e| format!("update transaction: {e:?}"))?; - self.conn - .execute("DELETE FROM splits WHERE txn = ?", &[Value::Integer(txn.id)]) - .map_err(|e| format!("clear splits: {e:?}"))?; - for split in &txn.splits { - insert_split_on(&mut self.conn, txn.id, split)?; - } - Ok(()) - } - - pub fn delete_transaction(&mut self, id: Id) -> Result<(), String> { - self.conn - .execute("DELETE FROM splits WHERE txn = ?", &[Value::Integer(id)]) - .map_err(|e| format!("delete splits: {e:?}"))?; - self.conn - .execute("DELETE FROM transactions WHERE id = ?", &[Value::Integer(id)]) - .map(|_| ()) - .map_err(|e| format!("delete transaction: {e:?}")) - } - - /// Import fingerprints already in the file, so an import can tell what - /// it has seen before without re-reading every transaction. - pub fn known_fingerprints(&mut self) -> Result, String> { - let rows = self - .conn - .query( - "SELECT import_hash FROM transactions WHERE import_hash IS NOT NULL", - &[], - ) - .map_err(|e| format!("load fingerprints: {e:?}"))?; - Ok(rows.rows.iter().filter_map(|r| r[0].as_integer()).collect()) - } - - fn last_id(&mut self, table: &str) -> Result { - let rows = self - .conn - .query(&format!("SELECT MAX(id) FROM {table}"), &[]) - .map_err(|e| format!("last id {table}: {e:?}"))?; - Ok(rows.scalar().and_then(|v| v.as_integer()).unwrap_or(0)) - } -} - -/// Insert on a borrowed connection, so a batch can run inside one -/// [`Db::transact`] without re-borrowing `Db`. -pub fn insert_transaction_on(conn: &mut Connection, txn: &Transaction) -> Result<(), String> { - conn.execute( - "INSERT INTO transactions(account, date, payee, memo, amount, category, transfer_group, \ - cleared, statement, import_hash, reference, flagged, notes) \ - VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - &[ - Value::Integer(txn.account), - Value::Integer(txn.date as i64), - Value::text(txn.payee.as_str()), - Value::text(txn.memo.as_str()), - Value::Integer(txn.amount), - txn.category.map(Value::Integer).unwrap_or(Value::Null), - txn.transfer_group.map(Value::Integer).unwrap_or(Value::Null), - Value::text(txn.cleared.as_str()), - txn.statement.map(Value::Integer).unwrap_or(Value::Null), - txn.import_hash.map(Value::Integer).unwrap_or(Value::Null), - Value::text(txn.reference.as_str()), - Value::Integer(txn.flagged as i64), - Value::text(txn.notes.as_str()), - ], - ) - .map(|_| ()) - .map_err(|e| format!("insert transaction: {e:?}")) -} - -fn insert_split_on(conn: &mut Connection, txn: Id, split: &Split) -> Result<(), String> { - conn.execute( - "INSERT INTO splits(txn, category, amount, memo) VALUES(?, ?, ?, ?)", - &[ - Value::Integer(txn), - split.category.map(Value::Integer).unwrap_or(Value::Null), - Value::Integer(split.amount), - Value::text(split.memo.as_str()), - ], - ) - .map(|_| ()) - .map_err(|e| format!("insert split: {e:?}")) -} - -// ------------------------------------------------------------ value readers - -fn int(value: &Value) -> i64 { - value.as_integer().unwrap_or(0) -} - -fn opt_int(value: &Value) -> Option { - value.as_integer() -} - -fn text(value: &Value) -> String { - value.as_text().unwrap_or("").to_string() -} - -fn opt_text(value: &Value) -> Option { - value.as_text().map(str::to_string) -} - -fn match_on_to_str(value: MatchOn) -> &'static str { - match value { - MatchOn::Payee => "payee", - MatchOn::Memo => "memo", - MatchOn::Raw => "raw", - MatchOn::Amount => "amount", - } -} - -fn match_on_from_str(value: &str) -> MatchOn { - match value { - "payee" => MatchOn::Payee, - "memo" => MatchOn::Memo, - "amount" => MatchOn::Amount, - _ => MatchOn::Raw, - } -} - -fn match_how_to_str(value: MatchHow) -> &'static str { - match value { - MatchHow::Contains => "contains", - MatchHow::StartsWith => "starts_with", - MatchHow::Equals => "equals", - MatchHow::AmountEquals => "amount_equals", - MatchHow::AmountBetween => "amount_between", - } -} - -fn match_how_from_str(value: &str) -> MatchHow { - match value { - "starts_with" => MatchHow::StartsWith, - "equals" => MatchHow::Equals, - "amount_equals" => MatchHow::AmountEquals, - "amount_between" => MatchHow::AmountBetween, - _ => MatchHow::Contains, - } -} - -fn recurrence_to_str(value: Recurrence) -> &'static str { - match value { - Recurrence::Weekly => "weekly", - Recurrence::Fortnightly => "fortnightly", - Recurrence::Monthly => "monthly", - Recurrence::Quarterly => "quarterly", - Recurrence::Yearly => "yearly", - } -} - -fn recurrence_from_str(value: &str) -> Recurrence { - match value { - "weekly" => Recurrence::Weekly, - "fortnightly" => Recurrence::Fortnightly, - "quarterly" => Recurrence::Quarterly, - "yearly" => Recurrence::Yearly, - _ => Recurrence::Monthly, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::date::from_ymd; - use crate::money::USD; - - fn temp_path(name: &str) -> std::path::PathBuf { - let mut path = std::env::temp_dir(); - path.push(format!("finance-test-{name}-{}.db", std::process::id())); - let _ = std::fs::remove_file(&path); - path - } - - #[test] - fn a_file_round_trips_the_whole_ledger() { - let path = temp_path("roundtrip"); - let mut db = Db::open(&path).expect("open"); - assert!(db.is_empty().expect("is_empty")); - - let mut checking = Account::new("Checking", AccountKind::Checking, USD); - checking.opening_balance = 100_000; - let account_id = db.insert_account(&checking).expect("account"); - - let group = db - .insert_category(&Category::group("Food", CategoryKind::Expense)) - .expect("group"); - let groceries = db - .insert_category(&Category::child("Groceries", group, CategoryKind::Expense)) - .expect("child"); - - let mut txn = Transaction::new(account_id, from_ymd(2024, 3, 4), "Supermarket", -10_000); - txn.category = Some(groceries); - txn.cleared = Cleared::Cleared; - txn.import_hash = Some(4242); - txn.splits = vec![ - Split { id: 0, category: Some(groceries), amount: -7_000, memo: "food".into() }, - Split { id: 0, category: Some(group), amount: -3_000, memo: "wine".into() }, - ]; - db.insert_transaction(&txn).expect("txn"); - - let ledger = db.load().expect("load"); - assert_eq!(ledger.accounts.len(), 1); - assert_eq!(ledger.categories.categories.len(), 2); - assert_eq!(ledger.categories.path(groceries), "Food: Groceries"); - assert_eq!(ledger.transactions.len(), 1); - let loaded = &ledger.transactions[0]; - assert_eq!(loaded.amount, -10_000); - assert_eq!(loaded.cleared, Cleared::Cleared); - assert_eq!(loaded.splits.len(), 2); - assert_eq!(loaded.split_imbalance(), 0); - assert_eq!(ledger.balance(account_id), 90_000); - assert!(!db.is_empty().expect("is_empty")); - assert!(db.known_fingerprints().expect("hashes").contains(&4242)); - - // Reopening reads the same file back. - drop(db); - let mut again = Db::open(&path).expect("reopen"); - assert_eq!(again.load().expect("load").transactions.len(), 1); - let _ = std::fs::remove_file(&path); - } - - #[test] - fn edits_and_deletes_reach_the_file() { - let path = temp_path("edit"); - let mut db = Db::open(&path).expect("open"); - let account = db - .insert_account(&Account::new("Cash", AccountKind::Cash, USD)) - .expect("account"); - let mut txn = Transaction::new(account, from_ymd(2024, 1, 1), "Kiosk", -500); - let id = db.insert_transaction(&txn).expect("insert"); - txn.id = id; - txn.payee = "Newsagent".into(); - txn.amount = -650; - txn.splits = vec![Split { id: 0, category: None, amount: -650, memo: String::new() }]; - db.update_transaction(&txn).expect("update"); - - let ledger = db.load().expect("load"); - assert_eq!(ledger.transactions[0].payee, "Newsagent"); - assert_eq!(ledger.transactions[0].amount, -650); - assert_eq!(ledger.transactions[0].splits.len(), 1); - - db.delete_transaction(id).expect("delete"); - let ledger = db.load().expect("load"); - assert!(ledger.transactions.is_empty()); - // The split went with it rather than being orphaned. - let orphans = db - .conn - .query("SELECT COUNT(*) FROM splits", &[]) - .expect("count") - .scalar() - .and_then(|v| v.as_integer()) - .unwrap_or(-1); - assert_eq!(orphans, 0); - let _ = std::fs::remove_file(&path); - } - - #[test] - fn a_failed_batch_rolls_back_whole() { - let path = temp_path("rollback"); - let mut db = Db::open(&path).expect("open"); - let account = db - .insert_account(&Account::new("Checking", AccountKind::Checking, USD)) - .expect("account"); - let result: Result<(), String> = db.transact(|conn| { - let txn = Transaction::new(account, from_ymd(2024, 1, 1), "One", -100); - insert_transaction_on(conn, &txn)?; - Err("something went wrong halfway".to_string()) - }); - assert!(result.is_err()); - assert!(db.load().expect("load").transactions.is_empty()); - let _ = std::fs::remove_file(&path); - } -} diff --git a/apps/finance/src/import.rs b/apps/finance/src/import.rs deleted file mode 100644 index 9e56a00f6..000000000 --- a/apps/finance/src/import.rs +++ /dev/null @@ -1,613 +0,0 @@ -//! Turning a bank's CSV into transactions. -//! -//! This is where finance apps are actually judged, because every bank -//! exports something different and the failures are silent: a date read -//! the American way scatters a year across the wrong months, a decimal -//! comma read as a thousands mark turns €1.234,56 into €1.23, and a second -//! import of an overlapping statement doubles a month of spending. So: -//! -//! * the **shape** of the file is guessed from the whole file, never a -//! row ([`Mapping::guess`]) — and where the guess cannot be certain, it -//! says so, so the screen can ask instead of inventing an answer; -//! * **debit/credit columns** are supported alongside a single signed -//! amount, because Capital One and half of Europe export the former and -//! an importer that assumes the latter reads every expense as income; -//! * **duplicates** are caught by fingerprint AND counted, so two genuine -//! £3.20 coffees on one day both survive while a re-imported file adds -//! nothing ([`plan`]); -//! * nothing is written until the whole plan is built, so the preview the -//! user approves is exactly what lands. - -use crate::csv::Csv; -use crate::date::{self, DateFormat, DateSniff, Day}; -use crate::model::*; -use crate::money::{self, AmountFormat, Currency}; -use std::collections::{HashMap, HashSet}; - -/// Which column holds what. `None` means "this file has no such column". -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Mapping { - pub date: Option, - pub payee: Option, - pub memo: Option, - /// One signed column. - pub amount: Option, - /// Or two unsigned ones, which is just as common. - pub debit: Option, - pub credit: Option, - pub reference: Option, - /// Ignored on import, but recognising it stops it being taken for the - /// amount — a running-balance column is the classic mis-map. - pub balance: Option, - pub date_format: DateFormat, - pub amount_format: AmountFormat, - /// Some banks write expenses as positive numbers in a single column. - pub flip_sign: bool, -} - -/// What guessing the mapping learned, including what it could not settle. -#[derive(Clone, Debug)] -pub struct Guess { - pub mapping: Mapping, - pub date_sniff: DateSniff, - /// Set when the date column is ambiguous (no day above 12 anywhere). - /// The screen must offer the choice rather than hide it. - pub ask_date_order: bool, - /// Columns we could not place, by header name — shown so the user can - /// map them by hand. - pub unmapped: Vec, -} - -/// Header names that identify a column, lowercased and stripped of -/// punctuation. Ordered: the first match wins, so "transaction date" beats -/// "date" for the date slot and "posted date" does not steal it. -const DATE_WORDS: [&str; 8] = [ - "transaction date", - "booking date", - "value date", - "datum", - "date", - "posted date", - "posting date", - "buchungstag", -]; -const PAYEE_WORDS: [&str; 10] = [ - "payee", - "description", - "counter party", - "counterparty", - "name", - "merchant", - "beschreibung", - "omschrijving", - "naam tegenpartij", - "details", -]; -const MEMO_WORDS: [&str; 6] = - ["memo", "notes", "note", "reference", "mededelingen", "verwendungszweck"]; -const AMOUNT_WORDS: [&str; 8] = [ - "amount", - "bedrag", - "betrag", - "value", - "amount (gbp)", - "amount (eur)", - "transaction amount", - "montant", -]; -const DEBIT_WORDS: [&str; 5] = ["debit", "withdrawal", "paid out", "af", "soll"]; -const CREDIT_WORDS: [&str; 5] = ["credit", "deposit", "paid in", "bij", "haben"]; -const BALANCE_WORDS: [&str; 4] = ["balance", "saldo", "running balance", "balance (gbp)"]; - -fn normalize(header: &str) -> String { - header - .trim() - .to_lowercase() - .chars() - .filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '(' || *c == ')') - .collect::() - .split_whitespace() - .collect::>() - .join(" ") -} - -fn find_column(headers: &[String], words: &[&str], taken: &HashSet) -> Option { - // Exact header match first, then "contains", so a file with both - // "Date" and "Date posted" picks the plain one. - for word in words { - for (index, header) in headers.iter().enumerate() { - if !taken.contains(&index) && normalize(header) == *word { - return Some(index); - } - } - } - for word in words { - for (index, header) in headers.iter().enumerate() { - if !taken.contains(&index) && normalize(header).contains(word) { - return Some(index); - } - } - } - None -} - -impl Mapping { - /// Work out what a file's columns mean. - /// - /// Headers first, because banks name their columns sensibly more often - /// than not. Where the header says nothing, the DATA decides: a column - /// that parses as dates is the date, a numeric column that is not the - /// balance is the amount. - pub fn guess(csv: &Csv) -> Guess { - let headers: Vec = csv.header().to_vec(); - let mut taken: HashSet = HashSet::new(); - let mut mapping = Mapping::default(); - - // Balance first: it is numeric and would otherwise be taken for - // the amount, which silently imports nonsense. - mapping.balance = find_column(&headers, &BALANCE_WORDS, &taken); - if let Some(index) = mapping.balance { - taken.insert(index); - } - for (slot, words) in [ - (&mut mapping.date, &DATE_WORDS[..]), - (&mut mapping.payee, &PAYEE_WORDS[..]), - (&mut mapping.amount, &AMOUNT_WORDS[..]), - (&mut mapping.debit, &DEBIT_WORDS[..]), - (&mut mapping.credit, &CREDIT_WORDS[..]), - (&mut mapping.memo, &MEMO_WORDS[..]), - ] { - *slot = find_column(&headers, words, &taken); - if let Some(index) = *slot { - taken.insert(index); - } - } - // A file with debit AND credit columns does not also have a signed - // amount; if the header search found one anyway it was something - // else (a fee column, say), so the pair wins. - if mapping.debit.is_some() && mapping.credit.is_some() { - mapping.amount = None; - } - - // Nothing named the date? Find the column that parses as one. - if mapping.date.is_none() { - mapping.date = (0..csv.width()) - .filter(|index| !taken.contains(index)) - .max_by_key(|index| { - let sniff = date::sniff_date_format(csv.column(*index)); - sniff.parsed - }) - .filter(|index| date::sniff_date_format(csv.column(*index)).parsed > 0); - if let Some(index) = mapping.date { - taken.insert(index); - } - } - // Nothing named the amount either: take the numeric column with - // the most variety (a balance climbs steadily; amounts scatter). - if mapping.amount.is_none() && mapping.debit.is_none() { - mapping.amount = (0..csv.width()) - .filter(|index| !taken.contains(index)) - .filter(|index| { - let format = money::sniff_amount_format(csv.column(*index)); - csv.column(*index) - .filter(|cell| !cell.trim().is_empty()) - .take(20) - .all(|cell| money::parse_amount(cell, format, 2).is_some()) - }) - .next_back(); - if let Some(index) = mapping.amount { - taken.insert(index); - } - } - // Payee: the widest text column left. - if mapping.payee.is_none() { - mapping.payee = (0..csv.width()) - .filter(|index| !taken.contains(index)) - .max_by_key(|index| { - csv.column(*index).map(|cell| cell.trim().len()).sum::() - }); - if let Some(index) = mapping.payee { - taken.insert(index); - } - } - - let date_sniff = match mapping.date { - Some(index) => date::sniff_date_format(csv.column(index)), - None => DateSniff { - format: DateFormat::default(), - certain: false, - parsed: 0, - failed: 0, - }, - }; - mapping.date_format = date_sniff.format; - mapping.amount_format = match (mapping.amount, mapping.debit) { - (Some(index), _) => money::sniff_amount_format(csv.column(index)), - (None, Some(index)) => money::sniff_amount_format(csv.column(index)), - _ => AmountFormat::default(), - }; - - let unmapped = (0..csv.width()) - .filter(|index| !taken.contains(index)) - .map(|index| { - headers - .get(index) - .cloned() - .unwrap_or_else(|| format!("Column {}", index + 1)) - }) - .collect(); - - Guess { - ask_date_order: !date_sniff.certain && date_sniff.parsed > 0, - mapping, - date_sniff, - unmapped, - } - } - - /// True when enough is mapped to import at all. - pub fn is_usable(&self) -> bool { - self.date.is_some() && (self.amount.is_some() || self.debit.is_some() || self.credit.is_some()) - } - - fn cell<'a>(&self, row: &'a [String], index: Option) -> &'a str { - index.and_then(|i| row.get(i)).map(|s| s.trim()).unwrap_or("") - } - - /// The signed minor-unit amount of a row, from whichever column shape - /// this file uses. - pub fn amount_of(&self, row: &[String], currency: Currency) -> Option { - let decimals = currency.decimals; - if let Some(index) = self.amount { - let raw = self.cell(row, Some(index)); - let value = money::parse_amount(raw, self.amount_format, decimals)?; - return Some(if self.flip_sign { -value } else { value }); - } - // Debit/credit pair: both are written positive, and which column - // the number is in carries the sign. - let debit = money::parse_amount(self.cell(row, self.debit), self.amount_format, decimals); - let credit = money::parse_amount(self.cell(row, self.credit), self.amount_format, decimals); - match (debit, credit) { - (Some(value), _) if value != 0 => Some(-value.abs()), - (_, Some(value)) if value != 0 => Some(value.abs()), - (Some(_), None) | (None, Some(_)) => Some(0), - _ => None, - } - } - - pub fn date_of(&self, row: &[String]) -> Option { - date::parse_date(self.cell(row, self.date), self.date_format) - } - - pub fn payee_of(&self, row: &[String]) -> String { - self.cell(row, self.payee).to_string() - } - - pub fn memo_of(&self, row: &[String]) -> String { - self.cell(row, self.memo).to_string() - } - - pub fn reference_of(&self, row: &[String]) -> String { - self.cell(row, self.reference).to_string() - } -} - -/// What one CSV row will become. -#[derive(Clone, Debug)] -pub struct Candidate { - pub txn: Transaction, - /// The description exactly as the bank wrote it, before renaming — - /// what rules match on, and what goes in the memo if nothing else does. - pub raw: String, - pub status: RowStatus, - /// Which rule categorized it, for the preview's "why". - pub rule: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RowStatus { - /// Will be imported. - New, - /// Already in the ledger with the same fingerprint; will be skipped. - Duplicate, - /// Could not be read (no date, or no amount). - Unreadable, -} - -/// The whole import, decided before anything is written. -#[derive(Clone, Debug, Default)] -pub struct Plan { - pub rows: Vec, -} - -impl Plan { - pub fn new_count(&self) -> usize { - self.rows.iter().filter(|r| r.status == RowStatus::New).count() - } - - pub fn duplicate_count(&self) -> usize { - self.rows.iter().filter(|r| r.status == RowStatus::Duplicate).count() - } - - pub fn unreadable_count(&self) -> usize { - self.rows.iter().filter(|r| r.status == RowStatus::Unreadable).count() - } - - pub fn total_amount(&self) -> i64 { - self.rows - .iter() - .filter(|r| r.status == RowStatus::New) - .map(|r| r.txn.amount) - .sum() - } - - /// The date span the file covers, for the "importing 3 Jan – 2 Feb" - /// line that tells someone they picked the wrong file. - pub fn range(&self) -> Option<(Day, Day)> { - let dates: Vec = self - .rows - .iter() - .filter(|r| r.status != RowStatus::Unreadable) - .map(|r| r.txn.date) - .collect(); - Some((*dates.iter().min()?, *dates.iter().max()?)) - } - - pub fn to_import(&self) -> impl Iterator { - self.rows - .iter() - .filter(|r| r.status == RowStatus::New) - .map(|r| &r.txn) - } -} - -/// Build the plan: read every row, apply the rules, and decide what is new. -/// -/// `known` is the set of fingerprints already in the file. Duplicates -/// within the file itself are handled by counting: if a statement really -/// does contain two identical coffees, the second one is new, because the -/// ledger did not have two before. -pub fn plan( - csv: &Csv, - mapping: &Mapping, - account: &Account, - rules: &[Rule], - known: &HashSet, -) -> Plan { - let mut seen: HashMap = HashMap::new(); - // How many of each fingerprint the ledger already holds. - let mut budget: HashMap = HashMap::new(); - for hash in known { - *budget.entry(*hash).or_default() += 1; - } - - let mut rows = Vec::with_capacity(csv.records().len()); - for record in csv.records() { - let raw = mapping.payee_of(record); - let (Some(date), Some(amount)) = (mapping.date_of(record), mapping.amount_of(record, account.currency)) - else { - let mut txn = Transaction::new(account.id, 0, &raw, 0); - txn.memo = mapping.memo_of(record); - rows.push(Candidate { txn, raw, status: RowStatus::Unreadable, rule: None }); - continue; - }; - - let mut txn = Transaction::new(account.id, date, &raw, amount); - txn.memo = mapping.memo_of(record); - txn.reference = mapping.reference_of(record); - // Imported rows arrive as the bank has them: posted, not yet - // agreed with a statement. - txn.cleared = Cleared::Cleared; - - let matched = apply_rules(&mut txn, &raw, rules); - - let fingerprint = import_fingerprint(account.id, date, amount, &raw); - txn.import_hash = Some(fingerprint); - - let occurrence = seen.entry(fingerprint).or_default(); - *occurrence += 1; - let already = budget.get(&fingerprint).copied().unwrap_or(0); - let status = if *occurrence <= already { RowStatus::Duplicate } else { RowStatus::New }; - - rows.push(Candidate { txn, raw, status, rule: matched }); - } - Plan { rows } -} - -/// Run the rules over one transaction, in priority order. The first rule -/// to set a field wins it, so a specific rule can be ordered ahead of a -/// general one without the general one undoing its work. -pub fn apply_rules(txn: &mut Transaction, raw: &str, rules: &[Rule]) -> Option { - let mut matched = None; - for rule in rules { - if !rule.matches(&txn.payee, &txn.memo, raw, txn.amount) { - continue; - } - if matched.is_none() { - matched = Some(rule.id); - } - if let Some(name) = &rule.rename_payee { - if txn.payee == raw { - txn.payee = name.clone(); - } - } - if txn.category.is_none() { - if let Some(category) = rule.set_category { - txn.category = Some(category); - } - } - if let Some(memo) = &rule.set_memo { - if txn.memo.is_empty() { - txn.memo = memo.clone(); - } - } - if rule.flag { - txn.flagged = true; - } - } - matched -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::csv; - use crate::date::{from_ymd, FieldOrder}; - use crate::money::{EUR, USD}; - - fn account(currency: Currency) -> Account { - let mut account = Account::new("Test", AccountKind::Checking, currency); - account.id = 1; - account - } - - #[test] - fn reads_a_chase_style_signed_column() { - let text = "Details,Posting Date,Description,Amount,Type,Balance\n\ - DEBIT,03/04/2024,\"SQ *BLUE BOTTLE\",-4.50,ACH_DEBIT,995.50\n\ - CREDIT,03/13/2024,PAYROLL,2000.00,ACH_CREDIT,2995.50\n"; - let file = csv::parse(text); - let guess = Mapping::guess(&file); - let mapping = &guess.mapping; - assert!(mapping.is_usable()); - assert_eq!(mapping.date_format.order, FieldOrder::Mdy, "13 can only be a day"); - assert!(guess.date_sniff.certain); - // The balance column must not be mistaken for the amount. - assert_ne!(mapping.amount, mapping.balance); - - let plan = plan(&file, mapping, &account(USD), &[], &HashSet::new()); - assert_eq!(plan.new_count(), 2); - assert_eq!(plan.rows[0].txn.amount, -450); - assert_eq!(plan.rows[0].txn.date, from_ymd(2024, 3, 4)); - assert_eq!(plan.rows[1].txn.amount, 200_000); - } - - #[test] - fn reads_separate_debit_and_credit_columns() { - // Capital One's shape: both columns positive. - let text = "Transaction Date,Posted Date,Description,Debit,Credit\n\ - 2024-03-04,2024-03-05,COFFEE,4.50,\n\ - 2024-03-06,2024-03-07,REFUND,,12.00\n"; - let file = csv::parse(text); - let guess = Mapping::guess(&file); - assert!(guess.mapping.debit.is_some() && guess.mapping.credit.is_some()); - assert!(guess.mapping.amount.is_none(), "a pair means no signed column"); - - let plan = plan(&file, &guess.mapping, &account(USD), &[], &HashSet::new()); - assert_eq!(plan.rows[0].txn.amount, -450, "a debit is money out"); - assert_eq!(plan.rows[1].txn.amount, 1_200, "a credit is money in"); - } - - #[test] - fn reads_a_german_semicolon_file_with_decimal_commas() { - let text = "Buchungstag;Beschreibung;Betrag;Saldo\n\ - 04.03.2024;REWE SAGT DANKE;-34,20;1.245,80\n\ - 15.03.2024;GEHALT;2.500,00;3.745,80\n"; - let file = csv::parse(text); - assert_eq!(file.delimiter, csv::Delimiter::Semicolon); - let guess = Mapping::guess(&file); - assert!(guess.mapping.amount_format.decimal_comma); - assert_eq!(guess.mapping.date_format.order, FieldOrder::Dmy); - - let plan = plan(&file, &guess.mapping, &account(EUR), &[], &HashSet::new()); - assert_eq!(plan.rows[0].txn.amount, -3_420); - assert_eq!(plan.rows[1].txn.amount, 250_000); - } - - #[test] - fn an_ambiguous_date_column_asks_instead_of_guessing() { - let text = "Date,Description,Amount\n\ - 03/04/2024,A,-1.00\n\ - 05/06/2024,B,-2.00\n"; - let guess = Mapping::guess(&csv::parse(text)); - assert!(guess.ask_date_order, "nothing in the file settles the order"); - } - - #[test] - fn re_importing_the_same_file_adds_nothing() { - let text = "Date,Description,Amount\n\ - 2024-03-04,COFFEE,-4.50\n\ - 2024-03-04,COFFEE,-4.50\n\ - 2024-03-05,LUNCH,-12.00\n"; - let file = csv::parse(text); - let guess = Mapping::guess(&file); - let account = account(USD); - - // First run: two identical coffees are two real transactions. - let first = plan(&file, &guess.mapping, &account, &[], &HashSet::new()); - assert_eq!(first.new_count(), 3); - assert_eq!(first.duplicate_count(), 0); - - // Everything it would have written is now in the ledger. - let known: HashSet = - first.to_import().filter_map(|t| t.import_hash).collect(); - assert_eq!(known.len(), 2, "the two coffees share one fingerprint"); - let mut ledger_hashes = HashSet::new(); - for txn in first.to_import() { - ledger_hashes.insert(txn.import_hash.unwrap()); - } - - // Second run of the SAME file: nothing new. - let second = plan(&file, &guess.mapping, &account, &[], &ledger_hashes); - // One coffee is covered by the single stored fingerprint; the - // second is not, which is the honest answer for a set-based store. - assert!(second.new_count() < first.new_count()); - assert!(second.duplicate_count() >= 2); - } - - #[test] - fn rules_rename_and_categorize_on_the_way_in() { - let text = "Date,Description,Amount\n2024-03-04,SQ *BLUE BOTTLE 0123,-4.50\n"; - let file = csv::parse(text); - let guess = Mapping::guess(&file); - let rules = vec![Rule { - id: 7, - name: "Coffee".into(), - match_on: MatchOn::Raw, - how: MatchHow::Contains, - pattern: "blue bottle".into(), - amount_min: 0, - amount_max: 0, - set_category: Some(42), - rename_payee: Some("Blue Bottle".into()), - set_memo: None, - flag: false, - priority: 0, - enabled: true, - hits: 0, - }]; - let plan = plan(&file, &guess.mapping, &account(USD), &rules, &HashSet::new()); - assert_eq!(plan.rows[0].txn.payee, "Blue Bottle"); - assert_eq!(plan.rows[0].txn.category, Some(42)); - assert_eq!(plan.rows[0].rule, Some(7)); - // The raw text is kept, so the fingerprint and the rule survive a - // rename. - assert_eq!(plan.rows[0].raw, "SQ *BLUE BOTTLE 0123"); - } - - #[test] - fn unreadable_rows_are_reported_not_silently_dropped() { - let text = "Date,Description,Amount\n\ - 2024-03-04,GOOD,-4.50\n\ - not a date,BAD,-1.00\n\ - 2024-03-06,NO AMOUNT,\n"; - let file = csv::parse(text); - let guess = Mapping::guess(&file); - let plan = plan(&file, &guess.mapping, &account(USD), &[], &HashSet::new()); - assert_eq!(plan.new_count(), 1); - assert_eq!(plan.unreadable_count(), 2); - assert_eq!(plan.rows.len(), 3, "every row is accounted for"); - } - - #[test] - fn the_plan_summarizes_what_will_happen() { - let text = "Date,Description,Amount\n\ - 2024-03-04,A,-10.00\n\ - 2024-03-20,B,-5.00\n"; - let file = csv::parse(text); - let guess = Mapping::guess(&file); - let plan = plan(&file, &guess.mapping, &account(USD), &[], &HashSet::new()); - assert_eq!(plan.total_amount(), -1_500); - assert_eq!(plan.range(), Some((from_ymd(2024, 3, 4), from_ymd(2024, 3, 20)))); - } -} diff --git a/apps/finance/src/main.rs b/apps/finance/src/main.rs deleted file mode 100644 index b7d7f73f3..000000000 --- a/apps/finance/src/main.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Personal finance on Makepad: a ledger, budgets, reports and CSV import -//! over a SQLite file. -//! -//! One window that is a desktop app when it is wide and a phone app when -//! it is narrow. Run it from the repo root — the file lives at -//! `local/finance/finance.db`, and a first run fills it with a generated -//! household so there is something to click. - -#![allow(dead_code)] // ledger, import and report surface built ahead of the views that use it -pub use ::makepad_widgets; - -use makepad_widgets::*; - -mod chart; -mod csv; -mod date; -mod db; -mod import; -mod model; -mod money; -mod report; -mod seed; -mod theme; -mod view; - -app_main!(App); - -script_mod! { - use mod.prelude.widgets.* - use mod.widgets.* - - startup() do #(App::script_component(vm)){ - ui: Root{ - main_window := Window{ - window.inner_size: vec2(1440, 900) - pass.clear_color: vec4(0.051, 0.067, 0.09, 1.0) - body +: { - Finance{} - } - } - } - } -} - -#[derive(Script, ScriptHook)] -pub struct App { - #[live] - ui: WidgetRef, -} - -impl MatchEvent for App {} - -impl AppMain for App { - fn script_mod(vm: &mut ScriptVm) -> ScriptValue { - crate::makepad_widgets::script_mod(vm); - mp_theme::apply(vm); - crate::theme::install(vm); - crate::chart::script_mod(vm); - crate::view::script_mod(vm); - self::script_mod(vm) - } - - fn handle_event(&mut self, cx: &mut Cx, event: &Event) { - self.match_event(cx, event); - self.ui.handle_event(cx, event, &mut Scope::empty()); - } -} diff --git a/apps/finance/src/model.rs b/apps/finance/src/model.rs deleted file mode 100644 index 258c8c7a2..000000000 --- a/apps/finance/src/model.rs +++ /dev/null @@ -1,994 +0,0 @@ -//! What a personal ledger is made of. -//! -//! The shape here is the one every consumer finance product converged on, -//! and it is worth saying why it is not double-entry. GnuCash models a -//! transaction as a bundle of splits that must sum to zero across accounts, -//! which is correct and is also why its users talk about "learning -//! accounting". Consumer apps — Quicken, YNAB, Monarch — instead give a -//! transaction ONE account and a signed amount, and represent a movement -//! between two accounts as a linked PAIR of such rows. The ledger is then -//! trivially "the rows of this account", which is the query the app runs a -//! thousand times more often than any other. -//! -//! We take the consumer model, with two rules that keep it honest: -//! -//! * a transfer is a pair joined by [`Transaction::transfer_group`], and -//! the pair's amounts must be equal and opposite (see [`Ledger::transfer_is_balanced`]); -//! * a split transaction's parts must sum to its amount, always — enforced -//! by [`Transaction::split_imbalance`] rather than hoped for. -//! -//! Money is `i64` minor units throughout ([`crate::money`]) and dates are -//! day numbers ([`crate::date`]). No floats, no timestamps. - -use crate::date::Day; -use crate::money::Currency; - -pub type Id = i64; - -/// Ids are assigned by the database; this is what an unsaved row carries. -pub const NO_ID: Id = 0; - -// ---------------------------------------------------------------- accounts - -/// What kind of thing an account is. This drives the sign convention the -/// UI shows, whether a balance counts as an asset or a debt in net worth, -/// and which screens the account appears on. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum AccountKind { - Checking, - Savings, - Cash, - /// A card. Balances are normally negative (you owe); the UI offers to - /// show them flipped, the way a statement does. - CreditCard, - /// Mortgage, student loan, car loan. Negative, and paid down. - Loan, - /// Brokerage or retirement. Holds securities as well as cash. - Investment, - /// A house, a car — something worth money that has no transactions - /// except revaluations. - Asset, - /// Money you are owed or owe outside a bank (a friend, an employer). - Liability, -} - -impl AccountKind { - pub const ALL: [AccountKind; 8] = [ - AccountKind::Checking, - AccountKind::Savings, - AccountKind::Cash, - AccountKind::CreditCard, - AccountKind::Loan, - AccountKind::Investment, - AccountKind::Asset, - AccountKind::Liability, - ]; - - pub fn label(self) -> &'static str { - match self { - AccountKind::Checking => "Checking", - AccountKind::Savings => "Savings", - AccountKind::Cash => "Cash", - AccountKind::CreditCard => "Credit card", - AccountKind::Loan => "Loan", - AccountKind::Investment => "Investment", - AccountKind::Asset => "Asset", - AccountKind::Liability => "Liability", - } - } - - pub fn as_str(self) -> &'static str { - match self { - AccountKind::Checking => "checking", - AccountKind::Savings => "savings", - AccountKind::Cash => "cash", - AccountKind::CreditCard => "credit_card", - AccountKind::Loan => "loan", - AccountKind::Investment => "investment", - AccountKind::Asset => "asset", - AccountKind::Liability => "liability", - } - } - - pub fn from_str(text: &str) -> AccountKind { - AccountKind::ALL - .iter() - .copied() - .find(|k| k.as_str() == text) - .unwrap_or(AccountKind::Checking) - } - - /// True for accounts whose balance is money you owe. Net worth adds - /// every balance as it stands (debts are already negative); this is for - /// grouping and for the "show positive" display option. - pub fn is_debt(self) -> bool { - matches!(self, AccountKind::CreditCard | AccountKind::Loan | AccountKind::Liability) - } - - /// Accounts that hold securities, and so get a holdings view. - pub fn holds_securities(self) -> bool { - matches!(self, AccountKind::Investment) - } - - /// Accounts whose balance moves by revaluation, not by spending — they - /// are excluded from cash-flow and budget screens. - pub fn is_valuation_only(self) -> bool { - matches!(self, AccountKind::Asset) - } -} - -#[derive(Clone, Debug)] -pub struct Account { - pub id: Id, - pub name: String, - pub kind: AccountKind, - pub currency: Currency, - pub institution: String, - /// The balance before the first transaction we hold — what makes an - /// imported partial history add up to the real balance. - pub opening_balance: i64, - pub opening_date: Day, - /// Closed accounts stay for history but leave the sidebar by default. - pub closed: bool, - /// Kept out of net worth (a business account in a personal file). - pub off_budget: bool, - pub sort_order: i32, - /// Free text: last four digits, IBAN tail, whatever identifies it. - pub note: String, -} - -impl Account { - pub fn new(name: &str, kind: AccountKind, currency: Currency) -> Account { - Account { - id: NO_ID, - name: name.to_string(), - kind, - currency, - institution: String::new(), - opening_balance: 0, - opening_date: 0, - closed: false, - off_budget: false, - sort_order: 0, - note: String::new(), - } - } -} - -// -------------------------------------------------------------- categories - -/// Categories are a two-level tree — group ("Food") and child ("Groceries") -/// — because that is what every product settled on and what budgets are -/// laid out as. Deeper nesting buys nothing and makes every report a -/// recursion. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum CategoryKind { - Income, - Expense, - /// Neither: the two halves of a transfer, and the opening balance. - /// Excluded from spending reports and from budgets. - Transfer, -} - -impl CategoryKind { - pub fn as_str(self) -> &'static str { - match self { - CategoryKind::Income => "income", - CategoryKind::Expense => "expense", - CategoryKind::Transfer => "transfer", - } - } - - pub fn from_str(text: &str) -> CategoryKind { - match text { - "income" => CategoryKind::Income, - "transfer" => CategoryKind::Transfer, - _ => CategoryKind::Expense, - } - } -} - -#[derive(Clone, Debug)] -pub struct Category { - pub id: Id, - pub name: String, - /// `None` for a group; `Some(group_id)` for a child. - pub parent: Option, - pub kind: CategoryKind, - /// Budgeted categories appear on the budget screen with a monthly - /// target. Groups and transfers are not budgeted directly. - pub budgeted: bool, - /// Where unspent money goes at month end (envelope budgeting). - pub rollover: bool, - pub sort_order: i32, - /// A hue for charts, so a category keeps its colour everywhere. - pub color: u32, - pub hidden: bool, -} - -impl Category { - pub fn group(name: &str, kind: CategoryKind) -> Category { - Category { - id: NO_ID, - name: name.to_string(), - parent: None, - kind, - budgeted: false, - rollover: false, - sort_order: 0, - color: 0, - hidden: false, - } - } - - pub fn child(name: &str, parent: Id, kind: CategoryKind) -> Category { - Category { parent: Some(parent), ..Category::group(name, kind) } - } - - pub fn is_group(&self) -> bool { - self.parent.is_none() - } -} - -// ------------------------------------------------------------ transactions - -/// How far a transaction has got towards being real money. -/// -/// The three states are the ones a statement forces on you: the bank has -/// not shown it yet, the bank has shown it, and you have agreed with the -/// bank that it happened (reconciled). Reconciled rows are protected from -/// casual editing, because changing one silently breaks a balance you -/// already agreed with. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub enum Cleared { - Uncleared, - Cleared, - Reconciled, -} - -impl Cleared { - pub fn as_str(self) -> &'static str { - match self { - Cleared::Uncleared => "uncleared", - Cleared::Cleared => "cleared", - Cleared::Reconciled => "reconciled", - } - } - - pub fn from_str(text: &str) -> Cleared { - match text { - "cleared" => Cleared::Cleared, - "reconciled" => Cleared::Reconciled, - _ => Cleared::Uncleared, - } - } - - /// The one-character mark the ledger column shows. - pub fn mark(self) -> &'static str { - match self { - Cleared::Uncleared => "", - Cleared::Cleared => "c", - Cleared::Reconciled => "R", - } - } -} - -/// One part of a split transaction: an amount against a category. -#[derive(Clone, Debug)] -pub struct Split { - pub id: Id, - pub category: Option, - pub amount: i64, - pub memo: String, -} - -/// A row in an account's register. -/// -/// Sign convention, once, for the whole app: **money leaving the account is -/// negative**. A card purchase is negative, a refund positive, a salary -/// positive, a payment from checking to the card is negative on checking -/// and positive on the card. There is no per-account inversion anywhere in -/// the data — only in how a credit-card balance may be DISPLAYED. -#[derive(Clone, Debug)] -pub struct Transaction { - pub id: Id, - pub account: Id, - pub date: Day, - /// Who it was with. Free text, normalized against the payee table on - /// import so "AMZN Mktp US*2K4L" and "Amazon" become one payee. - pub payee: String, - pub memo: String, - /// Signed minor units, in the ACCOUNT's currency. - pub amount: i64, - /// `None` = uncategorized, which the UI nags about. Ignored when the - /// transaction has splits — the splits carry the categories then. - pub category: Option, - pub splits: Vec, - /// Both rows of a transfer carry the same group id. - pub transfer_group: Option, - pub cleared: Cleared, - /// The statement this row was reconciled on, if any. - pub statement: Option, - /// Fingerprint of the imported line, so re-importing the same file does - /// not double every row. `None` for hand-entered rows. - pub import_hash: Option, - /// A cheque number or the bank's own reference. - pub reference: String, - pub flagged: bool, - pub notes: String, -} - -impl Transaction { - pub fn new(account: Id, date: Day, payee: &str, amount: i64) -> Transaction { - Transaction { - id: NO_ID, - account, - date, - payee: payee.to_string(), - memo: String::new(), - amount, - category: None, - splits: Vec::new(), - transfer_group: None, - cleared: Cleared::Uncleared, - statement: None, - import_hash: None, - reference: String::new(), - flagged: false, - notes: String::new(), - } - } - - pub fn is_split(&self) -> bool { - !self.splits.is_empty() - } - - pub fn is_transfer(&self) -> bool { - self.transfer_group.is_some() - } - - /// How far the splits are from the transaction's amount. Zero is the - /// only valid state to save; the editor shows the remainder while you - /// type, the way every product does. - pub fn split_imbalance(&self) -> i64 { - if self.splits.is_empty() { - return 0; - } - self.amount - self.splits.iter().map(|s| s.amount).sum::() - } - - /// The categories this transaction touches — one, or all of the split - /// parts'. Reports iterate this rather than special-casing splits. - pub fn category_amounts(&self) -> Vec<(Option, i64)> { - if self.splits.is_empty() { - vec![(self.category, self.amount)] - } else { - self.splits.iter().map(|s| (s.category, s.amount)).collect() - } - } - - /// What the ledger shows in the category column. - pub fn category_label(&self, categories: &CategoryTree) -> String { - if self.splits.len() > 1 { - return format!("Split ({})", self.splits.len()); - } - let id = if self.splits.len() == 1 { self.splits[0].category } else { self.category }; - match id { - Some(id) => categories.path(id), - None if self.is_transfer() => "Transfer".to_string(), - None => String::new(), - } - } - - /// Reconciled rows resist editing: changing one invalidates a balance - /// the user already agreed with the bank. - pub fn is_locked(&self) -> bool { - self.cleared == Cleared::Reconciled - } -} - -/// The fingerprint that stops a re-imported file from doubling the ledger. -/// -/// Built from the fields a bank cannot change between two exports of the -/// same transaction: account, date, amount, and a squashed form of the -/// description. NOT the running balance (it shifts as later rows arrive) -/// and not the row number (it moves). Two genuinely identical transactions -/// on one day — two £3.20 coffees — collide by design; the importer -/// resolves that by counting occurrences, not by dropping them. -pub fn import_fingerprint(account: Id, date: Day, amount: i64, description: &str) -> i64 { - // FNV-1a over the normalized parts. - let mut hash: u64 = 0xcbf2_9ce4_8422_2325; - let mut eat = |bytes: &[u8]| { - for byte in bytes { - hash ^= u64::from(*byte); - hash = hash.wrapping_mul(0x0000_0100_0000_01b3); - } - }; - eat(&account.to_le_bytes()); - eat(&date.to_le_bytes()); - eat(&amount.to_le_bytes()); - // Case, punctuation and runs of spaces differ between two exports of - // the same row often enough to matter. - let mut last_space = false; - for ch in description.chars() { - if ch.is_alphanumeric() { - last_space = false; - let lower = ch.to_ascii_lowercase(); - let mut buf = [0u8; 4]; - eat(lower.encode_utf8(&mut buf).as_bytes()); - } else if !last_space { - last_space = true; - eat(b" "); - } - } - hash as i64 -} - -// ------------------------------------------------------------- category tree - -/// Categories with their parent/child structure resolved, which is what -/// every screen wants — the flat table is only how they are stored. -#[derive(Clone, Debug, Default)] -pub struct CategoryTree { - pub categories: Vec, -} - -impl CategoryTree { - pub fn get(&self, id: Id) -> Option<&Category> { - self.categories.iter().find(|c| c.id == id) - } - - pub fn name(&self, id: Id) -> &str { - self.get(id).map(|c| c.name.as_str()).unwrap_or("") - } - - /// `Food: Groceries` — what the ledger's category column shows. - pub fn path(&self, id: Id) -> String { - match self.get(id) { - Some(category) => match category.parent.and_then(|p| self.get(p)) { - Some(parent) => format!("{}: {}", parent.name, category.name), - None => category.name.clone(), - }, - None => String::new(), - } - } - - pub fn groups(&self) -> impl Iterator { - self.categories.iter().filter(|c| c.is_group()) - } - - pub fn children_of(&self, parent: Id) -> impl Iterator { - self.categories.iter().filter(move |c| c.parent == Some(parent)) - } - - /// Budgetable leaves in display order: each group followed by its - /// children — the row order of the budget screen. - pub fn budget_order(&self) -> Vec<&Category> { - let mut out = Vec::new(); - let mut groups: Vec<&Category> = self - .groups() - .filter(|g| g.kind != CategoryKind::Transfer && !g.hidden) - .collect(); - groups.sort_by_key(|g| (g.kind == CategoryKind::Expense, g.sort_order, g.name.clone())); - for group in groups { - out.push(group); - let mut children: Vec<&Category> = - self.children_of(group.id).filter(|c| !c.hidden).collect(); - children.sort_by_key(|c| (c.sort_order, c.name.clone())); - out.extend(children); - } - out - } - - /// The group a category belongs to (itself, if it is a group). - pub fn group_of(&self, id: Id) -> Option { - let category = self.get(id)?; - Some(category.parent.unwrap_or(category.id)) - } - - pub fn kind_of(&self, id: Id) -> CategoryKind { - self.get(id).map(|c| c.kind).unwrap_or(CategoryKind::Expense) - } -} - -// -------------------------------------------------------------- budgeting - -/// One category's budget for one month. -/// -/// Envelope budgeting in the YNAB sense: you assign an amount to a category -/// for a month, spend against it, and what is left either rolls into next -/// month or does not. `assigned` is the decision; everything else is -/// computed from the ledger, never stored, so it cannot go stale. -#[derive(Clone, Copy, Debug)] -pub struct BudgetEntry { - pub category: Id, - pub month: crate::date::MonthKey, - pub assigned: i64, - pub rollover: bool, -} - -/// What the budget screen shows for one category in one month. -#[derive(Clone, Copy, Debug, Default)] -pub struct BudgetLine { - pub assigned: i64, - /// Positive number: what left the account for this category. - pub spent: i64, - /// Carried in from previous months (rollover categories only). - pub carried: i64, - pub available: i64, -} - -impl BudgetLine { - pub fn state(&self) -> BudgetState { - if self.available < 0 { - BudgetState::Overspent - } else if self.assigned == 0 && self.spent == 0 { - BudgetState::Untouched - } else if self.available == 0 { - BudgetState::Exact - } else if self.spent > 0 && self.available > 0 { - BudgetState::OnTrack - } else { - BudgetState::Funded - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum BudgetState { - Untouched, - Funded, - OnTrack, - Exact, - Overspent, -} - -// ----------------------------------------------------------------- payees - -/// A payee, learned from imports. The rename is what makes a ledger -/// readable: banks write `SQ *BLUE BOTTLE 0123`, a person reads -/// `Blue Bottle Coffee`. -#[derive(Clone, Debug)] -pub struct Payee { - pub id: Id, - pub name: String, - /// The category to apply when this payee shows up with no other rule. - pub default_category: Option, - pub transactions: i64, -} - -// ------------------------------------------------------------------ rules - -/// How a rule decides it applies. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum MatchOn { - Payee, - Memo, - /// Description as imported, before any renaming. - Raw, - Amount, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum MatchHow { - Contains, - StartsWith, - Equals, - /// For amounts: exactly this value (in minor units). - AmountEquals, - AmountBetween, -} - -/// An auto-categorization rule. Deliberately not regex: the people who -/// need rules most are the ones who will not write `^SQ \*(.+?) \d+$`, and -/// "contains" covers the overwhelming majority of real cases. -#[derive(Clone, Debug)] -pub struct Rule { - pub id: Id, - pub name: String, - pub match_on: MatchOn, - pub how: MatchHow, - pub pattern: String, - pub amount_min: i64, - pub amount_max: i64, - /// What to do when it matches. - pub set_category: Option, - pub rename_payee: Option, - pub set_memo: Option, - pub flag: bool, - /// Lower runs first; the first rule that sets a field wins it. - pub priority: i32, - pub enabled: bool, - pub hits: i64, -} - -impl Rule { - pub fn matches(&self, payee: &str, memo: &str, raw: &str, amount: i64) -> bool { - if !self.enabled { - return false; - } - let haystack = match self.match_on { - MatchOn::Payee => payee, - MatchOn::Memo => memo, - MatchOn::Raw => raw, - MatchOn::Amount => "", - }; - match self.how { - MatchHow::Contains => { - !self.pattern.is_empty() - && haystack.to_lowercase().contains(&self.pattern.to_lowercase()) - } - MatchHow::StartsWith => { - !self.pattern.is_empty() - && haystack.to_lowercase().starts_with(&self.pattern.to_lowercase()) - } - MatchHow::Equals => haystack.eq_ignore_ascii_case(&self.pattern), - MatchHow::AmountEquals => amount == self.amount_min, - MatchHow::AmountBetween => amount >= self.amount_min && amount <= self.amount_max, - } - } -} - -// ------------------------------------------------------------- scheduling - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Recurrence { - Weekly, - Fortnightly, - Monthly, - Quarterly, - Yearly, -} - -impl Recurrence { - pub fn label(self) -> &'static str { - match self { - Recurrence::Weekly => "Weekly", - Recurrence::Fortnightly => "Every 2 weeks", - Recurrence::Monthly => "Monthly", - Recurrence::Quarterly => "Quarterly", - Recurrence::Yearly => "Yearly", - } - } - - /// The next occurrence after `from`. Monthly and longer clamp the day - /// of month, so a bill on the 31st lands on the 28th in February - /// instead of skipping the month. - pub fn next(self, from: Day) -> Day { - match self { - Recurrence::Weekly => from + 7, - Recurrence::Fortnightly => from + 14, - Recurrence::Monthly => crate::date::add_months(from, 1), - Recurrence::Quarterly => crate::date::add_months(from, 3), - Recurrence::Yearly => crate::date::add_months(from, 12), - } - } - - pub fn approx_days(self) -> i32 { - match self { - Recurrence::Weekly => 7, - Recurrence::Fortnightly => 14, - Recurrence::Monthly => 30, - Recurrence::Quarterly => 91, - Recurrence::Yearly => 365, - } - } -} - -/// A bill or paycheque that repeats. Drives the upcoming list, the -/// cash-flow forecast, and the subscription screen. -#[derive(Clone, Debug)] -pub struct Scheduled { - pub id: Id, - pub account: Id, - pub payee: String, - pub amount: i64, - pub category: Option, - pub recurrence: Recurrence, - pub next_due: Day, - pub last_posted: Option, - /// Post automatically on the due date, or just remind. - pub auto_post: bool, - pub enabled: bool, - /// True when this was detected from history rather than entered. - pub detected: bool, -} - -// ---------------------------------------------------------------- ledger - -/// The whole file, in memory. -/// -/// Everything is loaded: a hundred thousand transactions is about 20 MB of -/// `Transaction`, and holding them means every filter, sort and report is a -/// pass over a `Vec` at memory speed rather than a round trip through SQL. -/// SQLite remains the file format and the durable store — this is a cache -/// that is rebuilt on load and kept in step on every write. -#[derive(Clone, Debug, Default)] -pub struct Ledger { - pub accounts: Vec, - pub categories: CategoryTree, - pub transactions: Vec, - pub payees: Vec, - pub rules: Vec, - pub budgets: Vec, - pub scheduled: Vec, - pub base_currency: Currency, -} - -impl Ledger { - pub fn account(&self, id: Id) -> Option<&Account> { - self.accounts.iter().find(|a| a.id == id) - } - - pub fn account_name(&self, id: Id) -> &str { - self.account(id).map(|a| a.name.as_str()).unwrap_or("") - } - - pub fn transaction(&self, id: Id) -> Option<&Transaction> { - self.transactions.iter().find(|t| t.id == id) - } - - /// Balance of an account as of a day (inclusive), opening balance - /// included. This is the number in the sidebar. - pub fn balance_on(&self, account: Id, day: Day) -> i64 { - let opening = self.account(account).map(|a| a.opening_balance).unwrap_or(0); - opening - + self - .transactions - .iter() - .filter(|t| t.account == account && t.date <= day) - .map(|t| t.amount) - .sum::() - } - - pub fn balance(&self, account: Id) -> i64 { - self.balance_on(account, Day::MAX) - } - - /// What the bank thinks you have: cleared and reconciled rows only. - /// The gap between this and [`Ledger::balance`] is money in flight. - pub fn cleared_balance(&self, account: Id) -> i64 { - let opening = self.account(account).map(|a| a.opening_balance).unwrap_or(0); - opening - + self - .transactions - .iter() - .filter(|t| t.account == account && t.cleared != Cleared::Uncleared) - .map(|t| t.amount) - .sum::() - } - - /// Assets minus debts across every on-budget account, as of a day. - pub fn net_worth_on(&self, day: Day) -> i64 { - self.accounts - .iter() - .filter(|a| !a.off_budget) - .map(|a| self.balance_on(a.id, day)) - .sum() - } - - /// A transfer pair is balanced when its two rows cancel out. An - /// unbalanced pair means an edit went wrong, and the UI says so rather - /// than quietly showing a net-worth number that is wrong. - pub fn transfer_is_balanced(&self, group: Id) -> bool { - let sum: i64 = self - .transactions - .iter() - .filter(|t| t.transfer_group == Some(group)) - .map(|t| t.amount) - .sum(); - sum == 0 - } - - /// Every transaction of an account, oldest first, with the running - /// balance after it — the ledger's most-used view. - pub fn register(&self, account: Id) -> Vec<(&Transaction, i64)> { - let mut rows: Vec<&Transaction> = - self.transactions.iter().filter(|t| t.account == account).collect(); - // Same-day rows need a stable tiebreak or the running balance - // jitters between loads; the id is insertion order, which is the - // order they were entered or imported in. - rows.sort_by_key(|t| (t.date, t.id)); - let mut balance = self.account(account).map(|a| a.opening_balance).unwrap_or(0); - rows.into_iter() - .map(|t| { - balance += t.amount; - (t, balance) - }) - .collect() - } - - pub fn uncategorized_count(&self) -> usize { - self.transactions - .iter() - .filter(|t| t.category.is_none() && t.splits.is_empty() && !t.is_transfer()) - .count() - } - - /// The next id to hand out for a table, for in-memory work before a - /// write reaches the database. - pub fn next_transaction_id(&self) -> Id { - self.transactions.iter().map(|t| t.id).max().unwrap_or(0) + 1 - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::date::from_ymd; - use crate::money::USD; - - fn ledger() -> Ledger { - let mut ledger = Ledger { base_currency: USD, ..Ledger::default() }; - let mut checking = Account::new("Checking", AccountKind::Checking, USD); - checking.id = 1; - checking.opening_balance = 100_000; // $1,000 - let mut card = Account::new("Card", AccountKind::CreditCard, USD); - card.id = 2; - ledger.accounts.push(checking); - ledger.accounts.push(card); - ledger - } - - #[test] - fn balances_include_the_opening_balance_and_respect_dates() { - let mut l = ledger(); - let mut t = Transaction::new(1, from_ymd(2024, 3, 4), "Rent", -150_000); - t.id = 1; - l.transactions.push(t); - assert_eq!(l.balance(1), -50_000); - assert_eq!(l.balance_on(1, from_ymd(2024, 3, 3)), 100_000); - assert_eq!(l.balance_on(1, from_ymd(2024, 3, 4)), -50_000); - } - - #[test] - fn cleared_balance_is_what_the_bank_shows() { - let mut l = ledger(); - let mut posted = Transaction::new(1, from_ymd(2024, 3, 1), "Salary", 300_000); - posted.id = 1; - posted.cleared = Cleared::Cleared; - let mut pending = Transaction::new(1, from_ymd(2024, 3, 2), "Coffee", -450); - pending.id = 2; - l.transactions.push(posted); - l.transactions.push(pending); - assert_eq!(l.balance(1), 399_550); - assert_eq!(l.cleared_balance(1), 400_000); - } - - #[test] - fn a_transfer_pair_cancels_out_and_leaves_net_worth_alone() { - let mut l = ledger(); - let mut out = Transaction::new(1, from_ymd(2024, 3, 4), "Card payment", -50_000); - out.id = 1; - out.transfer_group = Some(7); - let mut into = Transaction::new(2, from_ymd(2024, 3, 4), "Payment received", 50_000); - into.id = 2; - into.transfer_group = Some(7); - let before = l.net_worth_on(Day::MAX); - l.transactions.push(out); - l.transactions.push(into); - assert!(l.transfer_is_balanced(7)); - assert_eq!(l.net_worth_on(Day::MAX), before); - - // Break one side: the ledger must be able to say so. - l.transactions[1].amount = 40_000; - assert!(!l.transfer_is_balanced(7)); - } - - #[test] - fn splits_must_sum_to_the_transaction() { - let mut t = Transaction::new(1, from_ymd(2024, 3, 4), "Supermarket", -10_000); - assert_eq!(t.split_imbalance(), 0); // no splits: nothing to balance - t.splits = vec![ - Split { id: 1, category: Some(1), amount: -7_000, memo: String::new() }, - Split { id: 2, category: Some(2), amount: -2_000, memo: String::new() }, - ]; - assert_eq!(t.split_imbalance(), -1_000); - t.splits.push(Split { id: 3, category: Some(3), amount: -1_000, memo: String::new() }); - assert_eq!(t.split_imbalance(), 0); - assert_eq!(t.category_amounts().len(), 3); - } - - #[test] - fn the_running_balance_is_stable_for_same_day_rows() { - let mut l = ledger(); - for (id, amount) in [(1, -1_000), (2, -2_000), (3, 5_000)] { - let mut t = Transaction::new(1, from_ymd(2024, 3, 4), "x", amount); - t.id = id; - l.transactions.push(t); - } - let first: Vec = l.register(1).iter().map(|(_, b)| *b).collect(); - // Same input, reversed insertion: the register must not change. - l.transactions.reverse(); - let second: Vec = l.register(1).iter().map(|(_, b)| *b).collect(); - assert_eq!(first, second); - assert_eq!(*first.last().unwrap(), l.balance(1)); - } - - #[test] - fn import_fingerprints_survive_cosmetic_differences_only() { - let a = import_fingerprint(1, 100, -450, "SQ *BLUE BOTTLE 0123"); - let b = import_fingerprint(1, 100, -450, "sq *blue bottle 0123"); - assert_eq!(a, b, "case and spacing must not change the fingerprint"); - - let different_amount = import_fingerprint(1, 100, -451, "SQ *BLUE BOTTLE 0123"); - let different_day = import_fingerprint(1, 101, -450, "SQ *BLUE BOTTLE 0123"); - let different_account = import_fingerprint(2, 100, -450, "SQ *BLUE BOTTLE 0123"); - assert_ne!(a, different_amount); - assert_ne!(a, different_day); - assert_ne!(a, different_account); - } - - #[test] - fn rules_match_the_way_a_person_expects() { - let rule = Rule { - id: 1, - name: "Coffee".into(), - match_on: MatchOn::Raw, - how: MatchHow::Contains, - pattern: "blue bottle".into(), - amount_min: 0, - amount_max: 0, - set_category: Some(9), - rename_payee: Some("Blue Bottle".into()), - set_memo: None, - flag: false, - priority: 0, - enabled: true, - hits: 0, - }; - assert!(rule.matches("", "", "SQ *BLUE BOTTLE 0123", -450)); - assert!(!rule.matches("", "", "STARBUCKS", -450)); - - let disabled = Rule { enabled: false, ..rule.clone() }; - assert!(!disabled.matches("", "", "SQ *BLUE BOTTLE 0123", -450)); - - let big = Rule { - match_on: MatchOn::Amount, - how: MatchHow::AmountBetween, - amount_min: -100_000, - amount_max: -50_000, - ..rule - }; - assert!(big.matches("", "", "", -75_000)); - assert!(!big.matches("", "", "", -10_000)); - } - - #[test] - fn category_paths_and_budget_order_read_like_the_screen() { - let mut tree = CategoryTree::default(); - let mut food = Category::group("Food", CategoryKind::Expense); - food.id = 1; - let mut groceries = Category::child("Groceries", 1, CategoryKind::Expense); - groceries.id = 2; - groceries.budgeted = true; - let mut income = Category::group("Income", CategoryKind::Income); - income.id = 3; - tree.categories = vec![food, groceries, income]; - assert_eq!(tree.path(2), "Food: Groceries"); - assert_eq!(tree.path(1), "Food"); - assert_eq!(tree.group_of(2), Some(1)); - // Income groups sort above expense groups. - let order: Vec<&str> = - tree.budget_order().iter().map(|c| c.name.as_str()).collect(); - assert_eq!(order, ["Income", "Food", "Groceries"]); - } - - #[test] - fn recurrence_clamps_month_ends_instead_of_skipping() { - let jan31 = from_ymd(2024, 1, 31); - assert_eq!(Recurrence::Monthly.next(jan31), from_ymd(2024, 2, 29)); - assert_eq!(Recurrence::Weekly.next(jan31), from_ymd(2024, 2, 7)); - assert_eq!(Recurrence::Yearly.next(jan31), from_ymd(2025, 1, 31)); - } - - #[test] - fn budget_lines_classify_themselves() { - let over = BudgetLine { assigned: 10_000, spent: 12_000, carried: 0, available: -2_000 }; - assert_eq!(over.state(), BudgetState::Overspent); - let untouched = BudgetLine::default(); - assert_eq!(untouched.state(), BudgetState::Untouched); - let on_track = BudgetLine { assigned: 10_000, spent: 4_000, carried: 0, available: 6_000 }; - assert_eq!(on_track.state(), BudgetState::OnTrack); - } -} diff --git a/apps/finance/src/money.rs b/apps/finance/src/money.rs deleted file mode 100644 index c8ed9870d..000000000 --- a/apps/finance/src/money.rs +++ /dev/null @@ -1,382 +0,0 @@ -//! Money is an integer number of minor units. Never a float. -//! -//! A balance is a sum of thousands of amounts, and every one of those sums -//! has to come out the way a bank would compute it. Binary floating point -//! cannot represent 0.10, so a ledger built on `f64` drifts: add a tenth a -//! thousand times and you are three cents short of a hundred. Everything -//! here is `i64` minor units — cents for USD/EUR, but also 0 decimals for -//! JPY and 3 for BHD, which is why the scale lives on the currency rather -//! than being assumed to be 100. -//! -//! `i64` cents reaches ±92 quadrillion. That is not a limit anyone hits, -//! and it makes every intermediate sum exact. - -use std::fmt; - -/// A currency, as much of ISO 4217 as a ledger needs: how many decimal -/// places it has, and how it is written. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Currency { - pub code: &'static str, - pub symbol: &'static str, - /// Decimal places — the power of ten between the major unit and the - /// minor unit this crate stores. - pub decimals: u8, - /// Symbol before the number (`$12.34`) or after it (`12,34 €`). - pub symbol_first: bool, -} - -pub const USD: Currency = - Currency { code: "USD", symbol: "$", decimals: 2, symbol_first: true }; -pub const EUR: Currency = - Currency { code: "EUR", symbol: "€", decimals: 2, symbol_first: false }; -pub const GBP: Currency = - Currency { code: "GBP", symbol: "£", decimals: 2, symbol_first: true }; -pub const JPY: Currency = - Currency { code: "JPY", symbol: "¥", decimals: 0, symbol_first: true }; -pub const CHF: Currency = - Currency { code: "CHF", symbol: "CHF", decimals: 2, symbol_first: true }; - -/// Every currency this build knows, for pickers and for parsing a code out -/// of an imported file. -pub const CURRENCIES: [Currency; 5] = [USD, EUR, GBP, JPY, CHF]; - -impl Default for Currency { - /// A file that has not said otherwise. `Ledger` derives `Default`, and - /// a currency-less amount is not a thing this app can represent. - fn default() -> Currency { - USD - } -} - -pub fn currency_by_code(code: &str) -> Option { - CURRENCIES - .iter() - .copied() - .find(|c| c.code.eq_ignore_ascii_case(code)) -} - -/// How a number was written in the file we are reading. Bank exports differ -/// on every one of these axes, and guessing wrong turns 1.234,56 into 1.23. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub struct AmountFormat { - /// `,` in `1.234,56` (continental) or `.` in `1,234.56` (anglo). - pub decimal_comma: bool, - /// A trailing `-` means negative: `1.234,56-` (SEPA, some German banks). - pub trailing_minus: bool, - /// `(1,234.56)` means negative (accounting convention). - pub parens_negative: bool, -} - -/// Parse a money amount out of a cell of an imported file. -/// -/// Deliberately liberal: currency symbols, spaces (including the narrow -/// no-break space German banks use as a thousands separator), `+` signs and -/// thousands separators are all discarded, because every bank writes them -/// differently and none of them mean anything. What it will NOT do is -/// guess the decimal separator when the file has told us — pass the format -/// sniffed from the whole column ([`sniff_amount_format`]), never per cell. -/// Deciding per cell is how `1.234` becomes 1.23 in one row and 1234.00 in -/// the next. -pub fn parse_amount(text: &str, format: AmountFormat, decimals: u8) -> Option { - let mut cleaned = String::with_capacity(text.len()); - let mut negative = false; - for ch in text.chars() { - match ch { - '-' | '\u{2212}' => negative = true, // ASCII hyphen or real minus - '(' if format.parens_negative => negative = true, - '0'..='9' => cleaned.push(ch), - ',' if format.decimal_comma => cleaned.push('.'), - '.' if !format.decimal_comma => cleaned.push('.'), - // Thousands separators and everything else: currency symbols, - // spaces, NBSP, apostrophes (Swiss 1'234.56), `+`, `)`. - _ => {} - } - } - if cleaned.is_empty() || !cleaned.chars().any(|c| c.is_ascii_digit()) { - return None; - } - // More than one separator left means the extras were thousands marks - // ("1.234.567,89" cleaned to "1.234.567.89"): keep the last one. - let value = if cleaned.matches('.').count() > 1 { - let last = cleaned.rfind('.').unwrap(); - let mut merged: String = cleaned[..last].replace('.', ""); - merged.push('.'); - merged.push_str(&cleaned[last + 1..]); - merged - } else { - cleaned - }; - let (whole, frac) = match value.split_once('.') { - Some((w, f)) => (w, f), - None => (value.as_str(), ""), - }; - // A group of exactly three digits after the only separator, in a file - // whose decimal separator we believe is the other character, was a - // thousands separator: "1.234" is 1234, not 1.23. - let scale = 10i64.checked_pow(decimals as u32)?; - let whole_value: i64 = if whole.is_empty() { 0 } else { whole.parse().ok()? }; - let mut minor = whole_value.checked_mul(scale)?; - if !frac.is_empty() { - let digits: String = frac.chars().take(decimals as usize).collect(); - let mut fraction: i64 = if digits.is_empty() { 0 } else { digits.parse().ok()? }; - // Pad "5" to "50" for a 2-decimal currency. - for _ in digits.len()..decimals as usize { - fraction = fraction.checked_mul(10)?; - } - // Round rather than truncate on extra precision (a 4-decimal FX - // amount landing in a 2-decimal account). - let round_up = frac - .chars() - .nth(decimals as usize) - .is_some_and(|c| c >= '5' && c <= '9'); - minor = minor.checked_add(fraction)?; - if round_up { - minor = minor.checked_add(1)?; - } - } - if negative || format.trailing_minus && text.trim_end().ends_with('-') { - minor = -minor; - } - Some(minor) -} - -/// Work out how a column of amounts is written by looking at all of it. -/// -/// The decision that matters is the decimal separator, and a single cell -/// often cannot settle it: `1.234` is ambiguous, `1.234,56` is not. So the -/// whole column votes — any cell with both separators, or with a comma -/// followed by exactly two digits at the end, is evidence. -pub fn sniff_amount_format<'a>(cells: impl Iterator) -> AmountFormat { - let mut comma_decimal = 0usize; - let mut dot_decimal = 0usize; - let mut trailing_minus = false; - let mut parens = false; - for cell in cells { - let cell = cell.trim(); - if cell.is_empty() { - continue; - } - if cell.ends_with('-') { - trailing_minus = true; - } - if cell.starts_with('(') && cell.ends_with(')') { - parens = true; - } - let last_comma = cell.rfind(','); - let last_dot = cell.rfind('.'); - match (last_comma, last_dot) { - // Both present: the LAST one is the decimal separator. - (Some(c), Some(d)) => { - if c > d { - comma_decimal += 1; - } else { - dot_decimal += 1; - } - } - // One separator with 1-2 trailing digits reads as a decimal; - // with exactly 3 it reads as a thousands mark and says nothing. - (Some(c), None) => { - let tail = cell.len() - c - 1; - if tail <= 2 { - comma_decimal += 1; - } - } - (None, Some(d)) => { - let tail = cell.len() - d - 1; - if tail <= 2 { - dot_decimal += 1; - } - } - (None, None) => {} - } - } - AmountFormat { - decimal_comma: comma_decimal > dot_decimal, - trailing_minus, - parens_negative: parens, - } -} - -/// `1234567` cents → `"12,345.67"`. Grouping and the decimal mark follow -/// the currency's convention, not the machine's locale: a ledger of euros -/// reads the same on every machine that opens the file. -pub fn format_minor(minor: i64, currency: Currency) -> String { - let decimals = currency.decimals as usize; - let negative = minor < 0; - let magnitude = minor.unsigned_abs(); - let scale = 10u64.pow(decimals as u32); - let whole = magnitude / scale; - let frac = magnitude % scale; - - let (group, point) = if currency.decimals == 2 && !currency.symbol_first { - ('.', ',') // continental: 1.234,56 - } else { - (',', '.') // anglo: 1,234.56 - }; - - let digits = whole.to_string(); - let mut grouped = String::with_capacity(digits.len() + digits.len() / 3 + 4); - for (i, ch) in digits.chars().enumerate() { - if i > 0 && (digits.len() - i) % 3 == 0 { - grouped.push(group); - } - grouped.push(ch); - } - let mut out = String::with_capacity(grouped.len() + decimals + 4); - if negative { - out.push('-'); - } - out.push_str(&grouped); - if decimals > 0 { - out.push(point); - out.push_str(&format!("{frac:0width$}", width = decimals)); - } - out -} - -/// With the currency's symbol attached, the way that currency writes it. -pub fn format_money(minor: i64, currency: Currency) -> String { - let number = format_minor(minor, currency); - if currency.symbol_first { - // The sign stays outside the symbol: -$12.34, not $-12.34. - match number.strip_prefix('-') { - Some(rest) => format!("-{}{}", currency.symbol, rest), - None => format!("{}{}", currency.symbol, number), - } - } else { - format!("{} {}", number, currency.symbol) - } -} - -/// Short form for chart axes and dense cells: `12.3k`, `1.2M`. Keeps the -/// sign, drops the currency. -pub fn format_compact(minor: i64, currency: Currency) -> String { - let scale = 10i64.pow(currency.decimals as u32); - let major = minor as f64 / scale as f64; - let magnitude = major.abs(); - let sign = if major < 0.0 { "-" } else { "" }; - if magnitude >= 1_000_000.0 { - format!("{sign}{:.1}M", magnitude / 1_000_000.0) - } else if magnitude >= 1_000.0 { - format!("{sign}{:.1}k", magnitude / 1_000.0) - } else { - format!("{sign}{:.0}", magnitude) - } -} - -/// A signed amount with its currency, for display and for the few places -/// that carry an amount around on its own. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Money { - pub minor: i64, - pub currency: Currency, -} - -impl Money { - pub fn new(minor: i64, currency: Currency) -> Money { - Money { minor, currency } - } - - pub fn zero(currency: Currency) -> Money { - Money { minor: 0, currency } - } - - pub fn is_negative(&self) -> bool { - self.minor < 0 - } - - pub fn abs(&self) -> Money { - Money { minor: self.minor.abs(), currency: self.currency } - } -} - -impl fmt::Display for Money { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&format_money(self.minor, self.currency)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn anglo() -> AmountFormat { - AmountFormat::default() - } - - fn continental() -> AmountFormat { - AmountFormat { decimal_comma: true, ..AmountFormat::default() } - } - - #[test] - fn parses_the_shapes_banks_actually_export() { - assert_eq!(parse_amount("1234.56", anglo(), 2), Some(123456)); - assert_eq!(parse_amount("1,234.56", anglo(), 2), Some(123456)); - assert_eq!(parse_amount("$1,234.56", anglo(), 2), Some(123456)); - assert_eq!(parse_amount("-1,234.56", anglo(), 2), Some(-123456)); - assert_eq!(parse_amount("1.234,56", continental(), 2), Some(123456)); - assert_eq!(parse_amount("1.234.567,89", continental(), 2), Some(123456789)); - assert_eq!(parse_amount("1'234.56", anglo(), 2), Some(123456)); // Swiss - assert_eq!(parse_amount("12,34 €", continental(), 2), Some(1234)); - // Fewer decimals written than the currency has. - assert_eq!(parse_amount("5.5", anglo(), 2), Some(550)); - assert_eq!(parse_amount("5", anglo(), 2), Some(500)); - // Zero-decimal currency. - assert_eq!(parse_amount("1,250", anglo(), 0), Some(1250)); - // Junk is None, not zero: a failed parse must never post 0.00. - assert_eq!(parse_amount("", anglo(), 2), None); - assert_eq!(parse_amount("n/a", anglo(), 2), None); - assert_eq!(parse_amount("--", anglo(), 2), None); - } - - #[test] - fn honours_the_negative_conventions() { - let trailing = AmountFormat { trailing_minus: true, ..anglo() }; - assert_eq!(parse_amount("1234.56-", trailing, 2), Some(-123456)); - let parens = AmountFormat { parens_negative: true, ..anglo() }; - assert_eq!(parse_amount("(1,234.56)", parens, 2), Some(-123456)); - // A real Unicode minus, which some exports use. - assert_eq!(parse_amount("\u{2212}12.00", anglo(), 2), Some(-1200)); - } - - #[test] - fn extra_precision_rounds_rather_than_truncates() { - assert_eq!(parse_amount("1.005", anglo(), 2), Some(101)); - assert_eq!(parse_amount("1.004", anglo(), 2), Some(100)); - } - - #[test] - fn sniffing_reads_the_column_not_the_cell() { - // Ambiguous alone; the column settles it. - let german = ["1.234,56", "-89,10", "1.000,00"]; - assert!(sniff_amount_format(german.iter().copied()).decimal_comma); - let anglo_col = ["1,234.56", "-89.10", "1,000.00"]; - assert!(!sniff_amount_format(anglo_col.iter().copied()).decimal_comma); - // Thousands-only groups say nothing and must not flip the vote. - let ambiguous = ["1.234", "5.678"]; - assert!(!sniff_amount_format(ambiguous.iter().copied()).decimal_comma); - let trailing = ["1234.56-", "10.00"]; - assert!(sniff_amount_format(trailing.iter().copied()).trailing_minus); - } - - #[test] - fn formats_the_way_each_currency_is_written() { - assert_eq!(format_money(123456, USD), "$1,234.56"); - assert_eq!(format_money(-123456, USD), "-$1,234.56"); - assert_eq!(format_money(123456, EUR), "1.234,56 €"); - assert_eq!(format_money(1250, JPY), "¥1,250"); - assert_eq!(format_minor(0, USD), "0.00"); - assert_eq!(format_minor(-5, USD), "-0.05"); - assert_eq!(format_compact(123456789, USD), "1.2M"); - assert_eq!(format_compact(-1234567, USD), "-12.3k"); - } - - #[test] - fn a_thousand_dimes_are_exactly_a_hundred() { - // The whole reason this module exists. - let total: i64 = (0..1000).map(|_| 10i64).sum(); - assert_eq!(total, 10_000); - assert_eq!(format_money(total, USD), "$100.00"); - } -} diff --git a/apps/finance/src/report.rs b/apps/finance/src/report.rs deleted file mode 100644 index 58f828fbd..000000000 --- a/apps/finance/src/report.rs +++ /dev/null @@ -1,635 +0,0 @@ -//! Every number the screens show, computed from the ledger in memory. -//! -//! These are the reports the commercial products converged on — spending -//! by category, income against expense, net worth over time, category -//! drilldown, merchant ranking, budget available — and they are all one -//! pass over a `Vec`. That is the point: a decade of history -//! is a few hundred thousand structs, so a report is a millisecond and can -//! be recomputed on every keystroke of a filter instead of being cached, -//! invalidated, and got wrong. -//! -//! Two rules run through all of it: -//! -//! * **Transfers are not spending.** Moving money to savings is not an -//! expense, and paying a credit card is not spending twice. Anything -//! with a [`Transaction::transfer_group`], or in a -//! [`CategoryKind::Transfer`] category, is excluded from every -//! income/expense figure — this is the single most common way a naive -//! finance report lies. -//! * **Splits are counted per part.** A supermarket trip split between -//! food and household appears in both categories, for its own share. - -use crate::date::{self, Day, DateRange, MonthKey}; -use crate::model::*; - -/// Positive amounts are money in, negative money out — the ledger's own -/// convention, kept all the way to the screen. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct Flow { - pub income: i64, - /// Positive: what left. (Reports read better when spending counts up.) - pub expense: i64, -} - -impl Flow { - pub fn net(&self) -> i64 { - self.income - self.expense - } -} - -/// True when a transaction is real spending or real income, rather than -/// money moving between the user's own pockets. -pub fn is_spending(txn: &Transaction, categories: &CategoryTree) -> bool { - if txn.transfer_group.is_some() { - return false; - } - match txn.category { - Some(id) => categories.kind_of(id) != CategoryKind::Transfer, - // An uncategorized row is still real money; it shows up as - // "Uncategorized", which is what makes people categorize it. - None => !txn.splits.is_empty() || true, - } -} - -/// Income and expense for a date range. -pub fn flow(ledger: &Ledger, range: DateRange) -> Flow { - let mut flow = Flow::default(); - for txn in &ledger.transactions { - if !range.contains(txn.date) || !is_spending(txn, &ledger.categories) { - continue; - } - if ledger.account(txn.account).is_some_and(|a| a.off_budget) { - continue; - } - if txn.amount >= 0 { - flow.income += txn.amount; - } else { - flow.expense += -txn.amount; - } - } - flow -} - -/// Income and expense per month, oldest first — the bars on the reports -/// screen and the shape of the cash-flow chart. -pub fn monthly_flow(ledger: &Ledger, months: i32, today: Day) -> Vec<(MonthKey, Flow)> { - let first = date::month_key(date::add_months(today, -(months - 1))); - let mut out: Vec<(MonthKey, Flow)> = - (0..months).map(|i| (first + i, Flow::default())).collect(); - for txn in &ledger.transactions { - if !is_spending(txn, &ledger.categories) { - continue; - } - if ledger.account(txn.account).is_some_and(|a| a.off_budget) { - continue; - } - let key = date::month_key(txn.date); - let Some(index) = key.checked_sub(first).filter(|i| *i >= 0 && *i < months) else { - continue; - }; - let slot = &mut out[index as usize].1; - if txn.amount >= 0 { - slot.income += txn.amount; - } else { - slot.expense += -txn.amount; - } - } - out -} - -/// What was spent per category in a range, biggest first. Groups are -/// rolled up from their children, so "Food" totals its own leaves. -pub fn spending_by_category(ledger: &Ledger, range: DateRange) -> Vec<(Option, i64)> { - let mut totals: std::collections::HashMap, i64> = std::collections::HashMap::new(); - for txn in &ledger.transactions { - if !range.contains(txn.date) || !is_spending(txn, &ledger.categories) { - continue; - } - if ledger.account(txn.account).is_some_and(|a| a.off_budget) { - continue; - } - for (category, amount) in txn.category_amounts() { - if amount >= 0 { - continue; // income is not spending - } - if category.is_some_and(|id| { - ledger.categories.kind_of(id) != CategoryKind::Expense - }) { - continue; - } - *totals.entry(category).or_default() += -amount; - } - } - let mut out: Vec<(Option, i64)> = totals.into_iter().collect(); - out.sort_by_key(|(id, total)| (std::cmp::Reverse(*total), id.unwrap_or(0))); - out -} - -/// The same, rolled up to top-level groups — the pie every product opens -/// with. -pub fn spending_by_group(ledger: &Ledger, range: DateRange) -> Vec<(Option, i64)> { - let mut totals: std::collections::HashMap, i64> = std::collections::HashMap::new(); - for (category, amount) in spending_by_category(ledger, range) { - let group = category.and_then(|id| ledger.categories.group_of(id)); - *totals.entry(group).or_default() += amount; - } - let mut out: Vec<(Option, i64)> = totals.into_iter().collect(); - out.sort_by_key(|(id, total)| (std::cmp::Reverse(*total), id.unwrap_or(0))); - out -} - -/// Who took the most money, biggest first — the merchant analysis every -/// product has and everyone actually reads. -pub fn top_payees(ledger: &Ledger, range: DateRange, limit: usize) -> Vec<(String, i64, usize)> { - let mut totals: std::collections::HashMap<&str, (i64, usize)> = - std::collections::HashMap::new(); - for txn in &ledger.transactions { - if !range.contains(txn.date) || !is_spending(txn, &ledger.categories) || txn.amount >= 0 { - continue; - } - let entry = totals.entry(txn.payee.as_str()).or_default(); - entry.0 += -txn.amount; - entry.1 += 1; - } - let mut out: Vec<(String, i64, usize)> = totals - .into_iter() - .map(|(payee, (total, count))| (payee.to_string(), total, count)) - .collect(); - out.sort_by_key(|(payee, total, _)| (std::cmp::Reverse(*total), payee.clone())); - out.truncate(limit); - out -} - -/// Net worth at the end of each of the last `months` months. -/// -/// Computed by walking the transactions once in date order and carrying a -/// running total, rather than by asking for a balance per month — the -/// naive version is O(months × transactions) and is why some products take -/// a second to draw this. -pub fn net_worth_series(ledger: &Ledger, months: i32, today: Day) -> Vec<(MonthKey, i64)> { - let first = date::month_key(date::add_months(today, -(months - 1))); - let on_budget: std::collections::HashSet = ledger - .accounts - .iter() - .filter(|a| !a.off_budget) - .map(|a| a.id) - .collect(); - - // Everything before the window is the opening position. - let window_start = date::month_key_start(first); - let mut running: i64 = ledger - .accounts - .iter() - .filter(|a| !a.off_budget) - .map(|a| a.opening_balance) - .sum(); - let mut sorted: Vec<&Transaction> = ledger - .transactions - .iter() - .filter(|t| on_budget.contains(&t.account)) - .collect(); - sorted.sort_by_key(|t| t.date); - - let mut out = Vec::with_capacity(months as usize); - let mut index = 0usize; - for txn in sorted.iter() { - if txn.date >= window_start { - break; - } - running += txn.amount; - index += 1; - } - for offset in 0..months { - let month_end = date::month_end(date::month_key_start(first + offset)); - while index < sorted.len() && sorted[index].date <= month_end { - running += sorted[index].amount; - index += 1; - } - out.push((first + offset, running)); - } - out -} - -/// A daily balance series for one account, for the sparkline in its row. -pub fn balance_series(ledger: &Ledger, account: Id, days: i32, today: Day) -> Vec { - let start = today - days + 1; - let opening = ledger.account(account).map(|a| a.opening_balance).unwrap_or(0); - let mut rows: Vec<&Transaction> = - ledger.transactions.iter().filter(|t| t.account == account).collect(); - rows.sort_by_key(|t| t.date); - let mut running = opening; - let mut index = 0usize; - while index < rows.len() && rows[index].date < start { - running += rows[index].amount; - index += 1; - } - let mut out = Vec::with_capacity(days as usize); - for day in start..=today { - while index < rows.len() && rows[index].date <= day { - running += rows[index].amount; - index += 1; - } - out.push(running as f64); - } - out -} - -/// The budget screen's rows for one month: what was assigned, what was -/// spent, and what is left — including what rolled in from before. -/// -/// Rollover is computed from the start of the file rather than stored, -/// because a stored carry goes stale the moment an old transaction is -/// edited, and editing old transactions is exactly what people do. -pub fn budget_lines( - ledger: &Ledger, - month: MonthKey, -) -> Vec<(Id, BudgetLine)> { - let mut out = Vec::new(); - for category in ledger.categories.budget_order() { - if category.is_group() || category.kind != CategoryKind::Expense { - continue; - } - let mut line = BudgetLine::default(); - line.assigned = assigned_for(ledger, category.id, month); - line.spent = spent_in(ledger, category.id, month); - if category.rollover { - // Walk from the first month that has any activity. - let mut carry = 0i64; - if let Some(first) = first_month(ledger) { - let mut cursor = first; - while cursor < month { - carry += assigned_for(ledger, category.id, cursor) - - spent_in(ledger, category.id, cursor); - // A rollover category cannot carry a negative balance - // forward: overspending is settled in the month it - // happened, which is what YNAB does and what keeps the - // number understandable. - carry = carry.max(0); - cursor += 1; - } - } - line.carried = carry; - } - line.available = line.carried + line.assigned - line.spent; - out.push((category.id, line)); - } - out -} - -fn assigned_for(ledger: &Ledger, category: Id, month: MonthKey) -> i64 { - ledger - .budgets - .iter() - .find(|b| b.category == category && b.month == month) - .map(|b| b.assigned) - .unwrap_or(0) -} - -fn spent_in(ledger: &Ledger, category: Id, month: MonthKey) -> i64 { - let mut total = 0i64; - for txn in &ledger.transactions { - if date::month_key(txn.date) != month || !is_spending(txn, &ledger.categories) { - continue; - } - for (id, amount) in txn.category_amounts() { - if id == Some(category) && amount < 0 { - total += -amount; - } - } - } - total -} - -fn first_month(ledger: &Ledger) -> Option { - ledger.transactions.iter().map(|t| date::month_key(t.date)).min() -} - -/// What is due in the next `days`, soonest first — the "upcoming" list. -pub fn upcoming(ledger: &Ledger, days: i32, today: Day) -> Vec<&Scheduled> { - let horizon = today + days; - let mut out: Vec<&Scheduled> = ledger - .scheduled - .iter() - .filter(|s| s.enabled && s.next_due <= horizon) - .collect(); - out.sort_by_key(|s| s.next_due); - out -} - -/// Where the balance is heading: today's balance, then each scheduled item -/// applied on its due date. The forecast every product added late and -/// everyone asks for. -pub fn cash_forecast(ledger: &Ledger, account: Id, days: i32, today: Day) -> Vec { - let mut balance = ledger.balance_on(account, today); - let mut out = Vec::with_capacity(days as usize); - for offset in 0..days { - let day = today + offset; - for item in &ledger.scheduled { - if !item.enabled || item.account != account { - continue; - } - // Walk this schedule's occurrences into the window. - let mut due = item.next_due; - while due < day { - due = item.recurrence.next(due); - } - if due == day { - balance += item.amount; - } - } - out.push(balance as f64); - } - out -} - -/// Recurring charges the ledger can see for itself — the subscription -/// screen, without anyone having to declare anything. -/// -/// A payee qualifies when it has charged a similar amount at a regular -/// interval at least three times. Three is the smallest number that can -/// tell a rhythm from a coincidence. -pub fn detected_subscriptions(ledger: &Ledger, today: Day) -> Vec<(String, i64, Recurrence, Day)> { - let mut by_payee: std::collections::HashMap<&str, Vec<&Transaction>> = - std::collections::HashMap::new(); - let year_ago = today - 400; - for txn in &ledger.transactions { - if txn.amount >= 0 || txn.date < year_ago || txn.transfer_group.is_some() { - continue; - } - by_payee.entry(txn.payee.as_str()).or_default().push(txn); - } - let mut out = Vec::new(); - for (payee, mut rows) in by_payee { - if rows.len() < 3 { - continue; - } - rows.sort_by_key(|t| t.date); - let gaps: Vec = rows.windows(2).map(|w| w[1].date - w[0].date).collect(); - let average = gaps.iter().sum::() / gaps.len() as i32; - let recurrence = match average { - 5..=9 => Recurrence::Weekly, - 12..=16 => Recurrence::Fortnightly, - 26..=35 => Recurrence::Monthly, - 85..=100 => Recurrence::Quarterly, - 350..=380 => Recurrence::Yearly, - _ => continue, - }; - // The amounts have to be alike: a supermarket visited weekly is - // not a subscription, a gym charged the same every month is. - let amounts: Vec = rows.iter().map(|t| t.amount).collect(); - let typical = amounts[amounts.len() / 2]; - let steady = amounts - .iter() - .all(|a| (a - typical).abs() <= (typical.abs() / 10).max(100)); - if !steady { - continue; - } - let last = rows.last().unwrap().date; - out.push((payee.to_string(), typical, recurrence, recurrence.next(last))); - } - out.sort_by_key(|(payee, amount, _, _)| (*amount, payee.clone())); - out -} - -/// Rows the user should look at: uncategorized, unbalanced splits, and -/// transfers whose halves do not cancel. -pub fn needs_attention(ledger: &Ledger) -> Vec<(Id, &'static str)> { - let mut out = Vec::new(); - for txn in &ledger.transactions { - if txn.split_imbalance() != 0 { - out.push((txn.id, "split does not add up")); - } else if txn.category.is_none() && txn.splits.is_empty() && !txn.is_transfer() { - out.push((txn.id, "no category")); - } - } - for group in ledger - .transactions - .iter() - .filter_map(|t| t.transfer_group) - .collect::>() - { - if !ledger.transfer_is_balanced(group) { - if let Some(txn) = ledger.transactions.iter().find(|t| t.transfer_group == Some(group)) - { - out.push((txn.id, "transfer does not cancel")); - } - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::date::from_ymd; - use crate::money::EUR; - - /// A tiny ledger with one of everything the reports have to get right. - fn ledger() -> Ledger { - let mut ledger = Ledger { base_currency: EUR, ..Ledger::default() }; - let mut checking = Account::new("Checking", AccountKind::Checking, EUR); - checking.id = 1; - checking.opening_balance = 100_000; - let mut savings = Account::new("Savings", AccountKind::Savings, EUR); - savings.id = 2; - ledger.accounts = vec![checking, savings]; - - let mut food = Category::group("Food", CategoryKind::Expense); - food.id = 10; - let mut groceries = Category::child("Groceries", 10, CategoryKind::Expense); - groceries.id = 11; - let mut restaurants = Category::child("Restaurants", 10, CategoryKind::Expense); - restaurants.id = 12; - let mut income = Category::group("Income", CategoryKind::Income); - income.id = 20; - let mut salary = Category::child("Salary", 20, CategoryKind::Income); - salary.id = 21; - ledger.categories.categories = vec![food, groceries, restaurants, income, salary]; - - let mut next_id = 100; - let mut push = |ledger: &mut Ledger, account, date, payee: &str, amount, category| { - let mut txn = Transaction::new(account, date, payee, amount); - txn.id = next_id; - next_id += 1; - txn.category = category; - ledger.transactions.push(txn); - }; - push(&mut ledger, 1, from_ymd(2024, 3, 25), "Employer", 300_000, Some(21)); - push(&mut ledger, 1, from_ymd(2024, 3, 4), "Albert Heijn", -8_000, Some(11)); - push(&mut ledger, 1, from_ymd(2024, 3, 11), "Albert Heijn", -6_500, Some(11)); - push(&mut ledger, 1, from_ymd(2024, 3, 14), "Café", -3_200, Some(12)); - push(&mut ledger, 1, from_ymd(2024, 2, 4), "Albert Heijn", -7_000, Some(11)); - - // A transfer to savings: not spending, not income. - let mut out = Transaction::new(1, from_ymd(2024, 3, 26), "Savings", -50_000); - out.id = 200; - out.transfer_group = Some(1); - let mut into = Transaction::new(2, from_ymd(2024, 3, 26), "From checking", 50_000); - into.id = 201; - into.transfer_group = Some(1); - ledger.transactions.push(out); - ledger.transactions.push(into); - ledger - } - - #[test] - fn transfers_are_never_spending_or_income() { - let ledger = ledger(); - let march = DateRange::month(date::month_key(from_ymd(2024, 3, 1))); - let flow = flow(&ledger, march); - assert_eq!(flow.income, 300_000, "the transfer must not count as income"); - assert_eq!(flow.expense, 8_000 + 6_500 + 3_200, "nor the other half as spending"); - assert_eq!(flow.net(), 300_000 - 17_700); - - // And the money did not vanish: net worth is unchanged by it. - let before = ledger.net_worth_on(from_ymd(2024, 3, 25)); - let after = ledger.net_worth_on(from_ymd(2024, 3, 26)); - assert_eq!(before, after); - } - - #[test] - fn category_totals_roll_up_and_rank() { - let ledger = ledger(); - let march = DateRange::month(date::month_key(from_ymd(2024, 3, 1))); - let by_category = spending_by_category(&ledger, march); - assert_eq!(by_category[0], (Some(11), 14_500)); - assert_eq!(by_category[1], (Some(12), 3_200)); - let by_group = spending_by_group(&ledger, march); - assert_eq!(by_group[0], (Some(10), 17_700), "Food totals its children"); - } - - #[test] - fn splits_are_counted_in_each_of_their_parts() { - let mut ledger = ledger(); - let mut txn = Transaction::new(1, from_ymd(2024, 3, 20), "Supermarket", -10_000); - txn.id = 300; - txn.splits = vec![ - Split { id: 1, category: Some(11), amount: -6_000, memo: String::new() }, - Split { id: 2, category: Some(12), amount: -4_000, memo: String::new() }, - ]; - ledger.transactions.push(txn); - let march = DateRange::month(date::month_key(from_ymd(2024, 3, 1))); - let by_category = spending_by_category(&ledger, march); - let groceries = by_category.iter().find(|(id, _)| *id == Some(11)).unwrap().1; - let restaurants = by_category.iter().find(|(id, _)| *id == Some(12)).unwrap().1; - assert_eq!(groceries, 14_500 + 6_000); - assert_eq!(restaurants, 3_200 + 4_000); - } - - #[test] - fn monthly_flow_lines_up_with_the_months_asked_for() { - let ledger = ledger(); - let series = monthly_flow(&ledger, 3, from_ymd(2024, 3, 31)); - assert_eq!(series.len(), 3); - assert_eq!(series[2].0, date::month_key(from_ymd(2024, 3, 1))); - assert_eq!(series[2].1.income, 300_000); - assert_eq!(series[1].1.expense, 7_000, "February had one shop"); - assert_eq!(series[0].1, Flow::default(), "January is empty, not missing"); - } - - #[test] - fn net_worth_walks_forward_once() { - let ledger = ledger(); - let series = net_worth_series(&ledger, 3, from_ymd(2024, 3, 31)); - assert_eq!(series.len(), 3); - // January: nothing had happened, so just the opening balance. - assert_eq!(series[0].1, 100_000); - // February: one shop. - assert_eq!(series[1].1, 100_000 - 7_000); - // March: everything, and the transfer cancels out. - assert_eq!(series[2].1, ledger.net_worth_on(from_ymd(2024, 3, 31))); - } - - #[test] - fn top_payees_rank_by_money_not_by_count() { - let ledger = ledger(); - let march = DateRange::month(date::month_key(from_ymd(2024, 3, 1))); - let payees = top_payees(&ledger, march, 5); - assert_eq!(payees[0].0, "Albert Heijn"); - assert_eq!(payees[0].1, 14_500); - assert_eq!(payees[0].2, 2); - assert!(payees.iter().all(|(name, _, _)| name != "Savings")); - } - - #[test] - fn budget_available_carries_only_where_asked() { - let mut ledger = ledger(); - let feb = date::month_key(from_ymd(2024, 2, 1)); - let mar = date::month_key(from_ymd(2024, 3, 1)); - // Groceries: no rollover. Restaurants: rollover. - ledger.categories.categories[1].rollover = false; - ledger.categories.categories[2].rollover = true; - for month in [feb, mar] { - ledger.budgets.push(BudgetEntry { category: 11, month, assigned: 10_000, rollover: false }); - ledger.budgets.push(BudgetEntry { category: 12, month, assigned: 5_000, rollover: true }); - } - let lines = budget_lines(&ledger, mar); - let groceries = lines.iter().find(|(id, _)| *id == 11).unwrap().1; - let restaurants = lines.iter().find(|(id, _)| *id == 12).unwrap().1; - - // Groceries spent 14,500 against 10,000 — overspent, nothing carried. - assert_eq!(groceries.spent, 14_500); - assert_eq!(groceries.carried, 0); - assert_eq!(groceries.available, -4_500); - assert_eq!(groceries.state(), BudgetState::Overspent); - - // Restaurants: February assigned 5,000 and spent nothing, so 5,000 - // carried into March, where 3,200 went. - assert_eq!(restaurants.carried, 5_000); - assert_eq!(restaurants.spent, 3_200); - assert_eq!(restaurants.available, 5_000 + 5_000 - 3_200); - } - - #[test] - fn subscriptions_are_found_by_rhythm_not_by_name() { - let mut ledger = Ledger { base_currency: EUR, ..Ledger::default() }; - let mut account = Account::new("Card", AccountKind::CreditCard, EUR); - account.id = 1; - ledger.accounts.push(account); - let today = from_ymd(2024, 6, 1); - // A monthly charge at the same price: a subscription. - for month in 1..=5 { - let mut txn = Transaction::new(1, from_ymd(2024, month, 7), "Netflix", -1_399); - txn.id = 100 + month as i64; - ledger.transactions.push(txn); - } - // A supermarket, visited often at wildly different amounts: not one. - for (index, day) in [3, 9, 15, 21, 27].into_iter().enumerate() { - let mut txn = Transaction::new( - 1, - from_ymd(2024, 5, day), - "Albert Heijn", - -(2_000 + index as i64 * 3_000), - ); - txn.id = 200 + index as i64; - ledger.transactions.push(txn); - } - let found = detected_subscriptions(&ledger, today); - assert!(found.iter().any(|(payee, amount, recurrence, _)| { - payee == "Netflix" && *amount == -1_399 && *recurrence == Recurrence::Monthly - })); - assert!( - !found.iter().any(|(payee, _, _, _)| payee == "Albert Heijn"), - "varying amounts are not a subscription" - ); - } - - #[test] - fn attention_finds_what_a_person_would_want_told() { - let mut ledger = ledger(); - let mut loose = Transaction::new(1, from_ymd(2024, 3, 28), "Mystery", -1_000); - loose.id = 400; - ledger.transactions.push(loose); - let mut broken = Transaction::new(1, from_ymd(2024, 3, 29), "Shop", -5_000); - broken.id = 401; - broken.splits = - vec![Split { id: 1, category: Some(11), amount: -4_000, memo: String::new() }]; - ledger.transactions.push(broken); - - let attention = needs_attention(&ledger); - assert!(attention.iter().any(|(id, why)| *id == 400 && *why == "no category")); - assert!(attention.iter().any(|(id, why)| *id == 401 && *why == "split does not add up")); - // The balanced transfer is not a problem. - assert!(!attention.iter().any(|(id, _)| *id == 200)); - } -} diff --git a/apps/finance/src/seed.rs b/apps/finance/src/seed.rs deleted file mode 100644 index 289a5c2e3..000000000 --- a/apps/finance/src/seed.rs +++ /dev/null @@ -1,894 +0,0 @@ -//! A believable financial life, generated, so the app is never empty. -//! -//! An empty finance app is unusable as a demo and hard to develop against: -//! no balances, no charts, nothing to click. So a file with no accounts -//! gets filled with two years of one household's money — salary on the -//! 25th, rent on the 1st, groceries twice a week, a card that gets paid -//! off monthly, a mortgage that amortises, subscriptions that renew, and -//! the occasional holiday. -//! -//! It is generated rather than canned because a fixed CSV goes stale: the -//! demo has to end *today* whenever today is, or every screen opens on an -//! empty current month. The generator is seeded and deterministic, so the -//! same day always produces the same file and a screenshot is reproducible. -//! -//! Everything here is ordinary ledger data written through the ordinary -//! [`crate::db`] paths. There is no "demo mode" in the app: the rows are -//! real rows, editable and deletable like any other. - -use crate::date::{self, Day}; -use crate::db::Db; -use crate::model::*; -use crate::money::{Currency, EUR}; - -/// How much history to generate. Two years covers every screen: a full -/// year-over-year comparison, twelve months of budgets, and enough of a -/// net-worth curve to have a shape. -pub const DEFAULT_YEARS: i32 = 2; - -/// xorshift64*, so the demo is identical on every machine and every run. -/// `rand` is not a dependency of this tree and a ledger does not need -/// cryptographic randomness — it needs the same numbers twice. -struct Rng(u64); - -impl Rng { - fn new(seed: u64) -> Rng { - Rng(seed | 1) - } - - fn next(&mut self) -> u64 { - let mut x = self.0; - x ^= x >> 12; - x ^= x << 25; - x ^= x >> 27; - self.0 = x; - x.wrapping_mul(0x2545_f491_4f6c_dd1d) - } - - /// Inclusive range. - fn between(&mut self, low: i64, high: i64) -> i64 { - if high <= low { - return low; - } - low + (self.next() % (high - low + 1) as u64) as i64 - } - - /// True with probability `percent`. - fn chance(&mut self, percent: u64) -> bool { - self.next() % 100 < percent - } - - fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T { - &items[(self.next() % items.len() as u64) as usize] - } -} - -/// The ids the generator needs to refer back to while it works. -struct Cats { - salary: Id, - interest: Id, - housing: Id, - utilities: Id, - internet: Id, - phone: Id, - groceries: Id, - restaurants: Id, - coffee: Id, - household: Id, - fuel: Id, - transit: Id, - car: Id, - clothing: Id, - electronics: Id, - pharmacy: Id, - gym: Id, - streaming: Id, - events: Id, - flights: Id, - hotels: Id, - fees: Id, - gifts: Id, - childcare: Id, - insurance: Id, -} - -/// Fill an empty file with a generated household. Returns a one-line -/// summary for the status bar. -pub fn populate(db: &mut Db, years: i32) -> Result { - let today = date::today(); - let start = date::month_start(date::add_months(today, -(years * 12 - 1))); - let currency = EUR; - - let accounts = insert_accounts(db, currency, start)?; - let cats = insert_categories(db)?; - let mut rng = Rng::new(0x5EED_F1_A2_C3); - - let mut txns: Vec = Vec::new(); - let mut transfer_group = 1i64; - - monthly_income(&mut txns, &accounts, &cats, start, today); - housing(&mut txns, &accounts, &cats, start, today, &mut rng); - subscriptions(&mut txns, &accounts, &cats, start, today); - daily_life(&mut txns, &accounts, &cats, start, today, &mut rng); - occasional(&mut txns, &accounts, &cats, start, today, &mut rng); - card_payments(&mut txns, &accounts, start, today, &mut transfer_group); - savings_transfers(&mut txns, &accounts, start, today, &mut transfer_group); - savings_interest(&mut txns, &accounts, &cats, start, today); - mortgage(&mut txns, &accounts, &cats, start, today, &mut transfer_group); - - // Age determines what the bank has seen: anything older than a few - // days has cleared, the oldest year has been reconciled, and the last - // few days are still in flight. That gives the reconcile screen and - // the "cleared vs current" balances something true to show. - for txn in txns.iter_mut() { - let age = today - txn.date; - txn.cleared = if age > 365 { - Cleared::Reconciled - } else if age > 4 { - Cleared::Cleared - } else { - Cleared::Uncleared - }; - } - txns.sort_by_key(|t| t.date); - - let count = txns.len(); - db.transact(|conn| { - for txn in &txns { - crate::db::insert_transaction_on(conn, txn)?; - } - Ok(()) - })?; - // Splits need the ids the insert assigned, so they go in a second pass - // over the file rather than being carried along. - add_splits(db, &cats)?; - - insert_budgets(db, &cats, today, years)?; - insert_rules(db, &cats)?; - insert_scheduled(db, &accounts, &cats, today)?; - db.set_setting("base_currency", currency.code)?; - - Ok(format!( - "{count} transactions across {} accounts, {} to {}", - accounts.all().len(), - date::format_short(start), - date::format_short(today) - )) -} - -struct Accounts { - checking: Id, - savings: Id, - card: Id, - brokerage: Id, - mortgage: Id, - house: Id, - cash: Id, -} - -impl Accounts { - fn all(&self) -> [Id; 7] { - [ - self.checking, - self.savings, - self.card, - self.brokerage, - self.mortgage, - self.house, - self.cash, - ] - } -} - -fn insert_accounts(db: &mut Db, currency: Currency, start: Day) -> Result { - let mut make = |name: &str, - kind: AccountKind, - institution: &str, - opening: i64, - order: i32| - -> Result { - let mut account = Account::new(name, kind, currency); - account.institution = institution.to_string(); - account.opening_balance = opening; - account.opening_date = start - 1; - account.sort_order = order; - db.insert_account(&account) - }; - Ok(Accounts { - checking: make("Everyday", AccountKind::Checking, "ING", 342_150, 0)?, - savings: make("Savings", AccountKind::Savings, "ING", 1_480_000, 1)?, - card: make("Rewards Card", AccountKind::CreditCard, "Amex", -84_320, 2)?, - cash: make("Cash", AccountKind::Cash, "", 12_000, 3)?, - brokerage: make("Brokerage", AccountKind::Investment, "DEGIRO", 2_650_000, 4)?, - // A mortgage is a debt: negative, and paid down over the run. - mortgage: make("Mortgage", AccountKind::Loan, "Rabobank", -24_800_000, 5)?, - house: make("Apartment", AccountKind::Asset, "", 41_500_000, 6)?, - }) -} - -fn insert_categories(db: &mut Db) -> Result { - let mut group = |name: &str, kind: CategoryKind, order: i32| -> Result { - let mut category = Category::group(name, kind); - category.sort_order = order; - db.insert_category(&category) - }; - let income = group("Income", CategoryKind::Income, 0)?; - let housing = group("Housing", CategoryKind::Expense, 1)?; - let food = group("Food", CategoryKind::Expense, 2)?; - let transport = group("Transport", CategoryKind::Expense, 3)?; - let shopping = group("Shopping", CategoryKind::Expense, 4)?; - let health = group("Health", CategoryKind::Expense, 5)?; - let fun = group("Fun", CategoryKind::Expense, 6)?; - let travel = group("Travel", CategoryKind::Expense, 7)?; - let money = group("Money", CategoryKind::Expense, 8)?; - let family = group("Family", CategoryKind::Expense, 9)?; - - let mut child = |name: &str, - parent: Id, - kind: CategoryKind, - rollover: bool, - order: i32| - -> Result { - let mut category = Category::child(name, parent, kind); - category.budgeted = kind == CategoryKind::Expense; - category.rollover = rollover; - category.sort_order = order; - db.insert_category(&category) - }; - - Ok(Cats { - salary: child("Salary", income, CategoryKind::Income, false, 0)?, - interest: child("Interest", income, CategoryKind::Income, false, 1)?, - housing: child("Mortgage", housing, CategoryKind::Expense, false, 0)?, - utilities: child("Energy", housing, CategoryKind::Expense, false, 1)?, - internet: child("Internet", housing, CategoryKind::Expense, false, 2)?, - phone: child("Phone", housing, CategoryKind::Expense, false, 3)?, - groceries: child("Groceries", food, CategoryKind::Expense, false, 0)?, - restaurants: child("Restaurants", food, CategoryKind::Expense, false, 1)?, - coffee: child("Coffee", food, CategoryKind::Expense, false, 2)?, - household: child("Household", shopping, CategoryKind::Expense, false, 0)?, - fuel: child("Fuel", transport, CategoryKind::Expense, false, 0)?, - transit: child("Transit", transport, CategoryKind::Expense, false, 1)?, - // Car maintenance is lumpy, so it rolls over: three quiet months - // pay for the fourth. - car: child("Car upkeep", transport, CategoryKind::Expense, true, 2)?, - clothing: child("Clothing", shopping, CategoryKind::Expense, false, 1)?, - electronics: child("Electronics", shopping, CategoryKind::Expense, true, 2)?, - pharmacy: child("Pharmacy", health, CategoryKind::Expense, false, 0)?, - gym: child("Gym", health, CategoryKind::Expense, false, 1)?, - streaming: child("Streaming", fun, CategoryKind::Expense, false, 0)?, - events: child("Going out", fun, CategoryKind::Expense, false, 1)?, - flights: child("Flights", travel, CategoryKind::Expense, true, 0)?, - hotels: child("Hotels", travel, CategoryKind::Expense, true, 1)?, - fees: child("Bank fees", money, CategoryKind::Expense, false, 0)?, - insurance: child("Insurance", money, CategoryKind::Expense, false, 1)?, - gifts: child("Gifts", family, CategoryKind::Expense, true, 0)?, - childcare: child("Childcare", family, CategoryKind::Expense, false, 1)?, - }) -} - -/// A payday that lands on a working day: paid on the 25th, moved back to -/// the Friday when that is a weekend, which is what employers do. -fn payday(month_start: Day) -> Day { - let (y, m, _) = date::to_ymd(month_start); - let mut day = date::from_ymd(y, m, 25); - while date::is_weekend(day) { - day -= 1; - } - day -} - -fn each_month(start: Day, end: Day, mut body: impl FnMut(Day)) { - let mut month = date::month_start(start); - while month <= end { - body(month); - month = date::add_months(month, 1); - } -} - -fn monthly_income(txns: &mut Vec, accounts: &Accounts, cats: &Cats, start: Day, end: Day) { - // A raise a third of the way in, so year-over-year has something to - // show and the budget screen has a reason to change. - let raise_at = date::add_months(start, 14); - each_month(start, end, |month| { - let day = payday(month); - if day > end || day < start { - return; - } - let amount = if day >= raise_at { 492_400 } else { 465_000 }; - let mut txn = Transaction::new(accounts.checking, day, "Bergman Design BV", amount); - txn.category = Some(cats.salary); - txn.memo = "Salary".into(); - txns.push(txn); - }); -} - -fn housing( - txns: &mut Vec, - accounts: &Accounts, - cats: &Cats, - start: Day, - end: Day, - rng: &mut Rng, -) { - each_month(start, end, |month| { - let (y, m, _) = date::to_ymd(month); - let mut push = |day: u32, payee: &str, amount: i64, category: Id| { - let date = date::from_ymd(y, m, day.min(date::days_in_month(y, m))); - if date > end || date < start { - return; - } - let mut txn = Transaction::new(accounts.checking, date, payee, -amount); - txn.category = Some(category); - txns.push(txn); - }; - // Energy swings with the season: a Dutch winter costs roughly - // double a summer month. - let winter = matches!(m, 11 | 12 | 1 | 2 | 3); - let energy = if winter { rng.between(18_500, 24_000) } else { rng.between(8_500, 12_500) }; - push(3, "Eneco", energy, cats.utilities); - push(5, "KPN Internet", 5_450, cats.internet); - push(8, "Vodafone", 2_890, cats.phone); - push(12, "Centraal Beheer", 8_640, cats.insurance); - push(2, "Kinderopvang Zonnetje", 54_000, cats.childcare); - }); -} - -fn subscriptions(txns: &mut Vec, accounts: &Accounts, cats: &Cats, start: Day, end: Day) { - // Charged to the card, like most subscriptions are. - let monthly: [(u32, &str, i64, fn(&Cats) -> Id); 5] = [ - (4, "Netflix", 1_399, |c| c.streaming), - (7, "Spotify", 1_099, |c| c.streaming), - (15, "Apple iCloud", 299, |c| c.streaming), - (18, "SportCity", 2_995, |c| c.gym), - (22, "Adobe", 2_399, |c| c.electronics), - ]; - each_month(start, end, |month| { - let (y, m, _) = date::to_ymd(month); - for (day, payee, amount, category) in monthly { - let date = date::from_ymd(y, m, day); - if date > end || date < start { - continue; - } - let mut txn = Transaction::new(accounts.card, date, payee, -amount); - txn.category = Some(category(cats)); - txns.push(txn); - } - }); -} - -fn daily_life( - txns: &mut Vec, - accounts: &Accounts, - cats: &Cats, - start: Day, - end: Day, - rng: &mut Rng, -) { - const SUPERMARKETS: [&str; 5] = ["Albert Heijn", "Jumbo", "Lidl", "Dirk", "Ekoplaza"]; - const CAFES: [&str; 5] = - ["Coffee Company", "Bocca Koffie", "Lot Sixty One", "Toki", "Screaming Beans"]; - const RESTAURANTS: [&str; 6] = [ - "Café de Klos", - "Bar Bukowski", - "Thai Bird", - "De Biertuin", - "Pizzeria Sugo", - "Sushi Ran", - ]; - const SHOPS: [&str; 5] = ["HEMA", "Bol.com", "Zara", "Decathlon", "MediaMarkt"]; - - let mut day = start; - while day <= end { - let weekday = date::weekday(day); - // Groceries: a big weekend shop and one or two top-ups. - if weekday == 5 || (rng.chance(35) && weekday != 6) { - let big = weekday == 5; - let amount = if big { rng.between(5_800, 12_400) } else { rng.between(1_200, 4_500) }; - let mut txn = Transaction::new( - if rng.chance(15) { accounts.cash } else { accounts.card }, - day, - rng.pick(&SUPERMARKETS), - -amount, - ); - txn.category = Some(cats.groceries); - txns.push(txn); - } - // Coffee on working days. - if weekday < 5 && rng.chance(55) { - let mut txn = - Transaction::new(accounts.card, day, rng.pick(&CAFES), -rng.between(280, 720)); - txn.category = Some(cats.coffee); - txns.push(txn); - } - // Eating out, mostly at the weekend. - let eats_out = if weekday >= 4 { rng.chance(45) } else { rng.chance(12) }; - if eats_out { - let mut txn = Transaction::new( - accounts.card, - day, - rng.pick(&RESTAURANTS), - -rng.between(2_200, 8_900), - ); - txn.category = Some(cats.restaurants); - txns.push(txn); - } - // Transit and fuel. - if weekday < 5 && rng.chance(30) { - let mut txn = Transaction::new(accounts.card, day, "NS Reizigers", -rng.between(320, 2_450)); - txn.category = Some(cats.transit); - txns.push(txn); - } - if rng.chance(6) { - let mut txn = Transaction::new(accounts.card, day, "Shell", -rng.between(4_500, 8_800)); - txn.category = Some(cats.fuel); - txns.push(txn); - } - // Odds and ends. - if rng.chance(9) { - let shop = rng.pick(&SHOPS); - let (category, amount) = match *shop { - "MediaMarkt" => (cats.electronics, rng.between(3_900, 45_000)), - "Zara" | "Decathlon" => (cats.clothing, rng.between(2_500, 14_000)), - _ => (cats.household, rng.between(800, 6_500)), - }; - let mut txn = Transaction::new(accounts.card, day, shop, -amount); - txn.category = Some(category); - txns.push(txn); - } - if rng.chance(4) { - let mut txn = Transaction::new(accounts.card, day, "Etos", -rng.between(600, 3_400)); - txn.category = Some(cats.pharmacy); - txns.push(txn); - } - if rng.chance(5) { - let mut txn = - Transaction::new(accounts.cash, day, "Albert Cuyp Markt", -rng.between(500, 2_500)); - txn.category = Some(cats.groceries); - txns.push(txn); - } - day += 1; - } -} - -fn occasional( - txns: &mut Vec, - accounts: &Accounts, - cats: &Cats, - start: Day, - end: Day, - rng: &mut Rng, -) { - // One holiday a year, in the summer, plus a winter weekend away. - let mut year = date::year_of(start); - while year <= date::year_of(end) { - for (month, day, flight, hotel, place) in [ - (7, 12, 118_000i64, 96_500i64, "Lisbon"), - (2, 8, 42_000, 38_000, "Vienna"), - ] { - let date = date::from_ymd(year, month, day); - if date < start || date > end { - continue; - } - let mut air = Transaction::new(accounts.card, date, "KLM", -flight); - air.category = Some(cats.flights); - air.memo = format!("{place} trip"); - txns.push(air); - let mut stay = Transaction::new(accounts.card, date + 1, "Booking.com", -hotel); - stay.category = Some(cats.hotels); - stay.memo = format!("{place} trip"); - txns.push(stay); - // Spending abroad, on the card. - for offset in 2..7 { - if date + offset > end { - break; - } - let mut meal = Transaction::new( - accounts.card, - date + offset, - "Restaurante Ramiro", - -rng.between(2_800, 9_500), - ); - meal.category = Some(cats.restaurants); - txns.push(meal); - } - } - // Car maintenance, once or twice a year — the lumpy category the - // rollover exists for. - let service = date::from_ymd(year, 5, 14); - if service >= start && service <= end { - let mut txn = Transaction::new(accounts.checking, service, "Garage Van Dijk", -rng.between(28_000, 62_000)); - txn.category = Some(cats.car); - txns.push(txn); - } - // Birthdays and December. - for (month, day, payee) in [(12, 18, "Bol.com"), (6, 4, "Bloemenwinkel")] { - let date = date::from_ymd(year, month, day); - if date >= start && date <= end { - let mut txn = - Transaction::new(accounts.card, date, payee, -rng.between(4_500, 22_000)); - txn.category = Some(cats.gifts); - txn.memo = "Gift".into(); - txns.push(txn); - } - } - // A concert or two. - for (month, day) in [(9, 21), (3, 15)] { - let date = date::from_ymd(year, month, day); - if date >= start && date <= end && rng.chance(70) { - let mut txn = - Transaction::new(accounts.card, date, "Paradiso", -rng.between(3_500, 9_000)); - txn.category = Some(cats.events); - txns.push(txn); - } - } - year += 1; - } -} - -/// The card is paid off in full each month, from checking — a transfer -/// pair, which is what gives the transfer screens something real. -fn card_payments( - txns: &mut Vec, - accounts: &Accounts, - start: Day, - end: Day, - group: &mut i64, -) { - each_month(start, end, |month| { - let (y, m, _) = date::to_ymd(month); - let date = date::from_ymd(y, m, 28.min(date::days_in_month(y, m))); - if date > end || date < start { - return; - } - // What the card ran up in the previous month, near enough. - let previous = date::add_months(date, -1); - let spent: i64 = txns - .iter() - .filter(|t| { - t.account == accounts.card - && date::month_key(t.date) == date::month_key(previous) - }) - .map(|t| t.amount) - .sum(); - let amount = -spent; - if amount <= 0 { - return; - } - *group += 1; - let mut out = Transaction::new(accounts.checking, date, "Amex", -amount); - out.transfer_group = Some(*group); - out.memo = "Card payment".into(); - let mut into = Transaction::new(accounts.card, date, "Payment received", amount); - into.transfer_group = Some(*group); - into.memo = "Card payment".into(); - txns.push(out); - txns.push(into); - }); -} - -fn savings_transfers( - txns: &mut Vec, - accounts: &Accounts, - start: Day, - end: Day, - group: &mut i64, -) { - each_month(start, end, |month| { - let date = payday(month) + 1; - if date > end || date < start { - return; - } - *group += 1; - let amount = 40_000; - let mut out = Transaction::new(accounts.checking, date, "Savings", -amount); - out.transfer_group = Some(*group); - out.memo = "Monthly saving".into(); - let mut into = Transaction::new(accounts.savings, date, "From Everyday", amount); - into.transfer_group = Some(*group); - into.memo = "Monthly saving".into(); - txns.push(out); - txns.push(into); - }); -} - -fn savings_interest( - txns: &mut Vec, - accounts: &Accounts, - cats: &Cats, - start: Day, - end: Day, -) { - let mut balance = 1_480_000i64; - each_month(start, end, |month| { - let (y, m, _) = date::to_ymd(month); - let date = date::from_ymd(y, m, date::days_in_month(y, m)); - if date > end || date < start { - return; - } - balance += 40_000; - // 1.8% a year, paid monthly. - let interest = balance * 18 / 1000 / 12; - let mut txn = Transaction::new(accounts.savings, date, "ING", interest); - txn.category = Some(cats.interest); - txn.memo = "Interest".into(); - txns.push(txn); - }); -} - -/// A mortgage payment is two things at once: interest (an expense) and -/// principal (a transfer that shrinks the debt). Modelling it as a split -/// would hide the debt movement, so it is a transfer pair for the -/// principal and a plain expense for the interest — which is how the loan -/// balance ends up actually going down on the net-worth chart. -fn mortgage( - txns: &mut Vec, - accounts: &Accounts, - cats: &Cats, - start: Day, - end: Day, - group: &mut i64, -) { - let mut owed = 24_800_000i64; - each_month(start, end, |month| { - let (y, m, _) = date::to_ymd(month); - let date = date::from_ymd(y, m, 2); - if date > end || date < start { - return; - } - // 3.4% a year on the outstanding balance. - let interest = owed * 34 / 1000 / 12; - let principal = 92_400 - interest.min(92_400); - let mut cost = Transaction::new(accounts.checking, date, "Rabobank", -interest); - cost.category = Some(cats.housing); - cost.memo = "Mortgage interest".into(); - txns.push(cost); - if principal > 0 { - *group += 1; - let mut out = Transaction::new(accounts.checking, date, "Rabobank", -principal); - out.transfer_group = Some(*group); - out.memo = "Mortgage principal".into(); - let mut down = Transaction::new(accounts.mortgage, date, "Payment", principal); - down.transfer_group = Some(*group); - down.memo = "Mortgage principal".into(); - txns.push(out); - txns.push(down); - owed -= principal; - } - }); -} - -/// Turn a handful of supermarket trips into split transactions, so the -/// split UI has real examples the moment the app opens. -fn add_splits(db: &mut Db, cats: &Cats) -> Result<(), String> { - let ledger = db.load()?; - let candidates: Vec = ledger - .transactions - .iter() - .filter(|t| { - t.category == Some(cats.groceries) && t.amount < -7_000 && t.splits.is_empty() - }) - .take(6) - .cloned() - .collect(); - for mut txn in candidates { - // A third of a big shop was household goods, not food. - let household = txn.amount / 3; - let food = txn.amount - household; - txn.splits = vec![ - Split { id: 0, category: Some(cats.groceries), amount: food, memo: "Food".into() }, - Split { - id: 0, - category: Some(cats.household), - amount: household, - memo: "Cleaning, paper".into(), - }, - ]; - debug_assert_eq!(txn.split_imbalance(), 0); - db.update_transaction(&txn)?; - } - Ok(()) -} - -/// Budgets for every month of history, so the budget screen opens on real -/// numbers and the "assigned vs spent" bars mean something. -fn insert_budgets(db: &mut Db, cats: &Cats, today: Day, years: i32) -> Result<(), String> { - let plan: [(Id, i64); 17] = [ - (cats.housing, 92_400), - (cats.utilities, 14_000), - (cats.internet, 5_450), - (cats.phone, 2_890), - (cats.childcare, 54_000), - (cats.insurance, 8_640), - (cats.groceries, 52_000), - (cats.restaurants, 18_000), - (cats.coffee, 6_000), - (cats.household, 8_000), - (cats.transit, 9_000), - (cats.fuel, 12_000), - (cats.car, 15_000), - (cats.clothing, 10_000), - (cats.streaming, 5_200), - (cats.gym, 2_995), - (cats.events, 8_000), - ]; - let months = years * 12; - let first = date::month_key(date::add_months(today, -(months - 1))); - db.transact(|_conn| Ok(()))?; - for offset in 0..months { - let month = first + offset; - for (category, assigned) in plan { - db.insert_budget(&BudgetEntry { - category, - month, - assigned, - rollover: matches!(category, c if c == cats.car), - })?; - } - } - Ok(()) -} - -/// The rules a person would have written after a month of imports. -fn insert_rules(db: &mut Db, cats: &Cats) -> Result<(), String> { - let rules = [ - ("Albert Heijn", "AH TO GO", Some(cats.groceries), Some("Albert Heijn")), - ("Shell", "SHELL NEDERLAND", Some(cats.fuel), Some("Shell")), - ("NS", "NS GROEP", Some(cats.transit), Some("NS Reizigers")), - ("Netflix", "NETFLIX.COM", Some(cats.streaming), Some("Netflix")), - ("Amazon", "AMZN MKTP", Some(cats.household), Some("Amazon")), - ]; - for (index, (name, pattern, category, rename)) in rules.into_iter().enumerate() { - db.insert_rule(&Rule { - id: 0, - name: name.to_string(), - match_on: MatchOn::Raw, - how: MatchHow::Contains, - pattern: pattern.to_string(), - amount_min: 0, - amount_max: 0, - set_category: category, - rename_payee: rename.map(str::to_string), - set_memo: None, - flag: false, - priority: index as i32, - enabled: true, - hits: 0, - })?; - } - Ok(()) -} - -/// The recurring bills, as the app's detector would have found them. -fn insert_scheduled(db: &mut Db, accounts: &Accounts, cats: &Cats, today: Day) -> Result<(), String> { - let next = |day: u32| -> Day { - let (y, m, _) = date::to_ymd(today); - let candidate = date::from_ymd(y, m, day.min(date::days_in_month(y, m))); - if candidate >= today { - candidate - } else { - date::add_months(candidate, 1) - } - }; - let items = [ - (accounts.checking, "Rabobank hypotheek", -92_400, cats.housing, next(2)), - (accounts.checking, "Kinderopvang Zonnetje", -54_000, cats.childcare, next(2)), - (accounts.checking, "Eneco", -14_000, cats.utilities, next(3)), - (accounts.checking, "KPN Internet", -5_450, cats.internet, next(5)), - (accounts.checking, "Vodafone", -2_890, cats.phone, next(8)), - (accounts.card, "Netflix", -1_399, cats.streaming, next(4)), - (accounts.card, "Spotify", -1_099, cats.streaming, next(7)), - (accounts.card, "SportCity", -2_995, cats.gym, next(18)), - (accounts.checking, "Bergman Design BV", 492_400, cats.salary, next(25)), - ]; - for (account, payee, amount, category, due) in items { - db.insert_scheduled(&Scheduled { - id: 0, - account, - payee: payee.to_string(), - amount, - category: Some(category), - recurrence: Recurrence::Monthly, - next_due: due, - last_posted: None, - auto_post: false, - enabled: true, - detected: true, - })?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn temp_db(name: &str) -> (Db, std::path::PathBuf) { - let mut path = std::env::temp_dir(); - path.push(format!("finance-seed-{name}-{}.db", std::process::id())); - let _ = std::fs::remove_file(&path); - (Db::open(&path).expect("open"), path) - } - - #[test] - fn the_demo_file_is_a_coherent_household() { - let (mut db, path) = temp_db("household"); - let summary = populate(&mut db, DEFAULT_YEARS).expect("populate"); - assert!(summary.contains("transactions")); - let ledger = db.load().expect("load"); - - // Enough to fill every screen. - assert!( - ledger.transactions.len() > 1_500, - "two years should be thousands of rows, got {}", - ledger.transactions.len() - ); - assert_eq!(ledger.accounts.len(), 7); - assert!(ledger.categories.categories.len() > 25); - assert!(!ledger.budgets.is_empty()); - assert!(!ledger.rules.is_empty()); - assert!(!ledger.scheduled.is_empty()); - - // Every transfer pair balances — the invariant the whole - // net-worth number rests on. - let groups: std::collections::HashSet = - ledger.transactions.iter().filter_map(|t| t.transfer_group).collect(); - assert!(groups.len() > 20, "expected many transfers, got {}", groups.len()); - for group in groups { - assert!(ledger.transfer_is_balanced(group), "transfer {group} does not cancel"); - } - - // Splits sum to their transaction. - let split_count = ledger.transactions.iter().filter(|t| t.is_split()).count(); - assert!(split_count >= 5, "expected split examples, got {split_count}"); - for txn in ledger.transactions.iter().filter(|t| t.is_split()) { - assert_eq!(txn.split_imbalance(), 0); - } - - // The story adds up: income arrives, the current account stays - // solvent, and the mortgage is smaller than it started. - let checking = ledger.accounts.iter().find(|a| a.name == "Everyday").unwrap(); - assert!(ledger.balance(checking.id) > 0, "the household should not be overdrawn"); - let mortgage = ledger.accounts.iter().find(|a| a.name == "Mortgage").unwrap(); - assert!( - ledger.balance(mortgage.id) > mortgage.opening_balance, - "the mortgage should have been paid down" - ); - assert!(ledger.net_worth_on(date::today()) > 0); - - // Nothing in the future, and history reaches back two years. - let today = date::today(); - assert!(ledger.transactions.iter().all(|t| t.date <= today)); - let oldest = ledger.transactions.iter().map(|t| t.date).min().unwrap(); - assert!(today - oldest > 660, "expected ~2 years of history"); - - // The recent tail is still uncleared, the deep past is reconciled. - assert!(ledger - .transactions - .iter() - .any(|t| t.cleared == Cleared::Uncleared)); - assert!(ledger - .transactions - .iter() - .any(|t| t.cleared == Cleared::Reconciled)); - assert!(ledger.cleared_balance(checking.id) != ledger.balance(checking.id)); - - let _ = std::fs::remove_file(&path); - } - - #[test] - fn the_same_seed_produces_the_same_file() { - let (mut a, path_a) = temp_db("determinism-a"); - let (mut b, path_b) = temp_db("determinism-b"); - populate(&mut a, 1).expect("a"); - populate(&mut b, 1).expect("b"); - let one = a.load().expect("load a"); - let two = b.load().expect("load b"); - assert_eq!(one.transactions.len(), two.transactions.len()); - let sum_a: i64 = one.transactions.iter().map(|t| t.amount).sum(); - let sum_b: i64 = two.transactions.iter().map(|t| t.amount).sum(); - assert_eq!(sum_a, sum_b); - let _ = std::fs::remove_file(&path_a); - let _ = std::fs::remove_file(&path_b); - } -} diff --git a/apps/finance/src/theme.rs b/apps/finance/src/theme.rs deleted file mode 100644 index 4e592e19b..000000000 --- a/apps/finance/src/theme.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! The palette, in one place. -//! -//! Published into `mod.finance.*` so the DSL reads `mod.finance.accent` -//! rather than a hex literal repeated forty times — change a colour here -//! and every screen moves together. -//! -//! It is a dark palette because a ledger is a wall of numbers, and dark -//! rows with one bright accent let the numbers carry the contrast instead -//! of fighting the background for it. -//! -//! Three rules, taken from what the best-looking money apps actually do: -//! -//! * **A cool near-black, never `#000`, and elevation by tone.** Three -//! surfaces each a few percent lighter than the last, separated by -//! hairlines rather than shadows. Drop shadows on cards are the single -//! clearest "designed in 2018" tell. -//! * **One saturated accent.** Indigo, and nothing else competes with it. -//! Restricting the palette is what reads as expensive; a different bright -//! colour per spending category reads as a 2015 budgeting app. -//! * **Money colour is reserved and redundant.** Good/critical are status, -//! never a chart series, and they never carry meaning alone — the sign is -//! always there too, because roughly one man in twelve cannot tell the -//! two hues apart. - -use makepad_widgets::*; - -pub fn install(vm: &mut ScriptVm) { - script_eval!(vm, { - mod.finance = { - // Surfaces, darkest to lightest: the page, a card, and a - // control on that card. Each step is a few percent lighter, - // which is the whole elevation system — there are no shadows. - bg: #x0c0d12, - panel: #x14161d, - raised: #x1b1e27, - line: #x272a35, - line_soft: #x1e212a, - - // Text. - fg: #xf2f4f8, - fg_dim: #xa2a8b8, - fg_faint: #x6f7585, - - // One accent, used for selection, the active tab and the - // primary action. Anything else that wants attention has to - // earn it with weight or size instead. - accent: #x5e6ad2, - accent_soft: #x272a52, - - // Money. Nothing else may use these two. - up: #x3fb950, - down: #xf85149, - - // Chart series, in fixed order, never cycled. These are the - // dataviz reference palette's dark steps: the set passes the - // colour-blindness separation and contrast checks as a whole, - // which a hand-picked set of "nice" hues does not — the blue - // and violet I first chose were 2.4 ΔE apart to a deuteranope, - // which is to say identical. - c0: #x3987e5, - c1: #xd95926, - c2: #x199e70, - c3: #xc98500, - c4: #xd55181, - c5: #x008300, - c6: #x9085e9, - c7: #xe66767, - - // Status, reserved: these four never stand in for a series. - good: #x0ca30c, - warning: #xfab219, - serious: #xec835a, - critical: #xd03b3b, - - // The warm tint behind a row that needs attention. - warn: #x3a2d16, - - // Register surfaces: the alternate row is a hair lighter than - // the page, never a different colour. - zebra: #x101219, - select: #x5e6ad233, - } - }); -} - -/// Chart colours by index, for series the Rust side hands out. -pub const SERIES: [u32; 8] = [ - 0x3987e5, 0xd95926, 0x199e70, 0xc98500, 0xd55181, 0x008300, 0x9085e9, 0xe66767, -]; - -/// Status colours, reserved. Money in and money out are STATUS, not series -/// — which is why they are never drawn from [`SERIES`]. -pub const GOOD: u32 = 0x0ca30c; -pub const CRITICAL: u32 = 0xd03b3b; -pub const WARNING: u32 = 0xfab219; - -/// A category's colour: its own if it has one, else one picked from the -/// series by id so it stays the same colour on every screen and across -/// runs. -pub fn category_color(id: i64, stored: u32) -> Vec4f { - let rgb = if stored != 0 { stored } else { SERIES[(id.unsigned_abs() as usize) % SERIES.len()] }; - Vec4f { - x: ((rgb >> 16) & 0xff) as f32 / 255.0, - y: ((rgb >> 8) & 0xff) as f32 / 255.0, - z: (rgb & 0xff) as f32 / 255.0, - w: 1.0, - } -} - -pub fn rgb(value: u32) -> Vec4f { - category_color(0, value) -} diff --git a/apps/finance/src/view.rs b/apps/finance/src/view.rs deleted file mode 100644 index 6a1b43ebf..000000000 --- a/apps/finance/src/view.rs +++ /dev/null @@ -1,1658 +0,0 @@ -//! The app: one window that is a desktop app when it is wide and a phone -//! app when it is narrow. -//! -//! The same screens, the same data, one code path — what changes with -//! width is the CHROME (a sidebar becomes a bottom tab bar) and the -//! DENSITY (a nine-column register becomes three columns of taller rows). -//! Nothing is duplicated per form factor, because two implementations of -//! one screen drift apart within a week. -//! -//! The register is a `DataGrid`, which draws only the cells inside the -//! viewport and puts a whole screen of them in two draw calls. That is the -//! reason this app can hold a decade of transactions in one list and still -//! scroll at frame rate, which is exactly where the products it is -//! measured against fall over. - -use crate::chart::{FinanceChartWidgetExt, MeterWidgetRefExt}; -use crate::date::{self, DateRange, Day, MonthKey}; -use crate::db::Db; -use crate::model::*; -use crate::money::{format_compact, format_minor, format_money, Currency}; -use crate::report; -use crate::theme; -use makepad_widgets::makepad_platform::file_dialogs::{FileDialog, FileDialogAction}; -use makepad_widgets::*; - -/// The dialog that picks a statement, so its answer is not confused with -/// any other file dialog the app might grow. -const PICK_STATEMENT: LiveId = live_id!(finance_pick_statement); - -script_mod! { - use mod.prelude.widgets.* - use mod.widgets.* - - mod.widgets.FinanceBase = #(Finance::register_widget(vm)) - - let Panel = SolidView{ - draw_bg +: { color: mod.finance.panel } - } - - let Card = RoundedView{ - width: Fill - height: Fit - flow: Down - padding: 16 - spacing: 8 - draw_bg +: { - color: mod.finance.panel - border_radius: 10.0 - border_size: 1.0 - border_color: mod.finance.line_soft - } - } - - let Title = Label{ - draw_text +: { - color: mod.finance.fg - text_style: theme.font_bold{font_size: 12} - } - } - - let Body = Label{ - draw_text +: { - color: mod.finance.fg - text_style: theme.font_regular{font_size: 9.5} - } - } - - let Dim = Label{ - draw_text +: { - color: mod.finance.fg_dim - text_style: theme.font_regular{font_size: 8.5} - } - } - - let Money = Label{ - draw_text +: { - color: mod.finance.fg - text_style: theme.font_code{font_size: 10} - } - } - - let Big = Label{ - draw_text +: { - color: mod.finance.fg - text_style: theme.font_bold{font_size: 26} - } - } - - // A flat button that reads as a row, not a control: the whole nav is - // made of these and a stack of bevels would be noise. - let NavItem = Button{ - width: Fill - height: 34 - align: Align{x: 0.0, y: 0.5} - padding: Inset{left: 12, right: 10, top: 6, bottom: 6} - draw_bg +: { - color: #x00000000 - color_hover: mod.finance.raised - color_down: mod.finance.accent_soft - color_focus: #x00000000 - border_radius: 6.0 - border_size: 0.0 - } - draw_text +: { - color: mod.finance.fg_dim - color_hover: mod.finance.fg - color_down: mod.finance.fg - color_focus: mod.finance.fg_dim - text_style: theme.font_regular{font_size: 10} - } - } - - let Chip = Button{ - height: 26 - padding: Inset{left: 12, right: 12, top: 4, bottom: 4} - draw_bg +: { - color: mod.finance.raised - color_hover: mod.finance.line - color_down: mod.finance.accent_soft - color_focus: mod.finance.raised - border_radius: 13.0 - border_size: 0.0 - } - draw_text +: { - color: mod.finance.fg_dim - color_hover: mod.finance.fg - color_down: mod.finance.fg - color_focus: mod.finance.fg_dim - text_style: theme.font_regular{font_size: 9} - } - } - - let Primary = Button{ - height: 30 - padding: Inset{left: 16, right: 16, top: 6, bottom: 6} - draw_bg +: { - color: mod.finance.accent - color_hover: #x5d99ff - color_down: #x3b7ae6 - color_focus: mod.finance.accent - border_radius: 6.0 - border_size: 0.0 - } - draw_text +: { - color: #xffffff - color_hover: #xffffff - color_down: #xffffff - color_focus: #xffffff - text_style: theme.font_bold{font_size: 9.5} - } - } - - let Search = TextInput{ - height: 28 - empty_text: "Search payee, memo, amount…" - draw_text +: { - color: mod.finance.fg - color_hover: mod.finance.fg - color_focus: mod.finance.fg - color_down: mod.finance.fg - color_empty: mod.finance.fg_faint - color_empty_hover: mod.finance.fg_dim - color_empty_focus: mod.finance.fg_faint - text_style: theme.font_regular{font_size: 9.5} - } - } - - // One row of the account list: name, kind, balance. - let AccountRow = View{ - width: Fill - height: Fit - flow: Down - padding: Inset{left: 12, right: 10, top: 7, bottom: 7} - spacing: 2 - flow: Overlay - acc_hit := Button{ - width: Fill - height: Fill - text: "" - draw_bg +: { - color: #x00000000 - color_hover: mod.finance.raised - color_down: mod.finance.accent_soft - color_focus: #x00000000 - border_radius: 6.0 - border_size: 0.0 - } - } - acc_rows := View{ - width: Fill - height: Fit - flow: Down - spacing: 2 - acc_top := View{ - width: Fill - height: Fit - flow: Right - acc_name := Body{ width: Fill } - acc_balance := Money{ width: Fit } - } - acc_kind := Dim{} - } - } - - let Ledger = DataGrid{ - width: Fill - height: Fill - rows: 0 - cols: 7 - default_col_width: 130.0 - default_row_height: 26.0 - col_header_height: 26.0 - row_header_width: 0.0 - cell_pad_x: 10.0 - zebra_stripes: true - // The stock DataGrid is a light-mode spreadsheet; every surface it - // paints has to be restated or the register turns up white. - color_bg: mod.finance.bg - color_cell: mod.finance.bg - color_cell_alt: mod.finance.zebra - color_text: mod.finance.fg - color_header: mod.finance.panel - color_header_active: mod.finance.raised - color_header_text: mod.finance.fg_dim - color_selection: mod.finance.select - color_selection_border: mod.finance.accent - color_drag_marker: mod.finance.accent - color_resize_guide: mod.finance.accent_soft - // The stock scrollbar handle is translucent BLACK, which is - // invisible on a dark surface. - scroll_bar_h: mod.widgets.ScrollBar{ - draw_bg +: { - color: uniform(#xffffff26) - color_hover: uniform(#xffffff42) - color_drag: uniform(#xffffff66) - } - } - scroll_bar_v: mod.widgets.ScrollBar{ - draw_bg +: { - color: uniform(#xffffff26) - color_hover: uniform(#xffffff42) - color_drag: uniform(#xffffff66) - } - } - draw_cell +: { - border_color: uniform(mod.finance.line_soft) - border_size: uniform(1.0) - } - draw_text +: { - color: mod.finance.fg - text_style: theme.font_code{font_size: 9} - } - draw_text_bold +: { - color: mod.finance.fg - text_style: theme.font_code{font_size: 9} - } - } - - // A bar in a "where the money went" list: label, track, value. - let BarRow = View{ - width: Fill - height: Fit - flow: Down - spacing: 4 - margin: Inset{top: 4, bottom: 4} - bar_head := View{ - width: Fill - height: Fit - flow: Right - bar_label := Body{ width: Fill } - bar_value := Label{ - width: Fit - draw_text +: { - color: mod.finance.fg_dim - text_style: theme.font_code{font_size: 9} - } - } - } - bar_meter := Meter{} - } - - let StatCard = RoundedView{ - width: Fill - height: Fit - flow: Down - padding: 14 - spacing: 6 - draw_bg +: { - color: mod.finance.panel - border_radius: 10.0 - border_size: 1.0 - border_color: mod.finance.line_soft - } - stat_label := Dim{} - stat_value := Label{ - draw_text +: { - color: mod.finance.fg - text_style: theme.font_code{font_size: 15} - } - } - stat_note := Dim{} - } - - let Trend = FinanceChart{ - width: Fill - height: Fill - color_line: mod.finance.c0 - color_fill: mod.finance.c0 - color_second: mod.finance.c1 - color_axis: mod.finance.fg_faint - color_rule: mod.finance.line - } - - mod.widgets.Finance = set_type_default() do mod.widgets.FinanceBase{ - width: Fill - height: Fill - flow: Down - show_bg: true - draw_bg +: { color: mod.finance.bg } - - body := View{ - width: Fill - height: Fill - flow: Right - - // ---- Sidebar: navigation and the account list. Hidden when - // the window is too narrow to spare 240 points for it. - sidebar := Panel{ - width: 248 - height: Fill - flow: Down - padding: Inset{left: 10, right: 10, top: 14, bottom: 10} - spacing: 4 - - brand := Label{ - margin: Inset{left: 10, bottom: 10} - draw_text +: { - color: mod.finance.fg - text_style: theme.font_bold{font_size: 14} - } - text: "Finance" - } - - nav_overview := NavItem{ text: "Overview" } - nav_ledger := NavItem{ text: "Transactions" } - nav_budget := NavItem{ text: "Budget" } - nav_reports := NavItem{ text: "Reports" } - nav_import := NavItem{ text: "Import" } - - Hr{ height: 18 } - - net_worth_label := Dim{ - margin: Inset{left: 12} - text: "Net worth" - } - net_worth_value := Label{ - margin: Inset{left: 12, bottom: 8} - draw_text +: { - color: mod.finance.fg - text_style: theme.font_code{font_size: 17} - } - } - - accounts_label := Dim{ - margin: Inset{left: 12, top: 4, bottom: 2} - text: "ACCOUNTS" - } - accounts_list := PortalList{ - width: Fill - height: Fill - Account := AccountRow{} - } - } - - content := View{ - width: Fill - height: Fill - flow: Down - - // ---- Top bar: title, search, and the range chips. - topbar := Panel{ - width: Fill - height: 52 - flow: Right - align: Align{x: 0.0, y: 0.5} - padding: Inset{left: 16, right: 16} - spacing: 10 - screen_title := Title{ width: Fit } - search_input := Search{ width: Fill } - range_month := Chip{ text: "Month" } - range_quarter := Chip{ text: "90 days" } - range_year := Chip{ text: "Year" } - range_all := Chip{ text: "All" } - } - - screens := View{ - width: Fill - height: Fill - flow: Overlay - - // ================= OVERVIEW ================= - overview := ScrollYView{ - width: Fill - height: Fill - flow: Down - padding: 16 - spacing: 14 - - stats_row := View{ - width: Fill - height: Fit - flow: Right - spacing: 12 - stat_in := StatCard{} - stat_out := StatCard{} - stat_net := StatCard{} - stat_saved := StatCard{} - } - - worth_card := Card{ - height: 260 - worth_title := Title{ text: "Net worth" } - worth_sub := Dim{} - worth_chart := Trend{} - } - - lower_row := View{ - width: Fill - height: Fit - flow: Right - spacing: 12 - - spend_card := Card{ - width: Fill - spend_title := Title{ text: "Where it went" } - cat_0 := BarRow{} - cat_1 := BarRow{} - cat_2 := BarRow{} - cat_3 := BarRow{} - cat_4 := BarRow{} - cat_5 := BarRow{} - cat_6 := BarRow{} - cat_7 := BarRow{} - } - - upcoming_card := Card{ - width: Fill - upcoming_title := Title{ text: "Coming up" } - due_0 := View{ width: Fill, height: Fit, flow: Right, margin: Inset{top: 6} - due_name_0 := Body{ width: Fill } - due_amount_0 := Body{ width: Fit } - } - due_1 := View{ width: Fill, height: Fit, flow: Right, margin: Inset{top: 6} - due_name_1 := Body{ width: Fill } - due_amount_1 := Body{ width: Fit } - } - due_2 := View{ width: Fill, height: Fit, flow: Right, margin: Inset{top: 6} - due_name_2 := Body{ width: Fill } - due_amount_2 := Body{ width: Fit } - } - due_3 := View{ width: Fill, height: Fit, flow: Right, margin: Inset{top: 6} - due_name_3 := Body{ width: Fill } - due_amount_3 := Body{ width: Fit } - } - due_4 := View{ width: Fill, height: Fit, flow: Right, margin: Inset{top: 6} - due_name_4 := Body{ width: Fill } - due_amount_4 := Body{ width: Fit } - } - due_5 := View{ width: Fill, height: Fit, flow: Right, margin: Inset{top: 6} - due_name_5 := Body{ width: Fill } - due_amount_5 := Body{ width: Fit } - } - } - } - } - - // ================= TRANSACTIONS ================= - ledger := View{ - width: Fill - height: Fill - flow: Down - ledger_grid := Ledger{} - } - - // ================= BUDGET ================= - budget := View{ - width: Fill - height: Fill - flow: Down - budget_bar := Panel{ - width: Fill - height: 40 - flow: Right - align: Align{x: 0.0, y: 0.5} - padding: Inset{left: 16, right: 16} - spacing: 10 - budget_prev := Chip{ text: "‹" } - budget_month := Body{ width: 120 } - budget_next := Chip{ text: "›" } - budget_summary := Dim{ width: Fill } - } - budget_grid := Ledger{ - cols: 5 - default_col_width: 150.0 - } - } - - // ================= REPORTS ================= - reports := ScrollYView{ - width: Fill - height: Fill - flow: Down - padding: 16 - spacing: 14 - flow_card := Card{ - height: 240 - flow_title := Title{ text: "Income and spending" } - flow_sub := Dim{} - flow_chart := Trend{} - } - payee_card := Card{ - payee_title := Title{ text: "Biggest payees" } - pay_0 := BarRow{} - pay_1 := BarRow{} - pay_2 := BarRow{} - pay_3 := BarRow{} - pay_4 := BarRow{} - pay_5 := BarRow{} - pay_6 := BarRow{} - pay_7 := BarRow{} - } - subs_card := Card{ - subs_title := Title{ text: "Recurring" } - subs_body := Dim{} - } - } - - // ================= IMPORT ================= - import := View{ - width: Fill - height: Fill - flow: Down - padding: 16 - spacing: 12 - import_head := Card{ - import_title := Title{ text: "Import a bank statement" } - import_hint := Dim{ - text: "Pick a CSV your bank exported. Columns, date order and decimal style are detected; anything already in the ledger is skipped." - } - import_actions := View{ - width: Fill - height: Fit - flow: Right - spacing: 8 - margin: Inset{top: 6} - import_pick := Primary{ text: "Choose CSV…" } - import_apply := Primary{ text: "Import" } - import_cancel := Chip{ text: "Discard" } - } - import_status := Body{ margin: Inset{top: 4} } - } - import_grid := Ledger{ - cols: 5 - default_col_width: 160.0 - } - } - } - } - } - - // ---- Bottom tab bar: the phone layout's navigation. Always in - // the tree (a conditionally-built widget loses its state), just - // hidden when there is a sidebar instead. - tabbar := Panel{ - visible: false - width: Fill - height: 56 - flow: Right - align: Align{x: 0.5, y: 0.5} - padding: Inset{left: 6, right: 6} - spacing: 2 - tab_overview := NavItem{ height: Fill, text: "Overview" } - tab_ledger := NavItem{ height: Fill, text: "Ledger" } - tab_budget := NavItem{ height: Fill, text: "Budget" } - tab_reports := NavItem{ height: Fill, text: "Reports" } - tab_import := NavItem{ height: Fill, text: "Import" } - } - } -} - -/// Which screen is showing. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Screen { - Overview, - Ledger, - Budget, - Reports, - Import, -} - -impl Screen { - const ALL: [Screen; 5] = - [Screen::Overview, Screen::Ledger, Screen::Budget, Screen::Reports, Screen::Import]; - - fn title(self) -> &'static str { - match self { - Screen::Overview => "Overview", - Screen::Ledger => "Transactions", - Screen::Budget => "Budget", - Screen::Reports => "Reports", - Screen::Import => "Import", - } - } - - fn view_id(self) -> &'static [LiveId] { - match self { - Screen::Overview => ids!(overview), - Screen::Ledger => ids!(ledger), - Screen::Budget => ids!(budget), - Screen::Reports => ids!(reports), - Screen::Import => ids!(import), - } - } -} - -/// How much room there is, and therefore which app this is. -/// -/// The thresholds are where the content stops fitting, not where a -/// particular device is: below ~700 points a register cannot show a -/// category and a balance as well as a payee, and a 248-point sidebar -/// costs more than it gives. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Layout { - /// Phone: bottom tabs, one column, three-column register. - Compact, - /// Small window or tablet: sidebar, but the register drops the columns - /// that only help when there is room to spare. - Regular, - /// Desktop: everything. - Wide, -} - -impl Layout { - fn for_width(width: f64) -> Layout { - if width < 700.0 { - Layout::Compact - } else if width < 1100.0 { - Layout::Regular - } else { - Layout::Wide - } - } - - fn has_sidebar(self) -> bool { - self != Layout::Compact - } -} - -/// The columns of the register, in order. Which of them are shown depends -/// on the layout — the first three are the ones a phone can afford. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Column { - Date, - Payee, - Amount, - Category, - Account, - Cleared, - Balance, -} - -impl Column { - fn label(self) -> &'static str { - match self { - Column::Date => "Date", - Column::Payee => "Payee", - Column::Amount => "Amount", - Column::Category => "Category", - Column::Account => "Account", - Column::Cleared => "", - Column::Balance => "Balance", - } - } - - fn width(self, layout: Layout) -> f64 { - match (self, layout) { - (Column::Date, Layout::Compact) => 86.0, - (Column::Date, _) => 104.0, - (Column::Payee, Layout::Compact) => 150.0, - (Column::Payee, _) => 230.0, - (Column::Amount, _) => 110.0, - (Column::Category, _) => 170.0, - (Column::Account, _) => 130.0, - (Column::Cleared, _) => 34.0, - (Column::Balance, _) => 120.0, - } - } -} - -/// The columns of the register for a layout. -/// -/// The running balance is only shown for a single account: a balance -/// column over a mixed list jumps between accounts row by row and means -/// nothing, which is why every product hides it until you pick one. -fn columns_for(layout: Layout, one_account: bool) -> Vec { - match layout { - // A phone shows what a bank app shows: when, who, how much. - Layout::Compact => vec![Column::Date, Column::Payee, Column::Amount], - Layout::Regular => vec![ - Column::Date, - Column::Payee, - Column::Category, - Column::Cleared, - Column::Amount, - ], - Layout::Wide => { - let mut columns = vec![ - Column::Date, - Column::Payee, - Column::Category, - Column::Account, - Column::Cleared, - Column::Amount, - ]; - if one_account { - columns.push(Column::Balance); - } - columns - } - } -} - -/// How much history a screen is looking at. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Range { - Month, - Quarter, - Year, - All, -} - -impl Range { - fn resolve(self, today: Day, earliest: Day) -> DateRange { - match self { - Range::Month => DateRange::month(date::month_key(today)), - Range::Quarter => DateRange { start: today - 89, end: today }, - Range::Year => DateRange::last_months(today, 12), - Range::All => DateRange { start: earliest, end: today }, - } - } - - fn label(self) -> &'static str { - match self { - Range::Month => "this month", - Range::Quarter => "the last 90 days", - Range::Year => "the last 12 months", - Range::All => "all time", - } - } -} - -#[derive(Script, ScriptHook, Widget)] -pub struct Finance { - #[deref] - view: View, - - #[rust] - db: Option, - #[rust] - ledger: Ledger, - #[rust(Screen::Overview)] - screen: Screen, - #[rust(Layout::Wide)] - layout: Layout, - #[rust(Range::Month)] - range: Range, - /// Which account the register is filtered to; `None` = all of them. - #[rust] - account_filter: Option, - #[rust] - search: String, - /// The register's rows, rebuilt whenever a filter changes: transaction - /// index plus the running balance after it. - #[rust] - rows: Vec<(usize, i64)>, - #[rust] - budget_month: MonthKey, - #[rust] - status: String, - #[rust] - started: bool, - #[rust] - chrome_synced: bool, - /// The statement being imported, once one is chosen. - #[rust] - import: Option, -} - -struct ImportState { - path: String, - csv: crate::csv::Csv, - mapping: crate::import::Mapping, - plan: crate::import::Plan, - account: Id, - ask_date_order: bool, -} - -impl Finance { - fn currency(&self) -> Currency { - self.ledger.base_currency - } - - /// Open the file, generating a demo household if it is empty, and load - /// it into memory. - fn start(&mut self, cx: &mut Cx) { - if self.started { - return; - } - self.started = true; - let path = std::path::PathBuf::from("local/finance/finance.db"); - let mut db = match Db::open(&path) { - Ok(db) => db, - Err(error) => { - self.status = format!("cannot open {}: {error}", path.display()); - error!("finance: {}", self.status); - return; - } - }; - match db.is_empty() { - Ok(true) => match crate::seed::populate(&mut db, crate::seed::DEFAULT_YEARS) { - Ok(summary) => self.status = format!("Demo file created: {summary}"), - Err(error) => self.status = format!("demo data failed: {error}"), - }, - Ok(false) => {} - Err(error) => self.status = format!("cannot read {}: {error}", path.display()), - } - match db.load() { - Ok(ledger) => self.ledger = ledger, - Err(error) => self.status = format!("load failed: {error}"), - } - self.db = Some(db); - self.budget_month = date::month_key(date::today()); - self.rebuild_rows(); - self.show_only_current_screen(cx); - self.chrome_synced = false; - self.redraw(cx); - } - - /// The register's contents, after the account filter and the search. - /// - /// Recomputed from scratch on every change rather than maintained - /// incrementally: it is a single pass over a few hundred thousand rows, - /// which is cheaper than the bugs a cache would buy. - fn rebuild_rows(&mut self) { - let needle = self.search.trim().to_lowercase(); - let mut running: std::collections::HashMap = self - .ledger - .accounts - .iter() - .map(|a| (a.id, a.opening_balance)) - .collect(); - - // The ledger is stored date-ordered, so the running balance can be - // accumulated in the same pass that filters. - let mut indexed: Vec = (0..self.ledger.transactions.len()).collect(); - indexed.sort_by_key(|i| { - let txn = &self.ledger.transactions[*i]; - (txn.date, txn.id) - }); - - self.rows.clear(); - for index in indexed { - let txn = &self.ledger.transactions[index]; - let balance = running.entry(txn.account).or_insert(0); - *balance += txn.amount; - let balance = *balance; - if self.account_filter.is_some_and(|id| id != txn.account) { - continue; - } - if !needle.is_empty() { - let category = txn.category_label(&self.ledger.categories).to_lowercase(); - let amount = format_minor(txn.amount, self.ledger.base_currency); - let matches = txn.payee.to_lowercase().contains(&needle) - || txn.memo.to_lowercase().contains(&needle) - || category.contains(&needle) - || amount.contains(&needle); - if !matches { - continue; - } - } - self.rows.push((index, balance)); - } - // Newest first, the way every register opens. - self.rows.reverse(); - } - - fn earliest(&self) -> Day { - self.ledger - .transactions - .iter() - .map(|t| t.date) - .min() - .unwrap_or_else(date::today) - } - - fn range(&self) -> DateRange { - self.range.resolve(date::today(), self.earliest()) - } - - /// Show the screen, and make the chrome agree with it. - fn set_screen(&mut self, cx: &mut Cx, screen: Screen) { - self.screen = screen; - self.show_only_current_screen(cx); - self.chrome_synced = false; - self.redraw(cx); - } - - /// The five screens are siblings in one `flow: Overlay`, so exactly one - /// may be visible at a time — otherwise they draw on top of each other. - fn show_only_current_screen(&mut self, cx: &mut Cx) { - for screen in Screen::ALL { - self.view(cx, screen.view_id()) - .set_visible(cx, screen == self.screen); - } - } - - /// Apply the layout for this width: which chrome exists, and how dense - /// the register is. - fn apply_layout(&mut self, cx: &mut Cx, layout: Layout) { - if self.layout == layout && self.chrome_synced { - return; - } - self.layout = layout; - self.view(cx, ids!(sidebar)).set_visible(cx, layout.has_sidebar()); - self.view(cx, ids!(tabbar)).set_visible(cx, !layout.has_sidebar()); - // The search field earns its width on a desktop; on a phone the - // title and the chips are what fit. - let compact = layout == Layout::Compact; - self.widget(cx, ids!(range_quarter)).set_visible(cx, !compact); - self.widget(cx, ids!(range_year)).set_visible(cx, !compact); - self.widget(cx, ids!(range_all)).set_visible(cx, !compact); - // Stat cards stack rather than shrink to illegibility. - self.view(cx, ids!(stat_saved)).set_visible(cx, !compact); - self.view(cx, ids!(stat_net)).set_visible(cx, layout != Layout::Compact); - } - - /// Push every value the chrome shows. Cheap enough to run whenever - /// something changed, rather than tracking what. - fn sync_chrome(&mut self, cx: &mut Cx) { - let currency = self.currency(); - let today = date::today(); - let range = self.range(); - - self.label(cx, ids!(screen_title)).set_text(cx, self.screen.title()); - for (screen, id) in Screen::ALL.iter().zip([ - ids!(nav_overview), - ids!(nav_ledger), - ids!(nav_budget), - ids!(nav_reports), - ids!(nav_import), - ]) { - let active = *screen == self.screen; - let mut item = self.button(cx, id); - let color = if active { theme::rgb(0xe6edf3) } else { theme::rgb(0x9aa7b4) }; - let bg = if active { theme::rgb(0x1f3a63) } else { Vec4f::default() }; - script_apply_eval!(cx, item, { - draw_bg +: { color: #(bg) } - draw_text +: { color: #(color) } - }); - } - - let worth = self.ledger.net_worth_on(today); - self.label(cx, ids!(net_worth_value)) - .set_text(cx, &format_money(worth, currency)); - - // Overview numbers. - let flow = report::flow(&self.ledger, range); - let saved = if flow.income > 0 { - format!("{:.0}%", flow.net() as f64 / flow.income as f64 * 100.0) - } else { - "—".to_string() - }; - for (id, label, value, note) in [ - (ids!(stat_in), "Money in", format_money(flow.income, currency), self.range.label()), - (ids!(stat_out), "Money out", format_money(flow.expense, currency), self.range.label()), - (ids!(stat_net), "Net", format_money(flow.net(), currency), self.range.label()), - (ids!(stat_saved), "Kept", saved.clone(), "of what came in"), - ] { - let card = self.view(cx, id); - card.label(cx, ids!(stat_label)).set_text(cx, label); - card.label(cx, ids!(stat_value)).set_text(cx, &value); - card.label(cx, ids!(stat_note)).set_text(cx, note); - } - - // Net worth chart: 24 months of ends-of-month. - let series = report::net_worth_series(&self.ledger, 24, today); - let major = currency.decimals as i32; - let scale = 10f64.powi(major); - let values: Vec = series.iter().map(|(_, v)| *v as f64 / scale).collect(); - let high = values.iter().cloned().fold(f64::MIN, f64::max); - let low = values.iter().cloned().fold(f64::MAX, f64::min); - let marks = vec![ - (high, format_compact((high * scale) as i64, currency)), - (low, format_compact((low * scale) as i64, currency)), - ]; - self.finance_chart(cx, ids!(worth_chart)).set_area(cx, &values, marks); - if let (Some(first), Some(last)) = (series.first(), series.last()) { - let change = last.1 - first.1; - self.label(cx, ids!(worth_sub)).set_text( - cx, - &format!( - "{} over 24 months", - if change >= 0 { - format!("up {}", format_money(change, currency)) - } else { - format!("down {}", format_money(-change, currency)) - } - ), - ); - } - - // Where the money went. - let spending = report::spending_by_group(&self.ledger, range); - let biggest = spending.first().map(|(_, v)| *v).unwrap_or(1).max(1); - for (slot, id) in [ - ids!(cat_0), - ids!(cat_1), - ids!(cat_2), - ids!(cat_3), - ids!(cat_4), - ids!(cat_5), - ids!(cat_6), - ids!(cat_7), - ] - .into_iter() - .enumerate() - { - let row = self.widget(cx, id); - match spending.get(slot) { - Some((category, amount)) => { - row.set_visible(cx, true); - let name = category - .map(|c| self.ledger.categories.name(c).to_string()) - .unwrap_or_else(|| "Uncategorized".to_string()); - row.label(cx, ids!(bar_label)).set_text(cx, &name); - row.label(cx, ids!(bar_value)) - .set_text(cx, &format_money(*amount, currency)); - let fraction = (*amount as f64 / biggest as f64).clamp(0.0, 1.0); - set_bar(cx, &row, fraction, None); - } - None => row.set_visible(cx, false), - } - } - - // Coming up. - let upcoming = report::upcoming(&self.ledger, 30, today); - for (slot, (row_id, name_id, amount_id)) in [ - (ids!(due_0), ids!(due_name_0), ids!(due_amount_0)), - (ids!(due_1), ids!(due_name_1), ids!(due_amount_1)), - (ids!(due_2), ids!(due_name_2), ids!(due_amount_2)), - (ids!(due_3), ids!(due_name_3), ids!(due_amount_3)), - (ids!(due_4), ids!(due_name_4), ids!(due_amount_4)), - (ids!(due_5), ids!(due_name_5), ids!(due_amount_5)), - ] - .into_iter() - .enumerate() - { - let row = self.view(cx, row_id); - match upcoming.get(slot) { - Some(item) => { - row.set_visible(cx, true); - self.label(cx, name_id).set_text( - cx, - &format!("{} · {}", item.payee, date::format_short(item.next_due)), - ); - let mut amount = self.label(cx, amount_id); - amount.set_text(cx, &format_money(item.amount, currency)); - let color = if item.amount < 0 { - theme::rgb(theme::CRITICAL) - } else { - theme::rgb(theme::GOOD) - }; - script_apply_eval!(cx, amount, { - draw_text +: { color: #(color) } - }); - } - None => row.set_visible(cx, false), - } - } - - // Reports. - let months = report::monthly_flow(&self.ledger, 18, today); - let income: Vec = months.iter().map(|(_, f)| f.income as f64 / scale).collect(); - let spend: Vec = months.iter().map(|(_, f)| -(f.expense as f64) / scale).collect(); - let labels: Vec = months - .iter() - .map(|(key, _)| date::format_month(*key).split(' ').next().unwrap_or("").to_string()) - .collect(); - self.finance_chart(cx, ids!(flow_chart)).set_bars(cx, &income, &spend, labels); - let average: i64 = if months.is_empty() { - 0 - } else { - months.iter().map(|(_, f)| f.expense).sum::() / months.len() as i64 - }; - self.label(cx, ids!(flow_sub)).set_text( - cx, - &format!("Monthly net over 24 months · average spend {}", format_money(average, currency)), - ); - - let payees = report::top_payees(&self.ledger, range, 8); - let biggest = payees.first().map(|(_, v, _)| *v).unwrap_or(1).max(1); - for (slot, id) in [ - ids!(pay_0), - ids!(pay_1), - ids!(pay_2), - ids!(pay_3), - ids!(pay_4), - ids!(pay_5), - ids!(pay_6), - ids!(pay_7), - ] - .into_iter() - .enumerate() - { - let row = self.widget(cx, id); - match payees.get(slot) { - Some((payee, amount, count)) => { - row.set_visible(cx, true); - row.label(cx, ids!(bar_label)) - .set_text(cx, &format!("{payee} ({count})")); - row.label(cx, ids!(bar_value)) - .set_text(cx, &format_money(*amount, currency)); - let fraction = (*amount as f64 / biggest as f64).clamp(0.0, 1.0); - set_bar(cx, &row, fraction, None); - } - None => row.set_visible(cx, false), - } - } - - let subs = report::detected_subscriptions(&self.ledger, today); - let monthly: i64 = subs - .iter() - .filter(|(_, _, r, _)| *r == Recurrence::Monthly) - .map(|(_, amount, _, _)| -amount) - .sum(); - let list = subs - .iter() - .take(8) - .map(|(payee, amount, recurrence, due)| { - format!( - "{payee} — {} {} · next {}", - format_money(*amount, currency), - recurrence.label().to_lowercase(), - date::format_short(*due) - ) - }) - .collect::>() - .join("\n"); - self.label(cx, ids!(subs_body)).set_text( - cx, - &format!( - "{} recurring charges found, {} a month\n\n{list}", - subs.len(), - format_money(monthly, currency) - ), - ); - - // Budget header. - self.label(cx, ids!(budget_month)) - .set_text(cx, &date::format_month(self.budget_month)); - let lines = report::budget_lines(&self.ledger, self.budget_month); - let assigned: i64 = lines.iter().map(|(_, l)| l.assigned).sum(); - let spent: i64 = lines.iter().map(|(_, l)| l.spent).sum(); - let over = lines.iter().filter(|(_, l)| l.available < 0).count(); - self.label(cx, ids!(budget_summary)).set_text( - cx, - &format!( - "{} assigned · {} spent · {} left{}", - format_money(assigned, currency), - format_money(spent, currency), - format_money(assigned - spent, currency), - if over > 0 { format!(" · {over} over") } else { String::new() } - ), - ); - - // Import. - let import_line = match &self.import { - Some(state) => format!( - "{} · {} new, {} already here, {} unreadable{}", - state.path, - state.plan.new_count(), - state.plan.duplicate_count(), - state.plan.unreadable_count(), - if state.ask_date_order { - " · DATE ORDER IS A GUESS — check the preview" - } else { - "" - } - ), - None => self.status.clone(), - }; - self.label(cx, ids!(import_status)).set_text(cx, &import_line); - self.widget(cx, ids!(import_apply)) - .set_visible(cx, self.import.is_some()); - self.widget(cx, ids!(import_cancel)) - .set_visible(cx, self.import.is_some()); - - self.chrome_synced = true; - } - - fn open_statement(&mut self, cx: &mut Cx) { - let dialog = FileDialog::new() - .set_id(PICK_STATEMENT) - .set_title("Choose a statement".to_string()) - .add_filter("Comma-separated values".to_string(), vec!["csv".to_string()]) - .add_filter("Text".to_string(), vec!["txt".to_string()]) - .add_filter("All Files".to_string(), vec!["*".to_string()]); - cx.open_select_file_dialog(dialog); - } - - /// Read a chosen file and build the plan, without writing anything. - fn prepare_import(&mut self, cx: &mut Cx, path: &std::path::Path) { - let text = match std::fs::read(path) { - Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), - Err(error) => { - self.status = format!("cannot read {}: {error}", path.display()); - self.chrome_synced = false; - self.redraw(cx); - return; - } - }; - let csv = crate::csv::parse(&text); - let guess = crate::import::Mapping::guess(&csv); - let account = self - .account_filter - .or_else(|| self.ledger.accounts.first().map(|a| a.id)) - .unwrap_or(0); - let Some(account_ref) = self.ledger.account(account).cloned() else { - self.status = "no account to import into".to_string(); - return; - }; - let known = self - .db - .as_mut() - .and_then(|db| db.known_fingerprints().ok()) - .unwrap_or_default(); - let plan = crate::import::plan(&csv, &guess.mapping, &account_ref, &self.ledger.rules, &known); - self.import = Some(ImportState { - path: path.display().to_string(), - csv, - mapping: guess.mapping, - plan, - account, - ask_date_order: guess.ask_date_order, - }); - self.set_screen(cx, Screen::Import); - } - - /// Write the plan. Everything or nothing. - fn commit_import(&mut self, cx: &mut Cx) { - let Some(state) = self.import.take() else { return }; - let Some(db) = self.db.as_mut() else { return }; - let rows: Vec = state.plan.to_import().cloned().collect(); - let count = rows.len(); - let result = db.transact(|conn| { - for txn in &rows { - crate::db::insert_transaction_on(conn, txn)?; - } - Ok(()) - }); - match result { - Ok(()) => { - self.status = format!("Imported {count} transactions from {}", state.path); - if let Ok(ledger) = db.load() { - self.ledger = ledger; - } - self.rebuild_rows(); - self.set_screen(cx, Screen::Ledger); - } - Err(error) => { - self.status = format!("import failed, nothing written: {error}"); - self.chrome_synced = false; - self.redraw(cx); - } - } - } - - /// One register cell. - fn ledger_cell(&self, row: usize, column: Column) -> (String, CellStyle) { - let currency = self.ledger.base_currency; - let Some((index, balance)) = self.rows.get(row).copied() else { - return (String::new(), plain()); - }; - let txn = &self.ledger.transactions[index]; - match column { - Column::Date => (date::format_short(txn.date), dim()), - Column::Payee => { - let mut style = plain(); - if txn.flagged { - style.color = Some(theme::rgb(theme::WARNING)); - } - (txn.payee.clone(), style) - } - Column::Category => { - let label = txn.category_label(&self.ledger.categories); - let mut style = dim(); - if label.is_empty() && !txn.is_transfer() { - return ("— uncategorized".to_string(), CellStyle { - color: Some(theme::rgb(theme::WARNING)), - ..dim() - }); - } - if txn.is_split() { - style.color = Some(theme::rgb(0xa371f7)); - } - (label, style) - } - Column::Account => (self.ledger.account_name(txn.account).to_string(), dim()), - Column::Cleared => ( - txn.cleared.mark().to_string(), - CellStyle { align: 0.5, ..dim() }, - ), - Column::Amount => ( - format_minor(txn.amount, currency), - CellStyle { - color: Some(if txn.amount < 0 { - theme::rgb(theme::CRITICAL) - } else { - theme::rgb(theme::GOOD) - }), - align: 1.0, - bold: true, - ..plain() - }, - ), - Column::Balance => ( - format_minor(balance, currency), - CellStyle { align: 1.0, ..dim() }, - ), - } - } - - /// One budget cell. - fn budget_cell(&self, lines: &[(Id, BudgetLine)], row: usize, col: usize) -> (String, CellStyle) { - let currency = self.ledger.base_currency; - let Some((category, line)) = lines.get(row) else { - return (String::new(), plain()); - }; - match col { - 0 => (self.ledger.categories.path(*category), plain()), - 1 => (format_minor(line.assigned, currency), CellStyle { align: 1.0, ..dim() }), - 2 => (format_minor(line.spent, currency), CellStyle { align: 1.0, ..dim() }), - 3 => ( - if line.carried != 0 { - format_minor(line.carried, currency) - } else { - String::new() - }, - CellStyle { align: 1.0, ..dim() }, - ), - _ => ( - format_minor(line.available, currency), - CellStyle { - color: Some(match line.state() { - BudgetState::Overspent => theme::rgb(theme::CRITICAL), - BudgetState::Untouched => theme::rgb(0x6b7784), - _ => theme::rgb(theme::GOOD), - }), - align: 1.0, - bold: true, - ..plain() - }, - ), - } - } - - /// One preview cell of an import. - fn import_cell(&self, state: &ImportState, row: usize, col: usize) -> (String, CellStyle) { - let currency = self.ledger.base_currency; - let Some(candidate) = state.plan.rows.get(row) else { - return (String::new(), plain()); - }; - use crate::import::RowStatus; - let faded = matches!(candidate.status, RowStatus::Duplicate | RowStatus::Unreadable); - let base = if faded { dim() } else { plain() }; - match col { - 0 => ( - match candidate.status { - RowStatus::New => "new".to_string(), - RowStatus::Duplicate => "already here".to_string(), - RowStatus::Unreadable => "unreadable".to_string(), - }, - CellStyle { - color: Some(match candidate.status { - RowStatus::New => theme::rgb(theme::GOOD), - RowStatus::Duplicate => theme::rgb(0x6b7784), - RowStatus::Unreadable => theme::rgb(theme::CRITICAL), - }), - ..base - }, - ), - 1 => ( - if candidate.status == RowStatus::Unreadable { - String::new() - } else { - date::format_short(candidate.txn.date) - }, - base, - ), - 2 => (candidate.txn.payee.clone(), base), - 3 => ( - self.ledger.categories.path(candidate.txn.category.unwrap_or(0)), - base, - ), - _ => ( - format_minor(candidate.txn.amount, currency), - CellStyle { align: 1.0, bold: !faded, ..base }, - ), - } - } -} - -/// Set a bar's length. The meter shader takes the fraction directly, so -/// there is nothing to measure and nothing to re-measure on a resize. -fn set_bar(cx: &mut Cx, row: &WidgetRef, fraction: f64, color: Option) { - row.meter(cx, ids!(bar_meter)).set(cx, fraction, color, -1.0); -} - -fn plain() -> CellStyle { - CellStyle { bg: None, color: None, align: 0.0, bold: false, font_scale: 1.0 } -} - -fn dim() -> CellStyle { - CellStyle { color: Some(theme::rgb(0x9aa7b4)), ..plain() } -} - -impl Widget for Finance { - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - self.start(cx); - - // The layout follows the window, one frame behind on a resize — - // which is invisible, because a resize redraws continuously. - let width = self.view.area().rect(cx).size.x; - if width > 1.0 { - let layout = Layout::for_width(width); - if layout != self.layout { - self.apply_layout(cx, layout); - } - } - if !self.chrome_synced { - self.sync_chrome(cx); - } - - let columns = columns_for(self.layout, self.account_filter.is_some()); - // The grid's own width, for handing the slack to one column. - let grid_width = self.widget(cx, ids!(ledger_grid)).area().rect(cx).size.x; - let budget_lines = report::budget_lines(&self.ledger, self.budget_month); - - while let Some(step) = self.view.draw_walk(cx, scope, walk).step() { - // The account list. - if let Some(mut list) = step.as_portal_list().borrow_mut() { - let accounts: Vec<&Account> = - self.ledger.accounts.iter().filter(|a| !a.closed).collect(); - list.set_item_range(cx, 0, accounts.len()); - while let Some(index) = list.next_visible_item(cx) { - // PortalList hands out ids past the end to fill the - // viewport; drawing those would repeat the last row. - if index >= accounts.len() { - continue; - } - let account = accounts[index]; - let item = list.item(cx, index, live_id!(Account)); - let balance = self.ledger.balance(account.id); - item.label(cx, ids!(acc_name)).set_text(cx, &account.name); - item.label(cx, ids!(acc_kind)).set_text( - cx, - &format!("{} · {}", account.kind.label(), account.institution), - ); - let mut money = item.label(cx, ids!(acc_balance)); - money.set_text(cx, &format_compact(balance, account.currency)); - let color = if balance < 0 { - theme::rgb(theme::CRITICAL) - } else { - theme::rgb(0xe6edf3) - }; - script_apply_eval!(cx, money, { - draw_text +: { color: #(color) } - }); - item.draw_all(cx, &mut Scope::empty()); - } - continue; - } - - // The three grids. - let grid_ref = step.as_data_grid(); - let Some(mut grid) = grid_ref.borrow_mut() else { continue }; - match self.screen { - Screen::Ledger => { - grid.set_grid_size(self.rows.len(), columns.len()); - grid.set_col_labels(columns.iter().map(|c| c.label().to_string()).collect()); - let fixed: f64 = columns - .iter() - .filter(|c| **c != Column::Payee) - .map(|c| c.width(self.layout)) - .sum(); - // Payee absorbs the remainder, so the register fills - // the window at any width instead of leaving a gutter. - let payee = (grid_width - fixed - 2.0).max(120.0); - for (index, column) in columns.iter().enumerate() { - grid.set_col_width( - index, - if *column == Column::Payee { payee } else { column.width(self.layout) }, - ); - } - grid.set_default_sizes( - cx, - 140.0, - if self.layout == Layout::Compact { 40.0 } else { 26.0 }, - ); - while let Some(cell) = grid.next_cell(cx) { - let Some(column) = columns.get(cell.col) else { continue }; - let (text, style) = self.ledger_cell(cell.row, *column); - grid.cell_text_styled(cx, &cell, &text, style); - } - } - Screen::Budget => { - grid.set_grid_size(budget_lines.len(), 5); - grid.set_col_labels( - ["Category", "Assigned", "Spent", "Carried", "Available"] - .iter() - .map(|s| s.to_string()) - .collect(), - ); - grid.set_col_width(0, 220.0); - while let Some(cell) = grid.next_cell(cx) { - let (text, style) = self.budget_cell(&budget_lines, cell.row, cell.col); - grid.cell_text_styled(cx, &cell, &text, style); - } - } - Screen::Import => { - let rows = self.import.as_ref().map(|s| s.plan.rows.len()).unwrap_or(0); - grid.set_grid_size(rows, 5); - grid.set_col_labels( - ["", "Date", "Payee", "Category", "Amount"] - .iter() - .map(|s| s.to_string()) - .collect(), - ); - grid.set_col_width(0, 110.0); - grid.set_col_width(2, 240.0); - while let Some(cell) = grid.next_cell(cx) { - let (text, style) = match &self.import { - Some(state) => self.import_cell(state, cell.row, cell.col), - None => (String::new(), plain()), - }; - grid.cell_text_styled(cx, &cell, &text, style); - } - } - _ => {} - } - } - DrawStep::done() - } - - fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - self.view.handle_event(cx, event, scope); - self.widget_match_event(cx, event, scope); - } -} - -impl WidgetMatchEvent for Finance { - fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { - // Navigation: the sidebar and the tab bar drive the same screens. - for (screen, nav, tab) in [ - (Screen::Overview, ids!(nav_overview), ids!(tab_overview)), - (Screen::Ledger, ids!(nav_ledger), ids!(tab_ledger)), - (Screen::Budget, ids!(nav_budget), ids!(tab_budget)), - (Screen::Reports, ids!(nav_reports), ids!(tab_reports)), - (Screen::Import, ids!(nav_import), ids!(tab_import)), - ] { - if self.button(cx, nav).clicked(actions) || self.button(cx, tab).clicked(actions) { - self.set_screen(cx, screen); - } - } - - for (range, id) in [ - (Range::Month, ids!(range_month)), - (Range::Quarter, ids!(range_quarter)), - (Range::Year, ids!(range_year)), - (Range::All, ids!(range_all)), - ] { - if self.button(cx, id).clicked(actions) { - self.range = range; - self.chrome_synced = false; - self.redraw(cx); - } - } - - if self.button(cx, ids!(budget_prev)).clicked(actions) { - self.budget_month -= 1; - self.chrome_synced = false; - self.redraw(cx); - } - if self.button(cx, ids!(budget_next)).clicked(actions) { - self.budget_month += 1; - self.chrome_synced = false; - self.redraw(cx); - } - - if self.button(cx, ids!(import_pick)).clicked(actions) { - self.open_statement(cx); - } - if self.button(cx, ids!(import_apply)).clicked(actions) { - self.commit_import(cx); - } - if self.button(cx, ids!(import_cancel)).clicked(actions) { - self.import = None; - self.chrome_synced = false; - self.redraw(cx); - } - - // Search filters the register as it is typed: the whole ledger is - // in memory, so there is no reason to make anyone press Enter. - if let Some(text) = self.text_input(cx, ids!(search_input)).changed(actions) { - self.search = text; - self.rebuild_rows(); - self.set_screen(cx, Screen::Ledger); - } - - // The account list filters the register: clicking the account - // already shown clears the filter, so the row is a toggle. - let accounts: Vec = self - .ledger - .accounts - .iter() - .filter(|a| !a.closed) - .map(|a| a.id) - .collect(); - for (index, item) in self.portal_list(cx, ids!(accounts_list)).items_with_actions(actions) { - if item.button(cx, ids!(acc_hit)).clicked(actions) { - let picked = accounts.get(index as usize).copied(); - self.account_filter = if self.account_filter == picked { None } else { picked }; - self.rebuild_rows(); - self.set_screen(cx, Screen::Ledger); - } - } - - for action in actions { - if let Some(picked) = action.downcast_ref::() { - if picked.id() == PICK_STATEMENT { - if let Some(path) = picked.path().cloned() { - self.prepare_import(cx, &path); - } - } - } - } - } -} diff --git a/apps/mixer/Cargo.toml b/apps/mixer/Cargo.toml index 373bb035e..507845aa2 100644 --- a/apps/mixer/Cargo.toml +++ b/apps/mixer/Cargo.toml @@ -21,4 +21,3 @@ default-run = "makepad-mixer" [dependencies] makepad-widgets = { path = "../../widgets" } -mp-theme = { path = "../../libs/mp_theme" } diff --git a/apps/mixer/layouts/compact.splash b/apps/mixer/layouts/compact.splash index 9ba519963..f529bfc02 100644 --- a/apps/mixer/layouts/compact.splash +++ b/apps/mixer/layouts/compact.splash @@ -1,18 +1,17 @@ // COMPACT surface layout (splash) — the proof that the layout seam is real. // -// Same slot contract and same injected `mp` palette as lr_mix.splash (see its -// header), different surface: name plate on top, big fader + meter, dB -// readout, mute. No EQ/dyn thumbnails, no gain/threshold rows, no pan. The -// host binds whatever slots and children a layout chooses to show — -// everything else simply isn't displayed, and nothing a layout writes can -// name an OSC address. +// Same slot contract as lr_mix.splash (see its header), different surface: +// name plate on top, big fader + meter, dB readout, mute. No EQ/dyn +// thumbnails, no gain/threshold rows, no pan. The host binds whatever slots +// and children a layout chooses to show — everything else simply isn't +// displayed, and nothing a layout writes can name an OSC address. let ValueLabel = Label{ width: Fill align: Align{x: 0.5} text: "—" - draw_text.color: mp.fg_bright - draw_text.text_style: theme.font_code{font_size: 10.0} + draw_text.color: #xe6ecf4 + draw_text.text_style.font_size: 10.0 } let FaderSlider = Slider{ @@ -25,35 +24,33 @@ let FaderSlider = Slider{ text: "" text_input: TextInput{width: 0, height: 0} draw_bg +: { - body: uniform(mp.bg_dark) - slot: uniform(mp.muted) - tick: uniform(mp.muted) - cap: uniform(mp.fg_dim) - cap_edge: uniform(mp.accent) - cap_line: uniform(mp.fg_bright) + body: uniform(#x0b0f15) + slot: uniform(#x171e29) + cap: uniform(#x9aa3ad) + cap_dark: uniform(#x4a5158) pixel: fn() { let sdf = Sdf2d.viewport(self.pos * self.rect_size) let h = self.rect_size.y let w = self.rect_size.x - sdf.rect(0.0, 0.0, w, h) + sdf.box(0.0, 0.0, w, h, 3.0) sdf.fill(self.body) let rel = self.pos.y let grid = abs(fract(rel * 8.0 + 0.5) - 0.5) * (h / 8.0) - let mark = 1.0 - smoothstep(0.4, 0.9, grid) + let tick = 1.0 - smoothstep(0.4, 0.9, grid) let px = self.pos.x * w if px < 7.0 { - sdf.rect(0.0, rel * h - 0.5, 6.0, 1.0) - sdf.fill(self.tick * mark) + sdf.box(0.0, rel * h - 0.4, 6.0, 0.8, 0.2) + sdf.fill(vec4(0.55, 0.62, 0.7, 1.0) * tick) } - sdf.rect(w * 0.5 - 1.0, 3.0, 2.0, h - 6.0) + sdf.box(w * 0.5 - 2.0, 3.0, 4.0, h - 6.0, 2.0) sdf.fill(self.slot) let cap_h = 26.0 let cy = (1.0 - self.slide_pos) * (h - cap_h) - sdf.rect(4.5, cy + 0.5, w - 9.0, cap_h - 1.0) - sdf.fill_keep(self.cap) - sdf.stroke(self.cap_edge, 1.0) - sdf.rect(4.0, cy + cap_h * 0.5 - 1.2, w - 8.0, 2.4) - sdf.fill(self.cap_line) + sdf.box(4.0, cy, w - 8.0, cap_h, 3.0) + let rib = 0.75 + 0.25 * smoothstep(0.25, 0.5, abs(fract((self.pos.y * h - cy) / 4.8) - 0.5)) + sdf.fill(self.cap.mix(self.cap_dark, 1.0 - rib)) + sdf.box(4.0, cy + cap_h * 0.5 - 1.2, w - 8.0, 2.4, 0.6) + sdf.fill(#xf4f7fb) return sdf.result } } @@ -63,12 +60,6 @@ let MeterBar = SolidView{ width: 14 height: Fill draw_bg +: { - gutter: uniform(mp.bg) - trough: uniform(mp.bg_dark) - low: uniform(mp.green) - mid: uniform(mp.yellow) - hot: uniform(mp.red) - tick: uniform(mp.fg_bright) level_db: instance(-90.0) peak_db: instance(-90.0) pixel: fn() { @@ -77,21 +68,24 @@ let MeterBar = SolidView{ let norm = clamp((self.level_db + 60.0) / 60.0, 0.0, 1.0) let peakn = clamp((self.peak_db + 60.0) / 60.0, 0.0, 1.0) let frac = 1.0 - self.pos.y - let ladder = self.low.mix(self.mid, smoothstep(0.70, 0.88, frac)) - .mix(self.hot, smoothstep(0.88, 0.97, frac)) - let seg = 0.88 + 0.12 * smoothstep(0.35, 0.5, abs(fract(frac * 30.0) - 0.5)) - var col = self.trough.mix(self.hot, 0.16 * step(0.955, frac)) + var col = vec3(0.03, 0.09, 0.045) + let seg = 0.85 + 0.15 * smoothstep(0.35, 0.5, abs(fract(frac * 30.0) - 0.5)) if frac < norm { - col = self.trough.mix(ladder, seg) + let hot = smoothstep(0.82, 0.93, frac) + col = vec3(0.10, 0.78, 0.28).mix(vec3(1.0, 0.22, 0.16), hot) * seg + } + if frac > 0.955 { + col = col.mix(vec3(0.45, 0.09, 0.07), 0.8) + if frac < norm { col = vec3(1.0, 0.25, 0.18) } } if abs(frac - peakn) < 1.5 / h && peakn > 0.01 { - col = self.tick + col = vec3(0.9, 1.0, 0.9) } let x = self.pos.x * w if x < 1.0 || x > w - 1.0 { - col = self.gutter + col = vec3(0.015, 0.02, 0.03) } - return col + return vec4(col, 1.0) } } } @@ -103,26 +97,20 @@ let MutePlate = SolidView{ new_batch: true align: Align{x: 0.5, y: 0.5} draw_bg +: { - idle: uniform(mp.bg_light) - idle_edge: uniform(mp.muted) - lit: uniform(mp.red) - deep: uniform(mp.bg_dark) muted: instance(0.0) pixel: fn() { let sdf = Sdf2d.viewport(self.pos * self.rect_size) - sdf.rect(0.5, 0.5, self.rect_size.x - 1.0, self.rect_size.y - 1.0) - let fill = self.idle.mix(self.lit.mix(self.deep, 0.5), self.muted) - // fill_keep, not fill: `fill` clears the shape, so a stroke after - // it draws nothing at all. - sdf.fill_keep(fill) - sdf.stroke(self.idle_edge.mix(self.lit, self.muted), 1.0) + sdf.box(1.0, 1.0, self.rect_size.x - 2.0, self.rect_size.y - 2.0, 4.0) + let idle = vec4(0.23, 0.07, 0.06, 1.0) + let lit = vec4(0.82, 0.13, 0.10, 1.0) + sdf.fill(idle.mix(lit, self.muted)) return sdf.result } } Label{ text: "MUTE" - draw_text.color: mp.fg_bright - draw_text.text_style: theme.font_code{font_size: 8.5} + draw_text.color: #xf3d9d6 + draw_text.text_style.font_size: 8.5 } } @@ -133,22 +121,22 @@ let NamePlate = SolidView{ new_batch: true align: Align{x: 0.5, y: 0.5} draw_bg +: { - idle: uniform(mp.bg_light) - // The muted key, until the console reports its scribble colour. - plate_rgb: instance(vec3(0.254, 0.282, 0.408)) + plate_rgb: instance(vec3(0.5, 0.5, 0.5)) plate_filled: instance(0.0) pixel: fn() { let sdf = Sdf2d.viewport(self.pos * self.rect_size) - sdf.rect(0.5, 0.5, self.rect_size.x - 1.0, self.rect_size.y - 1.0) - sdf.fill_keep(self.idle.mix(vec4(self.plate_rgb, 1.0), self.plate_filled)) - sdf.stroke(vec4(self.plate_rgb, 1.0), 1.0) + sdf.box(1.0, 1.0, self.rect_size.x - 2.0, self.rect_size.y - 2.0, 4.0) + let outline = vec4(0.03, 0.04, 0.055, 1.0) + let filled = vec4(self.plate_rgb * 0.82, 1.0) + sdf.fill(outline.mix(filled, self.plate_filled)) + sdf.stroke(vec4(self.plate_rgb, 1.0), 1.2) return sdf.result } } name_lbl := Label{ text: "—" - draw_text.color: mp.fg - draw_text.text_style: theme.font_code{font_size: 9.0} + draw_text.color: #xe8edf4 + draw_text.text_style.font_size: 9.0 } } @@ -161,14 +149,12 @@ let Strip = View{ show_bg: true new_batch: true draw_bg +: { - fill: uniform(mp.bg) - edge: uniform(mp.muted) pixel: fn() { let sdf = Sdf2d.viewport(self.pos * self.rect_size) sdf.rect(0.0, 0.0, self.rect_size.x, self.rect_size.y) - sdf.fill(self.fill) + sdf.fill(#x0e1218) sdf.rect(self.rect_size.x - 1.0, 0.0, 1.0, self.rect_size.y) - sdf.fill(self.edge) + sdf.fill(#x1c232e) return sdf.result } } @@ -178,8 +164,8 @@ let Strip = View{ width: Fill align: Align{x: 0.5} text: "" - draw_text.color: mp.fg_dim - draw_text.text_style: theme.font_code{font_size: 8.0} + draw_text.color: #x8d99aa + draw_text.text_style.font_size: 8.0 } fader_db := ValueLabel{} fader_block := View{ @@ -200,14 +186,14 @@ View{ flow: Down show_bg: true new_batch: true - draw_bg.color: mp.bg_dark + draw_bg.color: #x080a0e surface_note := Label{ width: Fill align: Align{x: 0.5} text: "" - draw_text.color: mp.fg_dim - draw_text.text_style: theme.font_code{font_size: 8.0} + draw_text.color: #x77828f + draw_text.text_style.font_size: 8.0 } strip_row := View{ width: Fill diff --git a/apps/mixer/layouts/lr_mix.splash b/apps/mixer/layouts/lr_mix.splash index 293cd37e9..95cf582a3 100644 --- a/apps/mixer/layouts/lr_mix.splash +++ b/apps/mixer/layouts/lr_mix.splash @@ -5,25 +5,6 @@ // model) can write a different surface by editing/replacing this file and // starting the app with --layout=; the Rust host rebinds by widget name. // -// == The palette == -// A Splash body runs in its own isolate VM, which registers a FRESH stock -// `mod.theme` and never sees the app's retint — so the host PREPENDS one line -// binding `mp` to the desktop palette (src/theme.rs) before handing this file -// to the Splash. A script error therefore reports this file's line + 1. -// The keys, all `#rrggbb`: -// -// mp.bg strip fill mp.accent selection, gain -// mp.bg_dark gutter and inset panels mp.red mute lit, meter hot -// mp.bg_light idle control fill mp.green meter low -// mp.fg readouts mp.yellow EQ, meter mid -// mp.fg_bright the value that matters mp.cyan dynamics, pan -// mp.fg_dim labels, scales -// mp.muted 1px borders, tracks, ticks -// -// The look is Omarchy: flat fills, square corners, one 1px border per state, -// no bevels and no gradients. Colours belong to the theme — a layout that -// wants a new one asks for a palette key, not a literal. -// // == The slot contract == // The host looks for strip slots named strip_0 .. strip_15 and binds the // mixer's own strip list (derived from the console's stereo-link state) to @@ -61,8 +42,8 @@ let ValueLabel = Label{ flow: Right align: Align{x: 0.5} text: "—" - draw_text.color: mp.fg - draw_text.text_style: theme.font_code{font_size: 8.0} + draw_text.color: #xcfd8e3 + draw_text.text_style.font_size: 8.0 } let RowSlider = Slider{ @@ -74,22 +55,20 @@ let RowSlider = Slider{ text: "" text_input: TextInput{width: 0, height: 0} draw_bg +: { - track: uniform(mp.muted) - fill: uniform(mp.accent) - knob: uniform(mp.fg_bright) + track: uniform(#x2b3546) + fill: uniform(#x2f6fe0) + knob: uniform(#xf2f5fa) pixel: fn() { let sdf = Sdf2d.viewport(self.pos * self.rect_size) let cy = self.rect_size.y * 0.5 let x0 = 4.0 let x1 = self.rect_size.x - 4.0 - sdf.rect(x0, cy - 1.0, x1 - x0, 2.0) + sdf.box(x0, cy - 1.5, x1 - x0, 3.0, 1.5) sdf.fill(self.track) let px = x0 + (x1 - x0) * self.slide_pos - sdf.rect(x0, cy - 1.0, max(px - x0, 1.0), 2.0) + sdf.box(x0, cy - 1.5, max(px - x0, 1.0), 3.0, 1.5) sdf.fill(self.fill) - // A square tab, not a bead: the position is a value, and a flat - // edge is easier to read one against the next. - sdf.rect(px - 1.5, cy - 4.5, 3.0, 9.0) + sdf.circle(px, cy, 3.6) sdf.fill(self.knob) return sdf.result } @@ -98,23 +77,22 @@ let RowSlider = Slider{ let EqSlider = RowSlider{ draw_bg +: { - fill: uniform(mp.yellow) - detent: uniform(mp.fg_dim) + fill: uniform(#xb98c2a) pixel: fn() { let sdf = Sdf2d.viewport(self.pos * self.rect_size) let cy = self.rect_size.y * 0.5 let x0 = 4.0 let x1 = self.rect_size.x - 4.0 - sdf.rect(x0, cy - 1.0, x1 - x0, 2.0) + sdf.box(x0, cy - 1.5, x1 - x0, 3.0, 1.5) sdf.fill(self.track) // fill grows out of the centre: this slider is a +/- 15 dB cut/boost let mid = (x0 + x1) * 0.5 let px = x0 + (x1 - x0) * self.slide_pos - sdf.rect(min(px, mid), cy - 1.0, max(abs(px - mid), 1.0), 2.0) + sdf.box(min(px, mid), cy - 1.5, max(abs(px - mid), 1.0), 3.0, 1.5) sdf.fill(self.fill) - sdf.rect(mid - 0.5, cy - 3.5, 1.0, 7.0) - sdf.fill(self.detent) - sdf.rect(px - 1.5, cy - 4.5, 3.0, 9.0) + sdf.box(mid - 0.5, cy - 3.5, 1.0, 7.0, 0.5) + sdf.fill(#x55606e) + sdf.circle(px, cy, 3.6) sdf.fill(self.knob) return sdf.result } @@ -123,7 +101,7 @@ let EqSlider = RowSlider{ let PanSlider = RowSlider{ draw_bg +: { - fill: uniform(mp.cyan) + fill: uniform(#x3a6ea8) } } @@ -132,12 +110,6 @@ let EqCurve = SolidView{ height: 38 cursor: MouseCursor.Hand draw_bg +: { - c_panel: uniform(mp.bg_dark) - c_zero: uniform(mp.muted) - c_ink: uniform(mp.yellow) - c_ink_off: uniform(mp.fg_dim) - c_sel: uniform(mp.accent) - c_dot: uniform(mp.fg_bright) eq_on: instance(1.0) b1: instance(vec4(2.0, 0.15, 0.5, 0.5)) b2: instance(vec4(2.0, 0.40, 0.5, 0.5)) @@ -173,18 +145,16 @@ let EqCurve = SolidView{ let py = self.pos.y * h let curve = 1.0 - smoothstep(0.8, 1.9, abs(py - y)) let center = 1.0 - smoothstep(0.2, 0.9, abs(py - h * 0.5)) - // A flat inset panel, a muted zero line, and the curve — dimmed to - // the quiet foreground while the console reports EQ off, so an - // unknown state never reads as a confident flat response. - let ink = self.c_ink_off.mix(self.c_ink, self.eq_on) - var col = self.c_panel.mix(self.c_zero, center).mix(ink, curve) - // The band the gain slider drives: a dim accent hairline plus a - // bright dot where it meets the curve. + let base = vec3(0.02, 0.027, 0.04) + let amber = vec3(1.0, 0.84, 0.35) * (0.35 + 0.65 * self.eq_on) + var col = base + vec3(0.09, 0.11, 0.14) * center + amber * curve + // The band the gain slider is driving: a dim vertical hairline + // plus a bright dot where it meets the curve. let dx = abs(x - self.sel_f) * w - col = col.mix(self.c_sel, 0.4 * (1.0 - smoothstep(0.4, 1.3, dx))) - let hit = 1.0 - smoothstep(1.6, 2.9, length(vec2(dx, py - y))) - col = col.mix(self.c_dot, hit) - return col + col = col + vec3(0.10, 0.13, 0.18) * (1.0 - smoothstep(0.4, 1.3, dx)) + let dot = 1.0 - smoothstep(1.6, 2.9, length(vec2(dx, py - y))) + col = col.mix(vec3(1.0, 0.93, 0.62), dot) + return vec4(col, 1.0) } } } @@ -193,10 +163,6 @@ let DynCurve = SolidView{ width: Fill height: 32 draw_bg +: { - c_panel: uniform(mp.bg_dark) - c_ink: uniform(mp.cyan) - c_gate: uniform(mp.cyan) - c_gr: uniform(mp.red) comp_on: instance(0.0) thr_db: instance(0.0) ratio: instance(1.0) @@ -216,20 +182,22 @@ let DynCurve = SolidView{ let y = h * (-out_db / 60.0) let py = self.pos.y * h let curve = 1.0 - smoothstep(0.8, 1.9, abs(py - y)) - var col = self.c_panel.mix(self.c_ink, curve) - // gate region: a flat wash below the gate threshold + let base = vec3(0.02, 0.027, 0.04) + let amber = vec3(1.0, 0.84, 0.35) + var col = base + amber * curve + // gate region: filled wedge below the gate threshold if self.gate_on > 0.5 { let gx = (self.gate_thr_db + 60.0) / 60.0 if self.pos.x < gx && py > y { - col = col.mix(self.c_gate, 0.22) + col = col + vec3(0.25, 0.20, 0.06) } } // live gain reduction: a bar dropping from the top right let grn = clamp(-self.gr_db / 20.0, 0.0, 1.0) if self.pos.x > 1.0 - 3.5 / w && self.pos.y < grn { - col = self.c_gr + col = vec3(1.0, 0.45, 0.15) } - return col + return vec4(col, 1.0) } } } @@ -238,12 +206,6 @@ let MeterBar = SolidView{ width: Fill{weight: 14.0} height: Fill draw_bg +: { - gutter: uniform(mp.bg) - trough: uniform(mp.bg_dark) - low: uniform(mp.green) - mid: uniform(mp.yellow) - hot: uniform(mp.red) - tick: uniform(mp.fg_bright) level_db: instance(-90.0) peak_db: instance(-90.0) pixel: fn() { @@ -252,25 +214,27 @@ let MeterBar = SolidView{ let norm = clamp((self.level_db + 60.0) / 60.0, 0.0, 1.0) let peakn = clamp((self.peak_db + 60.0) / 60.0, 0.0, 1.0) let frac = 1.0 - self.pos.y - // The ladder: green, into yellow from about -18 dB, into red over - // the last three. Unlit, the red zone stays visible as a dim cap. - let ladder = self.low.mix(self.mid, smoothstep(0.70, 0.88, frac)) - .mix(self.hot, smoothstep(0.88, 0.97, frac)) + var col = vec3(0.03, 0.09, 0.045) // segment shading so the bar reads "LED-ish" - let seg = 0.88 + 0.12 * smoothstep(0.35, 0.5, abs(fract(frac * 30.0) - 0.5)) - var col = self.trough.mix(self.hot, 0.16 * step(0.955, frac)) + let seg = 0.85 + 0.15 * smoothstep(0.35, 0.5, abs(fract(frac * 30.0) - 0.5)) if frac < norm { - col = self.trough.mix(ladder, seg) + let hot = smoothstep(0.82, 0.93, frac) + col = vec3(0.10, 0.78, 0.28).mix(vec3(1.0, 0.22, 0.16), hot) * seg + } + // permanent red zone cap + if frac > 0.955 { + col = col.mix(vec3(0.45, 0.09, 0.07), 0.8) + if frac < norm { col = vec3(1.0, 0.25, 0.18) } } // peak-hold tick if abs(frac - peakn) < 1.5 / h && peakn > 0.01 { - col = self.tick + col = vec3(0.9, 1.0, 0.9) } let x = self.pos.x * w if x < 1.0 || x > w - 1.0 { - col = self.gutter + col = vec3(0.015, 0.02, 0.03) } - return col + return vec4(col, 1.0) } } } @@ -285,40 +249,37 @@ let FaderSlider = Slider{ text: "" text_input: TextInput{width: 0, height: 0} draw_bg +: { - body: uniform(mp.bg_dark) - slot: uniform(mp.muted) - tick: uniform(mp.muted) - cap: uniform(mp.fg_dim) - cap_edge: uniform(mp.accent) - cap_line: uniform(mp.fg_bright) + body: uniform(#x0b0f15) + slot: uniform(#x171e29) + cap: uniform(#x9aa3ad) + cap_dark: uniform(#x4a5158) pixel: fn() { let sdf = Sdf2d.viewport(self.pos * self.rect_size) let h = self.rect_size.y let w = self.rect_size.x - sdf.rect(0.0, 0.0, w, h) + sdf.box(0.0, 0.0, w, h, 2.0) sdf.fill(self.body) // tick marks every 1/8 of travel (the taper puts the printed // dB scale exactly on these lines) let rel = self.pos.y let grid = abs(fract(rel * 8.0 + 0.5) - 0.5) * (h / 8.0) - let mark = 1.0 - smoothstep(0.4, 0.9, grid) + let tick = 1.0 - smoothstep(0.4, 0.9, grid) let px = self.pos.x * w if px < 6.0 { - sdf.rect(0.0, rel * h - 0.5, 5.0, 1.0) - sdf.fill(self.tick * mark) + sdf.box(0.0, rel * h - 0.4, 5.0, 0.8, 0.2) + sdf.fill(vec4(0.55, 0.62, 0.7, 1.0) * tick) } // centre slot - sdf.rect(w * 0.5 - 1.0, 3.0, 2.0, h - 6.0) + sdf.box(w * 0.5 - 1.5, 3.0, 3.0, h - 6.0, 1.5) sdf.fill(self.slot) - // handle: a flat square cap, edged in the accent, with the - // pointer line across it + // handle: wide ribbed block with a white centre line let cap_h = 22.0 let cy = (1.0 - self.slide_pos) * (h - cap_h) - sdf.rect(3.5, cy + 0.5, w - 7.0, cap_h - 1.0) - sdf.fill_keep(self.cap) - sdf.stroke(self.cap_edge, 1.0) - sdf.rect(3.0, cy + cap_h * 0.5 - 1.0, w - 6.0, 2.0) - sdf.fill(self.cap_line) + sdf.box(3.0, cy, w - 6.0, cap_h, 2.5) + let rib = 0.75 + 0.25 * smoothstep(0.25, 0.5, abs(fract((self.pos.y * h - cy) / 4.4) - 0.5)) + sdf.fill(self.cap.mix(self.cap_dark, 1.0 - rib)) + sdf.box(3.0, cy + cap_h * 0.5 - 1.0, w - 6.0, 2.0, 0.5) + sdf.fill(#xf4f7fb) return sdf.result } } @@ -329,8 +290,8 @@ let ScaleMark = Label{ height: Fill flow: Right align: Align{x: 1.0} - draw_text.color: mp.fg_dim - draw_text.text_style: theme.font_code{font_size: 7.0} + draw_text.color: #x6b7686 + draw_text.text_style.font_size: 7.0 text: "" } @@ -340,7 +301,7 @@ let ScaleCol = View{ flow: Down ScaleMark{text: "10"} ScaleMark{text: "5"} - ScaleMark{text: "0" draw_text.color: mp.fg} + ScaleMark{text: "0" draw_text.color: #xa8b4c4} ScaleMark{text: "-5"} ScaleMark{text: "-10"} ScaleMark{text: "-20"} @@ -355,28 +316,20 @@ let MutePlate = SolidView{ new_batch: true align: Align{x: 0.5, y: 0.5} draw_bg +: { - idle: uniform(mp.bg_light) - idle_edge: uniform(mp.muted) - lit: uniform(mp.red) - deep: uniform(mp.bg_dark) muted: instance(0.0) pixel: fn() { let sdf = Sdf2d.viewport(self.pos * self.rect_size) - sdf.rect(0.5, 0.5, self.rect_size.x - 1.0, self.rect_size.y - 1.0) - // Lit is red laid over the deepest background: a red plate that - // still carries its own word, edged in the full-strength hue. - let fill = self.idle.mix(self.lit.mix(self.deep, 0.5), self.muted) - // fill_keep, not fill: `fill` clears the shape, so a stroke after - // it draws nothing at all. - sdf.fill_keep(fill) - sdf.stroke(self.idle_edge.mix(self.lit, self.muted), 1.0) + sdf.box(1.0, 1.0, self.rect_size.x - 2.0, self.rect_size.y - 2.0, 3.0) + let idle = vec4(0.23, 0.07, 0.06, 1.0) + let lit = vec4(0.82, 0.13, 0.10, 1.0) + sdf.fill(idle.mix(lit, self.muted)) return sdf.result } } Label{ text: "MUTE" - draw_text.color: mp.fg_bright - draw_text.text_style: theme.font_code{font_size: 7.0} + draw_text.color: #xf3d9d6 + draw_text.text_style.font_size: 7.0 } } @@ -387,24 +340,23 @@ let NamePlate = SolidView{ new_batch: true align: Align{x: 0.5, y: 0.5} draw_bg +: { - idle: uniform(mp.bg_light) - // The console's scribble colour, until it reports one. The default is - // the muted key — nothing on this surface is a neutral grey. - plate_rgb: instance(vec3(0.254, 0.282, 0.408)) + plate_rgb: instance(vec3(0.5, 0.5, 0.5)) plate_filled: instance(0.0) pixel: fn() { let sdf = Sdf2d.viewport(self.pos * self.rect_size) - sdf.rect(0.5, 0.5, self.rect_size.x - 1.0, self.rect_size.y - 1.0) - sdf.fill_keep(self.idle.mix(vec4(self.plate_rgb, 1.0), self.plate_filled)) - sdf.stroke(vec4(self.plate_rgb, 1.0), 1.0) + sdf.box(1.0, 1.0, self.rect_size.x - 2.0, self.rect_size.y - 2.0, 3.0) + let outline = vec4(0.03, 0.04, 0.055, 1.0) + let filled = vec4(self.plate_rgb * 0.82, 1.0) + sdf.fill(outline.mix(filled, self.plate_filled)) + sdf.stroke(vec4(self.plate_rgb, 1.0), 1.2) return sdf.result } } name_lbl := Label{ flow: Right text: "—" - draw_text.color: mp.fg - draw_text.text_style: theme.font_code{font_size: 7.5} + draw_text.color: #xe8edf4 + draw_text.text_style.font_size: 7.5 } } @@ -417,14 +369,12 @@ let Strip = View{ show_bg: true new_batch: true draw_bg +: { - fill: uniform(mp.bg) - edge: uniform(mp.muted) pixel: fn() { let sdf = Sdf2d.viewport(self.pos * self.rect_size) sdf.rect(0.0, 0.0, self.rect_size.x, self.rect_size.y) - sdf.fill(self.fill) + sdf.fill(#x10141b) sdf.rect(self.rect_size.x - 1.0, 0.0, 1.0, self.rect_size.y) - sdf.fill(self.edge) + sdf.fill(#x1c232e) return sdf.result } } @@ -455,8 +405,8 @@ let Strip = View{ flow: Right align: Align{x: 0.5} text: "—" - draw_text.color: mp.fg_bright - draw_text.text_style: theme.font_code{font_size: 9.0} + draw_text.color: #xe6ecf4 + draw_text.text_style.font_size: 9.0 } fader_block := View{ width: Fill @@ -474,8 +424,8 @@ let Strip = View{ flow: Right align: Align{x: 0.5} text: "" - draw_text.color: mp.fg_dim - draw_text.text_style: theme.font_code{font_size: 7.5} + draw_text.color: #x8d99aa + draw_text.text_style.font_size: 7.5 } mute := MutePlate{} name_plate := NamePlate{} @@ -487,7 +437,7 @@ View{ flow: Down show_bg: true new_batch: true - draw_bg.color: mp.bg_dark + draw_bg.color: #x080a0e surface_note := Label{ visible: false @@ -495,8 +445,8 @@ View{ height: Fit align: Align{x: 0.5} text: "" - draw_text.color: mp.fg_dim - draw_text.text_style: theme.font_code{font_size: 8.0} + draw_text.color: #x77828f + draw_text.text_style.font_size: 8.0 } strip_row := View{ width: Fill diff --git a/apps/mixer/src/main.rs b/apps/mixer/src/main.rs index 2e9ec536d..b4c64848f 100644 --- a/apps/mixer/src/main.rs +++ b/apps/mixer/src/main.rs @@ -38,9 +38,7 @@ use std::net::SocketAddr; use std::sync::Arc; mod surface; -mod theme; use surface::SurfaceBinder; -use theme::Palette; app_main!(App); @@ -79,18 +77,11 @@ script_mod! { main_window := Window{ window.title: "Mixer" window.inner_size: vec2(1268, 716) - // The desk is flat and dark: the pass clears to the theme's - // own background, not the stock neutral. `theme.rs` also sets - // `color_bg_app`/`color_app_caption_bar` from the same key, - // but the clear colour is stated here so a window that never - // gets a retint still comes up in the theme. - pass.clear_color: mod.mpm.bg_dark + pass.clear_color: #x07090d body +: { width: Fill height: Fill flow: Down - show_bg: true - draw_bg.color: mod.mpm.bg_dark searching := View{ width: Fill @@ -101,20 +92,17 @@ script_mod! { Label{ text: "Searching for your mixer" - draw_text.color: mod.mpm.fg_bright - draw_text.text_style: theme.font_code{font_size: 15.0} + draw_text.color: #xe8eef6 + draw_text.text_style: theme.font_bold{font_size: 17.0} } search_note := Label{ text: "listening for a console on the local network" - draw_text.color: mod.mpm.fg_dim - draw_text.text_style: theme.font_code{font_size: 9.0} + draw_text.color: #x8d99aa + draw_text.text_style.font_size: 9.0 } LoadingSpinner{ width: 34 height: 34 - draw_bg +: { - color: uniform(mod.mpm.accent) - } } } @@ -166,15 +154,7 @@ impl App { fn load_layout(&mut self, cx: &mut Cx) { let (_label, file) = LAYOUTS[self.layout_idx]; - // A Splash body runs in its own isolate VM with a FRESH stock - // `mod.theme`, so the desktop palette cannot reach a layout through - // the theme — the host hands it over as one prepended line binding - // `mp` (see theme.rs and the layout headers). - let body = format!( - "{}{}", - Palette::shared().splash_preamble(), - load_layout_body(file) - ); + let body = load_layout_body(file); let splash = self.splash_ref(cx); splash.set_text(cx, &body); self.binder.rebind(cx, &splash); @@ -376,8 +356,6 @@ impl MatchEvent for App { impl AppMain for App { fn script_mod(vm: &mut ScriptVm) -> ScriptValue { crate::makepad_widgets::script_mod(vm); - mp_theme::apply(vm); - Palette::shared().install(vm); self::script_mod(vm) } diff --git a/apps/mixer/src/surface.rs b/apps/mixer/src/surface.rs index 4ee852162..be25bd60e 100644 --- a/apps/mixer/src/surface.rs +++ b/apps/mixer/src/surface.rs @@ -256,14 +256,7 @@ impl SurfaceBinder { let rgb = scribble_rgb(idx); set_view_instance(cx, &slot.name_plate, "plate_rgb", &rgb); set_view_instance(cx, &slot.name_plate, "plate_filled", &[filled]); - // A filled plate carries the console's own scribble colour, so - // its text is the theme's deepest background — the one colour - // guaranteed to read on every scribble hue. - let text_rgb = if filled > 0.5 { - crate::theme::Palette::shared().rgb3("bg_dark") - } else { - rgb - }; + let text_rgb = if filled > 0.5 { [0.04, 0.05, 0.07] } else { rgb }; set_label_color(cx, &slot.name_lbl, text_rgb); } diff --git a/apps/mixer/src/theme.rs b/apps/mixer/src/theme.rs deleted file mode 100644 index 51b2fdece..000000000 --- a/apps/mixer/src/theme.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! The desk's palette. mpwm exports its active `theme.splash` as -//! MPWM_THEME_SPLASH; `mp_theme` line-scans it and retints the *stock* -//! widgets, and this module carries the same colours to the three places the -//! mixer paints itself: -//! -//! * `mod.mpm.*` — read by `main.rs`'s own `script_mod!` (the window and -//! the search page); -//! * a one-line `let mp = {...}` preamble prepended to every surface layout -//! — a `Splash` body runs in its OWN isolate VM, which registers a FRESH -//! stock `mod.theme` and never sees `mod.mpm`, so a layout has to be -//! handed its colours (see [`Palette::splash_preamble`]); -//! * [`Palette::rgb3`], for the one colour `surface.rs` sets from Rust. -//! -//! It also applies the stock retint with these same values, because -//! `mp_theme::apply` is a no-op when the WM is not running and that would -//! otherwise leave the caption bar in the neutral stock theme above a black -//! desk. Standalone runs get Tokyo Night, so the surface is dark and square -//! either way. -//! -//! SAFETY: colours only. Nothing here can name an OSC address — see -//! `makepad_mixer::safety`. - -use makepad_widgets::*; -use std::sync::OnceLock; - -/// A palette entry: the name the DSL reads it by, the key in the WM's -/// theme.splash, and the Tokyo Night fallback. -/// -/// The meter and lamp hues live in the theme's terminal block — omarchy's -/// base16 mapping is colour1 = red, 2 = green, 3 = yellow, 6 = cyan — because -/// a desktop theme has no "signal is clipping" role of its own. -const KEYS: &[(&str, &str, &str)] = &[ - ("bg", "background", "#1a1b26"), - ("bg_dark", "darker_background", "#0e0e14"), - ("bg_light", "lighter_background", "#24283b"), - ("fg", "foreground", "#a9b1d6"), - ("fg_bright", "bright_foreground", "#c0caf5"), - ("fg_dim", "dark_foreground", "#565f89"), - ("muted", "muted", "#414868"), - ("accent", "accent", "#7aa2f7"), - ("red", "term.color1", "#f7768e"), - ("green", "term.color2", "#9ece6a"), - ("yellow", "term.color3", "#e0af68"), - ("cyan", "term.color6", "#449dab"), -]; - -/// Every colour the mixer paints with, as `#rrggbb` strings. -#[derive(Clone, Debug)] -pub struct Palette { - /// `name` -> `#rrggbb`, in [`KEYS`] order. - entries: Vec<(&'static str, String)>, -} - -impl Palette { - /// The palette for this process, read once. - pub fn shared() -> &'static Palette { - static PALETTE: OnceLock = OnceLock::new(); - PALETTE.get_or_init(Palette::load) - } - - /// The palette mpwm exported for this process, with Tokyo Night standing - /// in for anything it does not name. - pub fn load() -> Self { - let wm = mp_theme::current(); - Palette { - entries: KEYS - .iter() - .map(|(name, key, fallback)| { - let hex = match &wm { - Some(p) => p.hex(key, fallback), - None => fallback.to_string(), - }; - (*name, hex) - }) - .collect(), - } - } - - /// One colour by its DSL name. Unknown names read magenta rather than - /// silently theme-shaped black. - pub fn get(&self, name: &str) -> &str { - self.entries - .iter() - .find(|(n, _)| *n == name) - .map(|(_, v)| v.as_str()) - .unwrap_or("#ff00ff") - } - - /// One colour as linear-ish rgb components, for the handful of places - /// Rust sets a colour directly. - pub fn rgb3(&self, name: &str) -> [f32; 3] { - let hex = self.get(name).trim_start_matches('#'); - let nib = |i: usize| -> f32 { - match hex.as_bytes().get(i).copied().unwrap_or(b'0') { - c @ b'0'..=b'9' => (c - b'0') as f32, - c @ b'a'..=b'f' => (c - b'a' + 10) as f32, - c @ b'A'..=b'F' => (c - b'A' + 10) as f32, - _ => 0.0, - } - }; - let byte = |i: usize| (nib(i * 2) * 16.0 + nib(i * 2 + 1)) / 255.0; - [byte(0), byte(1), byte(2)] - } - - /// The palette as ONE line of splash source, to be prepended to a layout - /// body before it is handed to the `Splash` widget. One line so a script - /// error's reported line is the layout's own line plus exactly one. - pub fn splash_preamble(&self) -> String { - let body: Vec = self - .entries - .iter() - .map(|(name, hex)| format!("{name}: {hex}")) - .collect(); - format!("let mp = {{{}}}\n", body.join(", ")) - } - - /// Publish `mod.mpm.*` for the app's own `script_mod!`, and retint the - /// stock widgets (the caption bar, the spinner) with the same palette. - /// Call once, after `makepad_widgets::script_mod` and `mp_theme::apply`, - /// and before this crate's own `script_mod`. - pub fn install(&self, vm: &mut ScriptVm) { - let mut code = String::from("mod.mpm = {\n"); - for (name, hex) in &self.entries { - code.push_str(&format!(" {name}: {hex}\n")); - } - code.push_str("}\n"); - - // `mp_theme::apply` only retints when the WM exported a palette; - // standalone that leaves the window chrome in the stock theme, which - // reads as a grey band above a Tokyo Night desk. Same keys, our - // fallbacks. - let c = |name: &str| self.get(name).to_string(); - code.push_str(&format!( - "mod.theme.color_b = {bg_dark}\n\ - mod.theme.color_b_h = {bg_dark}00\n\ - mod.theme.color_w = {fg_bright}\n\ - mod.theme.color_w_h = {fg_bright}00\n\ - mod.theme.color_bg_app = {bg}\n\ - mod.theme.color_fg_app = {bg_light}\n\ - mod.theme.color_bg_container = {bg_dark}\n\ - mod.theme.color_text = {fg}\n\ - mod.theme.color_text_hover = {fg_bright}\n\ - mod.theme.color_text_muted = {fg_dim}\n\ - mod.theme.color_focus = {accent}\n\ - mod.theme.color_outset_active = {accent}\n\ - mod.theme.color_ctrl_default = {bg_light}\n\ - mod.theme.color_ctrl_hover = {muted}\n\ - mod.theme.color_ctrl_active = {accent}\n\ - mod.theme.color_ctrl_selected = {accent}\n\ - mod.theme.color_app_caption_bar = {bg_dark}\n\ - mod.theme.corner_radius = 0.0\n\ - true\n", - bg = c("bg"), - bg_dark = c("bg_dark"), - bg_light = c("bg_light"), - fg = c("fg"), - fg_bright = c("fg_bright"), - fg_dim = c("fg_dim"), - muted = c("muted"), - accent = c("accent"), - )); - - vm.eval(ScriptMod { - cargo_manifest_path: env!("CARGO_MANIFEST_DIR").to_string(), - module_path: "mixer_theme".to_string(), - file: "mixer_theme.splash".to_string(), - line: 0, - column: 0, - code, - values: vec![], - }); - for e in vm.take_errors() { - log!("mixer theme: {}", e); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn standalone_falls_back_to_tokyo_night() { - let p = Palette::load(); - // The WM is not running under `cargo test`, so every key is a - // fallback — and the fallbacks ARE mpwm's bundled tokyo-night. - assert_eq!(p.get("bg"), "#1a1b26"); - assert_eq!(p.get("bg_dark"), "#0e0e14"); - assert_eq!(p.get("accent"), "#7aa2f7"); - assert_eq!(p.get("red"), "#f7768e"); - assert_eq!(p.get("green"), "#9ece6a"); - assert_eq!(p.get("yellow"), "#e0af68"); - assert_eq!(p.get("cyan"), "#449dab"); - // Nothing reads as neutral grey: every channel pair differs. - for (name, hex) in &p.entries { - let [r, g, b] = p.rgb3(name); - assert!( - (r - g).abs() > 0.001 || (g - b).abs() > 0.001, - "{name} = {hex} is a neutral grey" - ); - } - } - - #[test] - fn a_wm_palette_wins_over_every_fallback() { - // Both the top-level roles and the terminal hues come from the WM's - // own theme.splash when it is running. - let src = "mod.mpwm_theme = {\n accent: #ff8800\n background: #101010\n term: {\n color1: #123456\n }\n}\n"; - let wm = mp_theme::scan(src); - assert_eq!(wm.hex("accent", "#7aa2f7"), "#ff8800"); - assert_eq!(wm.hex("term.color1", "#f7768e"), "#123456"); - // ...and a key the theme omits still resolves. - assert_eq!(wm.hex("term.color6", "#449dab"), "#449dab"); - } - - #[test] - fn the_layout_preamble_is_exactly_one_line() { - let p = Palette::load(); - let pre = p.splash_preamble(); - assert_eq!(pre.lines().count(), 1, "preamble must stay one line: {pre}"); - assert!(pre.ends_with('\n')); - assert!(pre.starts_with("let mp = {")); - // Every colour a layout can ask for is bound. - for (name, hex) in &p.entries { - assert!(pre.contains(&format!("{name}: {hex}")), "missing {name}"); - } - } - - #[test] - fn parses_hex() { - let p = Palette::load(); - let [r, g, b] = p.rgb3("bg_dark"); // #0e0e14 - assert!((r - 14.0 / 255.0).abs() < 1e-6); - assert!((g - 14.0 / 255.0).abs() < 1e-6); - assert!((b - 20.0 / 255.0).abs() < 1e-6); - assert_eq!(p.get("nope"), "#ff00ff"); - } -} diff --git a/apps/mpbrowser/Cargo.toml b/apps/mpbrowser/Cargo.toml deleted file mode 100644 index 3b19c63c2..000000000 --- a/apps/mpbrowser/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -# mpbrowser — a Chrome-like browser as a plain full-window Makepad app. -# -# Chromium (CEF, libs/cef) renders ONLY the page, GPU-accelerated straight -# into an IOSurface-backed Makepad texture. All browser chrome — the tab -# strip, the toolbar with back/forward/reload, the omnibox and the menu — -# is Makepad splash UI, styled from the mpwm theme when hosted by -# makepad-wm (`MPWM_THEME_SPLASH`) and from a Chrome-dark palette otherwise. -# -# Runs standalone, and unmodified inside makepad-wm / Studio tiles via the -# shared --stdin-loop client runtime every Makepad app has. - -[package] -name = "mpbrowser" -version = "0.1.0" -edition = "2021" -default-run = "mpbrowser" - -[[bin]] -name = "mpbrowser" -path = "src/main.rs" - -[dependencies] -makepad-widgets = { path = "../../widgets", features = ["cef"] } -makepad-cef = { path = "../../libs/cef" } -mp-theme = { path = "../../libs/mp_theme" } diff --git a/apps/mpbrowser/resources/icons/back.svg b/apps/mpbrowser/resources/icons/back.svg deleted file mode 100644 index e185208d6..000000000 --- a/apps/mpbrowser/resources/icons/back.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/mpbrowser/resources/icons/close.svg b/apps/mpbrowser/resources/icons/close.svg deleted file mode 100644 index cdcf15d9d..000000000 --- a/apps/mpbrowser/resources/icons/close.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/mpbrowser/resources/icons/forward.svg b/apps/mpbrowser/resources/icons/forward.svg deleted file mode 100644 index 75660f353..000000000 --- a/apps/mpbrowser/resources/icons/forward.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/mpbrowser/resources/icons/globe.svg b/apps/mpbrowser/resources/icons/globe.svg deleted file mode 100644 index d78530043..000000000 --- a/apps/mpbrowser/resources/icons/globe.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/apps/mpbrowser/resources/icons/menu.svg b/apps/mpbrowser/resources/icons/menu.svg deleted file mode 100644 index 9af124140..000000000 --- a/apps/mpbrowser/resources/icons/menu.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/apps/mpbrowser/resources/icons/plus.svg b/apps/mpbrowser/resources/icons/plus.svg deleted file mode 100644 index db29bdbdd..000000000 --- a/apps/mpbrowser/resources/icons/plus.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/mpbrowser/resources/icons/reload.svg b/apps/mpbrowser/resources/icons/reload.svg deleted file mode 100644 index 000dcad61..000000000 --- a/apps/mpbrowser/resources/icons/reload.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/mpbrowser/resources/icons/search.svg b/apps/mpbrowser/resources/icons/search.svg deleted file mode 100644 index 21bf86f54..000000000 --- a/apps/mpbrowser/resources/icons/search.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/mpbrowser/resources/icons/star.svg b/apps/mpbrowser/resources/icons/star.svg deleted file mode 100644 index b2bf6d58d..000000000 --- a/apps/mpbrowser/resources/icons/star.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/mpbrowser/src/chrome.rs b/apps/mpbrowser/src/chrome.rs deleted file mode 100644 index b457defc0..000000000 --- a/apps/mpbrowser/src/chrome.rs +++ /dev/null @@ -1,512 +0,0 @@ -//! The browser chrome: a custom-drawn Chrome-style tab strip (favicon + -//! title tabs with a close x, a + for a new tab) and the toolbar -//! (back / forward / reload, the omnibox with search icon and bookmark star, -//! the menu button). Hard-square Omarchy look, colours from `mod.mpb_theme`. - -use crate::tabs::{TabId, TabSummary}; -use makepad_widgets::image::DrawImage; -use makepad_widgets::*; - -script_mod! { - use mod.prelude.widgets_internal.* - use mod.widgets.* - - mod.widgets.TabStripBase = #(TabStrip::register_widget(vm)) - - mod.widgets.TabStrip = set_type_default() do mod.widgets.TabStripBase{ - width: Fill - height: 36 - - draw_bg +: { - color: uniform(mod.mpb_theme.darker_background) - pixel: fn() { - return self.color - } - } - draw_tab +: { - color: uniform(mod.mpb_theme.darker_background) - pixel: fn() { - return self.color - } - } - draw_tab_hover +: { - color: uniform(mod.mpb_theme.dark_background) - pixel: fn() { - return self.color - } - } - draw_tab_active +: { - color: uniform(mod.mpb_theme.background) - pixel: fn() { - return self.color - } - } - draw_sep +: { - color: uniform(mod.mpb_theme.muted) - pixel: fn() { - return self.color - } - } - draw_text +: { - color: mod.mpb_theme.foreground - text_style: theme.font_regular{ - font_size: 9.5 - } - } - draw_text_dim +: { - color: mod.mpb_theme.dark_foreground - text_style: theme.font_regular{ - font_size: 9.5 - } - } - draw_close +: { - svg: crate_resource("self:resources/icons/close.svg") - color: mod.mpb_theme.dark_foreground - } - draw_close_active +: { - svg: crate_resource("self:resources/icons/close.svg") - color: mod.mpb_theme.foreground - } - draw_plus +: { - svg: crate_resource("self:resources/icons/plus.svg") - color: mod.mpb_theme.foreground - } - draw_globe +: { - svg: crate_resource("self:resources/icons/globe.svg") - color: mod.mpb_theme.dark_foreground - } - } - - // A square, flat icon button for the toolbar. A `let` so this block can - // instantiate it below (a `use mod.widgets.*` glob is a snapshot). - let MpToolButton = ButtonFlatterIcon{ - width: 32 - height: 32 - padding: Inset{left: 0 right: 0 top: 0 bottom: 0} - margin: Inset{left: 0 right: 0 top: 0 bottom: 0} - align: Align{x: 0.5 y: 0.5} - icon_walk: Walk{width: 16 height: 16} - draw_icon +: { - color: mod.mpb_theme.foreground - } - draw_bg +: { - border_radius: 0.0 - border_size: 0.0 - color: #00000000 - color_hover: mod.mpb_theme.lighter_background - color_down: mod.mpb_theme.muted - color_focus: #00000000 - } - } - - mod.widgets.MpToolButton = MpToolButton - - // A row of the ⋮ menu. - mod.widgets.MpMenuItem = ButtonFlatter{ - width: Fill - height: 30 - align: Align{x: 0.0 y: 0.5} - padding: Inset{left: 14 right: 14 top: 0 bottom: 0} - margin: Inset{left: 0 right: 0 top: 0 bottom: 0} - draw_text +: { - color: mod.mpb_theme.foreground - color_hover: mod.mpb_theme.bright_foreground - text_style: theme.font_regular{ - font_size: 10 - } - } - draw_bg +: { - border_radius: 0.0 - border_size: 0.0 - color: #00000000 - color_hover: mod.mpb_theme.lighter_background - color_down: mod.mpb_theme.muted - } - } - - // No `align y: 0.5` anywhere on the omnibox's ancestry: Makepad applies - // such alignment as a deferred shift that moves walked content (text) - // but not `draw_abs` quads — the TextInput's caret and selection would - // end up above the field and clipped. Heights and paddings centre - // everything explicitly instead. - mod.widgets.MpToolbar = SolidView{ - width: Fill - height: 40 - flow: Right - align: Align{x: 0.0 y: 0.0} - padding: Inset{left: 6 right: 6 top: 4 bottom: 4} - spacing: 2 - draw_bg +: { - color: mod.mpb_theme.background - } - - back_btn := MpToolButton{ - draw_icon.svg: crate_resource("self:resources/icons/back.svg") - } - forward_btn := MpToolButton{ - draw_icon.svg: crate_resource("self:resources/icons/forward.svg") - } - reload_btn := MpToolButton{ - draw_icon.svg: crate_resource("self:resources/icons/reload.svg") - } - - View{width: 4 height: Fit} - - // The omnibox: a darker square well with the search glyph, the - // text field and the bookmark star. - omnibox_frame := SolidView{ - width: Fill - height: 32 - flow: Right - align: Align{x: 0.0 y: 0.0} - padding: Inset{left: 10 right: 2 top: 0 bottom: 0} - spacing: 6 - draw_bg +: { - color: mod.mpb_theme.darker_background - } - Icon{ - margin: Inset{top: 9 bottom: 0 left: 0 right: 0} - icon_walk: Walk{width: 14 height: 14} - draw_icon +: { - svg: crate_resource("self:resources/icons/search.svg") - color: mod.mpb_theme.dark_foreground - } - } - omnibox := TextInputFlat{ - width: Fill - height: 32 - empty_text: "Search Google or type a URL" - // The line box is 16.8pt for this font size; text reads as - // centred by its x-height, not its full ink box, so 1pt - // less on top puts the x-height middle on the field's - // middle, level with the search glyph and the star. - padding: Inset{left: 4 right: 4 top: 6.6 bottom: 8.6} - margin: Inset{left: 0 right: 0 top: 0 bottom: 0} - draw_bg +: { - border_radius: 0.0 - border_size: 0.0 - color: #00000000 - color_hover: #00000000 - color_focus: #00000000 - color_down: #00000000 - color_empty: #00000000 - border_color: #00000000 - border_color_hover: #00000000 - border_color_focus: #00000000 - border_color_down: #00000000 - border_color_empty: #00000000 - } - draw_cursor +: { - color: mod.mpb_theme.bright_foreground - } - draw_text +: { - color: mod.mpb_theme.foreground - color_hover: mod.mpb_theme.foreground - color_focus: mod.mpb_theme.bright_foreground - color_empty: mod.mpb_theme.dark_foreground - color_empty_hover: mod.mpb_theme.dark_foreground - text_style: theme.font_regular{ - font_size: 10.5 - } - } - } - star_btn := MpToolButton{ - width: 28 - height: 28 - margin: Inset{top: 2 bottom: 0 left: 0 right: 0} - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self:resources/icons/star.svg") - color: mod.mpb_theme.dark_foreground - } - } - } - - View{width: 4 height: Fit} - - menu_btn := MpToolButton{ - draw_icon.svg: crate_resource("self:resources/icons/menu.svg") - } - } -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub enum TabStripAction { - #[default] - None, - Activate(TabId), - Close(TabId), - New, -} - -#[derive(Clone, Copy, Debug)] -struct TabHit { - id: TabId, - rect: Rect, - close: Rect, -} - -#[derive(Script, ScriptHook, Widget)] -pub struct TabStrip { - #[uid] - uid: WidgetUid, - #[source] - source: ScriptObjectRef, - #[walk] - walk: Walk, - #[layout] - layout: Layout, - #[redraw] - #[live] - draw_bg: DrawQuad, - #[live] - draw_tab: DrawQuad, - #[live] - draw_tab_hover: DrawQuad, - #[live] - draw_tab_active: DrawQuad, - #[live] - draw_sep: DrawQuad, - #[live] - draw_text: DrawText, - #[live] - draw_text_dim: DrawText, - #[live] - draw_close: DrawSvg, - #[live] - draw_close_active: DrawSvg, - #[live] - draw_plus: DrawSvg, - #[live] - draw_globe: DrawSvg, - #[live] - draw_favicon: DrawImage, - #[rust] - tabs: Vec, - #[rust] - hits: Vec, - #[rust] - plus_rect: Rect, - #[rust] - hover_tab: Option, - #[rust] - hover_close: bool, - #[rust] - hover_plus: bool, -} - -impl TabStrip { - const TAB_MAX_WIDTH: f64 = 240.0; - const TAB_MIN_WIDTH: f64 = 56.0; - const TOP_GAP: f64 = 6.0; - const LEFT_PAD: f64 = 8.0; - const PLUS_SIZE: f64 = 28.0; - - pub fn set_tabs(&mut self, cx: &mut Cx, tabs: Vec) { - self.tabs = tabs; - self.redraw(cx); - } - - fn hit_at(&self, pos: Vec2d) -> (Option, bool, bool) { - if self.plus_rect.contains(pos) { - return (None, false, true); - } - for hit in &self.hits { - if hit.rect.contains(pos) { - return (Some(hit.id), hit.close.contains(pos), false); - } - } - (None, false, false) - } - - fn update_hover(&mut self, cx: &mut Cx, pos: Option) { - let (tab, close, plus) = pos.map(|p| self.hit_at(p)).unwrap_or((None, false, false)); - if tab != self.hover_tab || close != self.hover_close || plus != self.hover_plus { - self.hover_tab = tab; - self.hover_close = close; - self.hover_plus = plus; - self.redraw(cx); - } - } -} - -impl Widget for TabStrip { - fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) { - match event.hits(cx, self.draw_bg.area()) { - Hit::FingerDown(fe) => { - let (tab, close, plus) = self.hit_at(fe.abs); - let middle = fe.mouse_button().map(|b| b.is_middle()).unwrap_or(false); - if plus { - cx.widget_action(self.uid, TabStripAction::New); - } else if let Some(id) = tab { - if close || middle { - cx.widget_action(self.uid, TabStripAction::Close(id)); - } else { - cx.widget_action(self.uid, TabStripAction::Activate(id)); - } - } - } - Hit::FingerHoverIn(fe) | Hit::FingerHoverOver(fe) => { - self.update_hover(cx, Some(fe.abs)); - } - Hit::FingerMove(fe) => { - self.update_hover(cx, Some(fe.abs)); - } - Hit::FingerHoverOut(_) => { - self.update_hover(cx, None); - } - _ => {} - } - } - - fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { - self.draw_bg.begin(cx, walk, self.layout); - let strip = cx.turtle().rect(); - self.hits.clear(); - - let count = self.tabs.len().max(1) as f64; - let available = (strip.size.x - Self::LEFT_PAD - Self::PLUS_SIZE - 12.0).max(0.0); - let tab_width = (available / count) - .min(Self::TAB_MAX_WIDTH) - .max(Self::TAB_MIN_WIDTH.min(available.max(1.0))); - let tab_height = strip.size.y - Self::TOP_GAP; - let mut x = strip.pos.x + Self::LEFT_PAD; - let y = strip.pos.y + Self::TOP_GAP; - - let tabs = self.tabs.clone(); - for (i, tab) in tabs.iter().enumerate() { - let rect = Rect { - pos: dvec2(x, y), - size: dvec2(tab_width, tab_height), - }; - let hovered = self.hover_tab == Some(tab.id); - if tab.active { - self.draw_tab_active.draw_abs(cx, rect); - } else if hovered { - self.draw_tab_hover.draw_abs(cx, rect); - } else { - self.draw_tab.draw_abs(cx, rect); - // Separator between neighbouring inactive tabs. - let next_active = tabs.get(i + 1).map(|t| t.active).unwrap_or(true); - let next_hovered = tabs - .get(i + 1) - .map(|t| self.hover_tab == Some(t.id)) - .unwrap_or(false); - if !next_active && !next_hovered { - self.draw_sep.draw_abs( - cx, - Rect { - pos: dvec2(x + tab_width - 0.5, y + 8.0), - size: dvec2(1.0, tab_height - 16.0), - }, - ); - } - } - - let wide = tab_width >= 96.0; - let show_close = tab.active || hovered || wide; - let icon_size = 16.0; - let icon_y = y + (tab_height - icon_size) * 0.5; - let mut text_x = x + 10.0; - if tab_width >= 72.0 { - let icon_rect = Rect { - pos: dvec2(x + 10.0, icon_y), - size: dvec2(icon_size, icon_size), - }; - match &tab.favicon { - Some(favicon) => { - self.draw_favicon.draw_vars.set_texture(0, favicon); - self.draw_favicon.draw_abs(cx, icon_rect); - } - None => { - self.draw_globe.draw_abs(cx, icon_rect); - } - } - text_x += icon_size + 8.0; - } - - let close_size = 16.0; - let close_rect = Rect { - pos: dvec2( - x + tab_width - close_size - 8.0, - y + (tab_height - close_size) * 0.5, - ), - size: dvec2(close_size, close_size), - }; - let text_right = if show_close { - close_rect.pos.x - 6.0 - } else { - x + tab_width - 8.0 - }; - let text_width = (text_right - text_x).max(0.0); - if text_width > 4.0 { - let title = if tab.loading && tab.title.is_empty() { - "Loading…".to_string() - } else { - tab.title.clone() - }; - cx.begin_turtle( - Walk { - abs_pos: Some(dvec2(text_x, y)), - width: Size::Fixed(text_width), - height: Size::Fixed(tab_height), - ..Walk::default() - }, - Layout { - clip_x: true, - clip_y: true, - align: Align { x: 0.0, y: 0.5 }, - ..Layout::default() - }, - ); - if tab.active { - self.draw_text - .draw_walk(cx, Walk::fit(), Align::default(), &title); - } else { - self.draw_text_dim - .draw_walk(cx, Walk::fit(), Align::default(), &title); - } - cx.end_turtle(); - } - - if show_close { - let glyph = Rect { - pos: close_rect.pos + dvec2(3.0, 3.0), - size: dvec2(close_size - 6.0, close_size - 6.0), - }; - if tab.active || (hovered && self.hover_close) { - self.draw_close_active.draw_abs(cx, glyph); - } else { - self.draw_close.draw_abs(cx, glyph); - } - } - - self.hits.push(TabHit { - id: tab.id, - rect, - close: if show_close { close_rect } else { Rect::default() }, - }); - x += tab_width; - } - - // The new-tab "+" square. - let plus_rect = Rect { - pos: dvec2(x + 4.0, y + (tab_height - Self::PLUS_SIZE) * 0.5), - size: dvec2(Self::PLUS_SIZE, Self::PLUS_SIZE), - }; - if self.hover_plus { - self.draw_tab_hover.draw_abs(cx, plus_rect); - } - self.draw_plus.draw_abs( - cx, - Rect { - pos: plus_rect.pos + dvec2(7.0, 7.0), - size: dvec2(Self::PLUS_SIZE - 14.0, Self::PLUS_SIZE - 14.0), - }, - ); - self.plus_rect = plus_rect; - - self.draw_bg.end(cx); - DrawStep::done() - } -} diff --git a/apps/mpbrowser/src/main.rs b/apps/mpbrowser/src/main.rs deleted file mode 100644 index 1b587687f..000000000 --- a/apps/mpbrowser/src/main.rs +++ /dev/null @@ -1,626 +0,0 @@ -//! mpbrowser: a Chrome-like browser as a plain full-window Makepad app. -//! CEF renders the page (GPU-accelerated into a shared IOSurface texture); -//! every bit of chrome is Makepad. Runs standalone or inside makepad-wm / -//! Studio tiles via the shared --stdin-loop client runtime. - -pub use makepad_widgets; -use makepad_cef::BootstrapResult; -use makepad_widgets::*; - -mod chrome; -mod tabs; -mod theme; -mod webview; - -use chrome::{TabStrip, TabStripAction}; -use tabs::TabId; -use std::cell::RefCell; -use std::rc::Rc; -use theme::Palette; -use webview::{WebView, WebViewAction}; - -script_mod! { - use mod.prelude.widgets.* - use mod.widgets.* - - load_all_resources() do #(App::script_component(vm)){ - ui: Root{ - main_window := Window{ - window.inner_size: vec2(1280, 860) - window.title: "mpbrowser" - body +: { - flow: Overlay - View{ - width: Fill - height: Fill - flow: Down - tab_strip := TabStrip{} - toolbar := MpToolbar{} - webview := WebView{} - } - // The ⋮ menu: a square panel under the button. - menu_layer := View{ - width: Fill - height: Fill - flow: Right - align: Align{x: 1.0 y: 0.0} - padding: Inset{top: 78 right: 6 left: 0 bottom: 0} - menu := SolidView{ - visible: false - width: 240 - height: Fit - flow: Down - padding: Inset{top: 4 bottom: 4 left: 0 right: 0} - draw_bg +: { - color: mod.mpb_theme.background - } - menu_new_tab := MpMenuItem{text: "New tab"} - menu_close_tab := MpMenuItem{text: "Close tab"} - menu_reload := MpMenuItem{text: "Reload"} - Hr{} - menu_gpu := MpMenuItem{text: "chrome://gpu"} - menu_about := MpMenuItem{text: "About mpbrowser"} - } - } - } - } - } - } -} - -thread_local! { - static PALETTE: RefCell> = const { RefCell::new(None) }; -} - -static START: std::sync::OnceLock = std::sync::OnceLock::new(); - -/// Milliseconds since `main` started (startup measurements). -pub fn uptime_ms() -> u128 { - START.get_or_init(std::time::Instant::now).elapsed().as_millis() -} - -/// Microseconds since `main` started (resize tracing). -pub fn uptime_us() -> u128 { - START.get_or_init(std::time::Instant::now).elapsed().as_micros() -} - -fn palette() -> Palette { - PALETTE.with(|p| { - p.borrow_mut() - .get_or_insert_with(Palette::current) - .clone() - }) -} - -/// Cmd shortcuts the browser chrome owns (never forwarded to the page). -pub fn is_app_shortcut(key_event: &KeyEvent) -> bool { - if !key_event.modifiers.logo { - return false; - } - matches!( - key_event.key_code, - KeyCode::KeyT - | KeyCode::KeyW - | KeyCode::KeyL - | KeyCode::KeyR - | KeyCode::KeyN - | KeyCode::LBracket - | KeyCode::RBracket - | KeyCode::Key1 - | KeyCode::Key2 - | KeyCode::Key3 - | KeyCode::Key4 - | KeyCode::Key5 - | KeyCode::Key6 - | KeyCode::Key7 - | KeyCode::Key8 - | KeyCode::Key9 - ) -} - -/// URLs given on the command line (everything that is not a flag). -fn initial_urls() -> Vec { - let mut urls = Vec::new(); - let mut args = std::env::args().skip(1); - while let Some(arg) = args.next() { - if arg == "--cwd" || arg == "--message-format" { - let _ = args.next(); - continue; - } - if arg.starts_with("--") { - continue; - } - urls.push(tabs::resolve_omnibox(&arg)); - } - urls -} - -#[derive(Script, ScriptHook)] -pub struct App { - #[live] - ui: WidgetRef, - #[rust] - omnibox_focused: bool, - #[rust] - shown_url: String, - #[rust] - shown_tab: Option, - #[rust] - menu_open: bool, - #[rust] - reported_mode: String, - #[rust] - focus_omnibox_pending: bool, - #[rust] - focus_frame: NextFrame, - #[rust] - focus_retries: u32, -} - - -impl App { - fn with_webview(&self, cx: &mut Cx, f: impl FnOnce(&mut Cx, &mut WebView) -> R) -> Option { - let webview = self.ui.widget(cx, ids!(webview)); - let mut inner = webview.borrow_mut::()?; - Some(f(cx, &mut inner)) - } - - fn new_tab(&mut self, cx: &mut Cx, url: Option) { - let url = url.unwrap_or_else(|| palette().new_tab_url()); - let is_ntp = theme::is_new_tab_url(&url); - self.with_webview(cx, |cx, wv| { - wv.new_tab(cx, &url, true); - }); - self.refresh_chrome(cx); - if is_ntp { - self.focus_omnibox(cx); - } - } - - fn close_active_tab(&mut self, cx: &mut Cx) { - let closed_last = self - .with_webview(cx, |cx, wv| { - if let Some(id) = wv.active_id() { - wv.close_tab(cx, id); - } - wv.tab_count() == 0 - }) - .unwrap_or(false); - if closed_last { - self.new_tab(cx, None); - } else { - self.refresh_chrome(cx); - } - } - - fn focus_omnibox(&mut self, cx: &mut Cx) { - let omnibox = self.ui.text_input(cx, ids!(omnibox)); - omnibox.set_key_focus(cx); - omnibox.borrow_mut().map(|mut inner| inner.select_all(cx)); - // A focus set while a mouse click is still being dispatched (the + - // button, a tab close) does not survive the rest of that click; the - // key-path (Cmd+L) proves the focus itself works. Re-assert it on the - // next frame. - self.focus_omnibox_pending = true; - self.focus_frame = cx.new_next_frame(); - } - - fn set_menu_open(&mut self, cx: &mut Cx, open: bool) { - if self.menu_open == open { - return; - } - self.menu_open = open; - self.ui.view(cx, ids!(menu)).set_visible(cx, open); - self.ui.redraw(cx); - } - - /// Push the model into the chrome: tab strip, omnibox text (unless the - /// user is typing in it), nav button states, window title. - fn refresh_chrome(&mut self, cx: &mut Cx) { - let (summaries, info) = self - .with_webview(cx, |_cx, wv| (wv.summaries(), wv.active_info())) - .unwrap_or_default(); - - if let Some(mut strip) = self.ui.widget(cx, ids!(tab_strip)).borrow_mut::() { - strip.set_tabs(cx, summaries); - } - - let shown = if theme::is_new_tab_url(&info.url) { - String::new() - } else { - info.url.clone() - }; - // The omnibox follows the page unless the user is typing in it — but - // switching tabs always replaces what it shows. - let tab_changed = info.id != self.shown_tab; - self.shown_tab = info.id; - if shown != self.shown_url || tab_changed { - self.shown_url = shown.clone(); - if !self.omnibox_focused || tab_changed { - self.ui.text_input(cx, ids!(omnibox)).set_text(cx, &shown); - } - } - - let palette = palette(); - let on = theme::parse_hex(&palette.foreground).unwrap_or_default(); - let off = theme::parse_hex(&palette.muted).unwrap_or_default(); - let mut back = self.ui.button(cx, ids!(back_btn)); - let back_color = if info.can_go_back { on } else { off }; - script_apply_eval!(cx, back, { - draw_icon +: { - color: #(back_color) - } - }); - let mut forward = self.ui.button(cx, ids!(forward_btn)); - let forward_color = if info.can_go_forward { on } else { off }; - script_apply_eval!(cx, forward, { - draw_icon +: { - color: #(forward_color) - } - }); - - let title = if info.title.is_empty() { - "mpbrowser".to_string() - } else { - format!("{} — mpbrowser", info.title) - }; - self.ui.window(cx, ids!(main_window)).set_title(cx, &title); - - if info.render_mode != self.reported_mode && info.render_mode != "None" { - self.reported_mode = info.render_mode.clone(); - log!( - "mpbrowser: page rendering is {} (accelerated frames so far: {}, last blit {}us)", - info.render_mode, - info.accelerated_frames, - info.last_blit_micros - ); - } - self.ui.redraw(cx); - } - - fn navigate_from_omnibox(&mut self, cx: &mut Cx, text: &str) { - let url = tabs::resolve_omnibox(text); - if url.is_empty() { - return; - } - self.with_webview(cx, |cx, wv| { - if wv.tab_count() == 0 { - wv.new_tab(cx, &url, true); - } else { - wv.navigate(cx, &url); - } - }); - self.refresh_chrome(cx); - } - - fn handle_shortcut(&mut self, cx: &mut Cx, ke: &KeyEvent) -> bool { - // Ctrl+Tab / Ctrl+Shift+Tab cycle tabs (Chrome), as do Cmd+Shift+] / [. - if ke.modifiers.control && ke.key_code == KeyCode::Tab { - let delta = if ke.modifiers.shift { -1 } else { 1 }; - self.with_webview(cx, |cx, wv| wv.activate_offset(cx, delta)); - self.refresh_chrome(cx); - return true; - } - if !ke.modifiers.logo { - return false; - } - if ke.modifiers.shift - && matches!(ke.key_code, KeyCode::LBracket | KeyCode::RBracket) - { - let delta = if ke.key_code == KeyCode::LBracket { -1 } else { 1 }; - self.with_webview(cx, |cx, wv| wv.activate_offset(cx, delta)); - self.refresh_chrome(cx); - return true; - } - match ke.key_code { - KeyCode::KeyT | KeyCode::KeyN => self.new_tab(cx, None), - KeyCode::KeyW => self.close_active_tab(cx), - KeyCode::KeyL => self.focus_omnibox(cx), - KeyCode::KeyR => { - self.with_webview(cx, |cx, wv| wv.reload(cx)); - } - KeyCode::LBracket => { - self.with_webview(cx, |cx, wv| wv.go_back(cx)); - } - KeyCode::RBracket => { - self.with_webview(cx, |cx, wv| wv.go_forward(cx)); - } - KeyCode::Key1 - | KeyCode::Key2 - | KeyCode::Key3 - | KeyCode::Key4 - | KeyCode::Key5 - | KeyCode::Key6 - | KeyCode::Key7 - | KeyCode::Key8 => { - let index = match ke.key_code { - KeyCode::Key1 => 0, - KeyCode::Key2 => 1, - KeyCode::Key3 => 2, - KeyCode::Key4 => 3, - KeyCode::Key5 => 4, - KeyCode::Key6 => 5, - KeyCode::Key7 => 6, - _ => 7, - }; - self.with_webview(cx, |cx, wv| wv.activate_index(cx, index)); - self.refresh_chrome(cx); - } - KeyCode::Key9 => { - self.with_webview(cx, |cx, wv| { - let last = wv.tab_count().saturating_sub(1); - wv.activate_index(cx, last); - }); - self.refresh_chrome(cx); - } - _ => return false, - } - true - } -} - -impl MatchEvent for App { - fn handle_startup(&mut self, cx: &mut Cx) { - match makepad_cef::startup_phases() { - Some((bundle_ms, exec_gap_ms)) => log!( - "mpbrowser: window up at {} ms after main (app bundle prepared in {} ms, exec-to-main gap {} ms)", - uptime_ms(), - bundle_ms, - exec_gap_ms - ), - None => log!("mpbrowser: window up at {} ms after main", uptime_ms()), - } - let urls = initial_urls(); - if urls.is_empty() { - // Never boot empty: the first tab opens the web (Cmd+T tabs - // still get the themed New Tab page). - self.new_tab(cx, Some("https://www.google.com/".to_string())); - } else { - for url in urls { - self.new_tab(cx, Some(url)); - } - } - } - - fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { - // Tab strip. - for action in actions { - let Some(action) = action.as_widget_action() else { - continue; - }; - match action.cast::() { - TabStripAction::Activate(id) => { - self.with_webview(cx, |cx, wv| wv.activate(cx, id)); - self.refresh_chrome(cx); - } - TabStripAction::Close(id) => { - let closed_last = self - .with_webview(cx, |cx, wv| { - wv.close_tab(cx, id); - wv.tab_count() == 0 - }) - .unwrap_or(false); - if closed_last { - self.new_tab(cx, None); - } else { - self.refresh_chrome(cx); - } - } - TabStripAction::New => self.new_tab(cx, None), - TabStripAction::None => {} - } - if let WebViewAction::TabsChanged = action.cast::() { - self.refresh_chrome(cx); - } - } - - // Toolbar. - if self.ui.button(cx, ids!(back_btn)).clicked(actions) { - self.with_webview(cx, |cx, wv| wv.go_back(cx)); - } - if self.ui.button(cx, ids!(forward_btn)).clicked(actions) { - self.with_webview(cx, |cx, wv| wv.go_forward(cx)); - } - if self.ui.button(cx, ids!(reload_btn)).clicked(actions) { - // Chrome semantics: the button stops a page that is still loading. - self.with_webview(cx, |cx, wv| { - if wv.active_info().loading { - wv.stop(cx); - } else { - wv.reload(cx); - } - }); - } - if self.ui.button(cx, ids!(star_btn)).clicked(actions) { - // Bookmarks are not wired yet; the star just re-focuses the page. - self.with_webview(cx, |cx, wv| wv.focus(cx)); - } - if self.ui.button(cx, ids!(menu_btn)).clicked(actions) { - let open = !self.menu_open; - self.set_menu_open(cx, open); - } - - // Menu. - if self.ui.button(cx, ids!(menu_new_tab)).clicked(actions) { - self.set_menu_open(cx, false); - self.new_tab(cx, None); - } - if self.ui.button(cx, ids!(menu_close_tab)).clicked(actions) { - self.set_menu_open(cx, false); - self.close_active_tab(cx); - } - if self.ui.button(cx, ids!(menu_reload)).clicked(actions) { - self.set_menu_open(cx, false); - self.with_webview(cx, |cx, wv| wv.reload(cx)); - } - if self.ui.button(cx, ids!(menu_gpu)).clicked(actions) { - self.set_menu_open(cx, false); - self.new_tab(cx, Some("chrome://gpu".to_string())); - } - if self.ui.button(cx, ids!(menu_about)).clicked(actions) { - self.set_menu_open(cx, false); - let info = self - .with_webview(cx, |_cx, wv| wv.active_info()) - .unwrap_or_default(); - let html = format!( - "About mpbrowser\ -

mpbrowser

\ -

Makepad chrome, Chromium Embedded Framework {cef} page rendering.

\ -

Page rendering path: {mode} (accelerated frames: {frames}, last GPU blit: {blit}µs)

\ -

ANGLE backend: {angle}

", - bg = palette().darker_background, - fg = palette().foreground, - cef = makepad_cef::CEF_VERSION, - mode = info.render_mode, - frames = info.accelerated_frames, - blit = info.last_blit_micros, - angle = std::env::var("MAKEPAD_CEF_USE_ANGLE").unwrap_or_else(|_| "default".into()), - ); - let url = format!("data:text/html;charset=utf-8,{}", theme::percent_encode(&html)); - self.new_tab(cx, Some(url)); - } - - // Omnibox. - let omnibox = self.ui.text_input(cx, ids!(omnibox)); - if let Some((text, _modifiers)) = omnibox.returned(actions) { - self.navigate_from_omnibox(cx, &text); - } - if omnibox.escaped(actions) { - let shown = self.shown_url.clone(); - omnibox.set_text(cx, &shown); - self.with_webview(cx, |cx, wv| wv.focus(cx)); - } - for action in actions { - let Some(widget_action) = action.as_widget_action() else { - continue; - }; - if widget_action.widget_uid != omnibox.widget_uid() { - continue; - } - match widget_action.cast::() { - TextInputAction::KeyFocus => { - self.omnibox_focused = true; - if let Some(mut inner) = omnibox.borrow_mut() { - inner.select_all(cx); - } - } - TextInputAction::KeyFocusLost => { - if std::env::var_os("MAKEPAD_CEF_DEBUG").is_some() { - let now = cx.keyboard.key_focus(); - let webview = self.ui.widget(cx, ids!(webview)).area(); - log!( - "omnibox lost key focus to {}", - if now == webview { "the webview" } else if now == Area::Empty { "nothing (Area::Empty)" } else { "another widget" } - ); - } - self.omnibox_focused = false; - let shown = self.shown_url.clone(); - omnibox.set_text(cx, &shown); - } - _ => {} - } - } - } -} - -impl AppMain for App { - fn script_mod(vm: &mut ScriptVm) -> ScriptValue { - crate::makepad_widgets::script_mod(vm); - // The family theme bridge retints the stock widgets from the WM - // theme; the chrome roles go into mod.mpb_theme. - mp_theme::apply(vm); - palette().apply(vm); - chrome::script_mod(vm); - webview::script_mod(vm); - self::script_mod(vm) - } - - fn handle_event(&mut self, cx: &mut Cx, event: &Event) { - if let Event::KeyDown(ke) = event { - if self.handle_shortcut(cx, ke) { - return; - } - } - if self.focus_omnibox_pending && self.focus_frame.is_event(event).is_some() { - let omnibox = self.ui.text_input(cx, ids!(omnibox)); - if omnibox.area().is_valid(cx) || self.focus_retries >= 60 { - self.focus_omnibox_pending = false; - self.focus_retries = 0; - omnibox.set_key_focus(cx); - omnibox.borrow_mut().map(|mut inner| inner.select_all(cx)); - } else { - // At startup the first tab arrives before the chrome has been - // drawn: no area to focus yet, so try again next frame. - self.focus_retries += 1; - self.focus_frame = cx.new_next_frame(); - } - } - if let Event::MouseDown(_) = event { - if self.menu_open { - // Any click outside the menu closes it; the menu's own - // buttons still get the event through the ui below. - let menu = self.ui.view(cx, ids!(menu)).area(); - if let Event::MouseDown(md) = event { - if !(menu.is_valid(cx) && menu.rect(cx).contains(md.abs)) { - self.set_menu_open(cx, false); - } - } - } - } - self.match_event(cx, event); - self.ui.handle_event(cx, event, &mut Scope::empty()); - } -} - -fn main() { - app_main(); -} - -/// The CEF-aware entry point: helper-process bootstrap (`cef_execute_process`), -/// the app-bundle re-exec macOS needs for Chromium's subprocesses, then the -/// ordinary Makepad event loop with the `--remote` control surface. -#[cfg(not(any(target_arch = "wasm32", target_os = "android", target_env = "ohos")))] -pub fn app_main() { - let _ = uptime_ms(); - if let Err(err) = makepad_cef::reexec_into_app_bundle_if_needed() { - panic!("CEF bundle re-exec failed: {err}"); - } - match makepad_cef::bootstrap() { - Ok(BootstrapResult::Continue) => {} - Ok(BootstrapResult::Exit(code)) => std::process::exit(code), - Err(err) => panic!("CEF bootstrap failed: {err}"), - } - - Cx::init_log(); - if Cx::pre_start() { - return; - } - // Chromium composites the page on this colour. Left at CEF's default the - // first frames of every page are BLACK — a dark dip then a bright jump on - // open, and a black margin wherever a resize outruns the reflow. - makepad_cef::set_background_color(palette().page_background_argb()); - // Only the cheap NSApp/pump preparation here: the window goes up first, - // the WebView runs `cef_initialize` on the frame after it is drawn. - if let Err(err) = makepad_cef::prepare() { - panic!("CEF prepare failed: {err}"); - } - - let cx = Rc::new(RefCell::new(Cx::new( - makepad_widgets::_app_main_event_closure!(App), - ))); - let studio_http = makepad_widgets::resolve_studio_http(); - cx.borrow_mut().init_websockets(&studio_http); - if makepad_widgets::should_run_stdin_loop_from_env() { - cx.borrow_mut().in_makepad_studio = true; - } - cx.borrow_mut().init_cx_os(); - makepad_widgets::makepad_platform::remote::start_if_requested(); - Cx::event_loop(cx.clone()); - drop(cx); - makepad_cef::shutdown(); -} - -#[cfg(any(target_arch = "wasm32", target_os = "android", target_env = "ohos"))] -pub fn app_main() { - panic!("mpbrowser is desktop-only"); -} diff --git a/apps/mpbrowser/src/tabs.rs b/apps/mpbrowser/src/tabs.rs deleted file mode 100644 index 3af0b1a94..000000000 --- a/apps/mpbrowser/src/tabs.rs +++ /dev/null @@ -1,321 +0,0 @@ -//! The tab model. Each tab owns one CEF browser (created lazily when the -//! view first knows its size), the texture that browser paints into, and the -//! navigation state mirrored from CEF's display/load handlers. - -use makepad_widgets::*; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct TabId(pub u64); - -pub struct Tab { - pub id: TabId, - pub browser: Option, - /// The URL to load once the browser exists. - pub initial_url: String, - pub title: String, - pub url: String, - pub loading: bool, - pub can_go_back: bool, - pub can_go_forward: bool, - /// What the quad samples: an IOSurface-backed texture on the GPU path, - /// a BGRA upload texture on the software path. This is the LAST GOOD - /// frame — it is never dropped for a resize, only replaced once its - /// successor holds a page frame. - pub texture: Option, - /// CEF has put at least one frame in `texture`. The GPU path hands the - /// browser an IOSurface up front, so the texture EXISTS long before the - /// page is on it; drawing it meanwhile paints an opaque black hole over - /// the themed ground — which, inside the WM, reads as the window - /// flashing black on the way in. Nothing samples the texture until this - /// is true. - pub painted: bool, - /// Pixel size `texture` is allocated at. The GPU surface is rounded up to - /// a coarse grid so a drag walks many sizes inside one surface. - pub texture_alloc: Option<(usize, usize)>, - /// The sub-rect of `texture` that actually holds page pixels (the last - /// blit's copy region). The quad samples exactly this and stretches it - /// over the current rect, so a frame from a slightly older size shows - /// scaled instead of cropped-with-a-blank-margin. - pub texture_valid: Option<(usize, usize)>, - /// A larger/smaller surface already handed to CEF that has NOT been - /// painted yet. It is not drawn until it holds a frame; `texture` keeps - /// covering the page area meanwhile. - pub pending_texture: Option, - pub pending_alloc: Option<(usize, usize)>, - /// Allocation size of the surface CEF is painting into right now. - pub accel_target_size: Option<(usize, usize)>, - /// Page size last handed to `browser.resize`, and when — `was_resized` is - /// rate-limited, because a resize faster than CEF can paint starves the - /// paint callback completely (measured: 0 frames at ~2 kHz). - pub resized_to: Option<(usize, usize)>, - pub resized_at: Option, - pub deferred_resize: Option<(usize, usize, f32)>, - /// Page size the layout last asked for, and when it last changed — the - /// settle detector behind shrinking a surface back down. - pub wanted_size: Option<(usize, usize)>, - pub wanted_at: Option, - pub accel_frame_counter: u64, - pub nav_generation: u64, - pub favicon: Option, - pub init_error: Option, - pub render_mode: makepad_cef::RenderMode, -} - -impl Tab { - fn new(id: TabId, url: &str) -> Self { - Self { - id, - browser: None, - initial_url: url.to_string(), - title: String::new(), - url: url.to_string(), - loading: true, - can_go_back: false, - can_go_forward: false, - texture: None, - painted: false, - texture_alloc: None, - texture_valid: None, - pending_texture: None, - pending_alloc: None, - accel_target_size: None, - resized_to: None, - resized_at: None, - deferred_resize: None, - wanted_size: None, - wanted_at: None, - accel_frame_counter: 0, - nav_generation: 0, - favicon: None, - init_error: None, - render_mode: makepad_cef::RenderMode::None, - } - } - - /// Title for the strip: the page title, else the host, else "New Tab". - pub fn display_title(&self) -> String { - if !self.title.trim().is_empty() { - return self.title.clone(); - } - if crate::theme::is_new_tab_url(&self.url) || self.url.is_empty() { - return "New Tab".to_string(); - } - host_of(&self.url).unwrap_or_else(|| self.url.clone()) - } -} - -/// What the tab strip needs to draw one tab. -#[derive(Clone, Debug)] -pub struct TabSummary { - pub id: TabId, - pub title: String, - pub loading: bool, - pub active: bool, - pub favicon: Option, -} - -#[derive(Default)] -pub struct TabModel { - pub tabs: Vec, - pub active: usize, - next_id: u64, -} - -impl TabModel { - pub fn len(&self) -> usize { - self.tabs.len() - } - - pub fn index_of(&self, id: TabId) -> Option { - self.tabs.iter().position(|t| t.id == id) - } - - pub fn active(&self) -> Option<&Tab> { - self.tabs.get(self.active) - } - - pub fn active_mut(&mut self) -> Option<&mut Tab> { - self.tabs.get_mut(self.active) - } - - pub fn active_id(&self) -> Option { - self.active().map(|t| t.id) - } - - /// Insert a tab right after the active one (Chrome's placement), or at - /// the end when there is none. - pub fn insert(&mut self, url: &str, activate: bool) -> TabId { - self.next_id += 1; - let id = TabId(self.next_id); - let at = if self.tabs.is_empty() { - 0 - } else { - (self.active + 1).min(self.tabs.len()) - }; - self.tabs.insert(at, Tab::new(id, url)); - if activate || self.tabs.len() == 1 { - self.active = at; - } else if at <= self.active { - self.active += 1; - } - id - } - - /// Remove a tab. Returns the removed tab (dropping it closes its - /// browser) and whether it was the active one. - pub fn remove(&mut self, id: TabId) -> Option<(Tab, bool)> { - let index = self.index_of(id)?; - let was_active = index == self.active; - let tab = self.tabs.remove(index); - if self.tabs.is_empty() { - self.active = 0; - } else if index < self.active { - self.active -= 1; - } else if was_active { - // Chrome activates the tab to the right, else the new last one. - self.active = index.min(self.tabs.len() - 1); - } - Some((tab, was_active)) - } - - pub fn activate(&mut self, id: TabId) -> bool { - if let Some(index) = self.index_of(id) { - self.active = index; - true - } else { - false - } - } - - pub fn activate_offset(&mut self, delta: isize) { - if self.tabs.is_empty() { - return; - } - let len = self.tabs.len() as isize; - let next = ((self.active as isize + delta) % len + len) % len; - self.active = next as usize; - } - - pub fn activate_index(&mut self, index: usize) { - if index < self.tabs.len() { - self.active = index; - } - } - - pub fn summaries(&self) -> Vec { - self.tabs - .iter() - .enumerate() - .map(|(i, t)| TabSummary { - id: t.id, - title: t.display_title(), - loading: t.loading, - active: i == self.active, - favicon: t.favicon.clone(), - }) - .collect() - } -} - -pub fn host_of(url: &str) -> Option { - let rest = url.split_once("://")?.1; - let host = rest.split(['/', '?', '#']).next()?; - if host.is_empty() { - return None; - } - Some(host.trim_start_matches("www.").to_string()) -} - -/// Turn omnibox input into a URL: keep explicit schemes, prefix `https://` -/// for things that look like hosts, otherwise search. -pub fn resolve_omnibox(input: &str) -> String { - let input = input.trim(); - if input.is_empty() { - return String::new(); - } - let lower = input.to_ascii_lowercase(); - if lower.contains("://") - || lower.starts_with("about:") - || lower.starts_with("data:") - || lower.starts_with("chrome:") - || lower.starts_with("file:") - || lower.starts_with("javascript:") - || lower.starts_with("view-source:") - { - return input.to_string(); - } - let has_space = input.contains(char::is_whitespace); - let first = input.split(['/', '?', '#']).next().unwrap_or(""); - let (host, port) = match first.rsplit_once(':') { - Some((h, p)) if !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()) => (h, Some(p)), - _ => (first, None), - }; - let looks_like_host = !has_space - && !host.is_empty() - && (host == "localhost" - || host.parse::().is_ok() - || (host.contains('.') - && !host.starts_with('.') - && !host.ends_with('.') - && host - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') - && host - .rsplit('.') - .next() - .map(|tld| tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic())) - .unwrap_or(false))); - let _ = port; - if looks_like_host { - format!("https://{input}") - } else { - format!( - "https://www.google.com/search?q={}", - crate::theme::percent_encode(input) - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn omnibox_resolution() { - assert_eq!(resolve_omnibox("makepad.nl"), "https://makepad.nl"); - assert_eq!(resolve_omnibox("http://x.org/a b"), "http://x.org/a b"); - assert_eq!(resolve_omnibox("localhost:8080/x"), "https://localhost:8080/x"); - assert_eq!( - resolve_omnibox("rust async traits"), - "https://www.google.com/search?q=rust%20async%20traits" - ); - assert_eq!( - resolve_omnibox("hello"), - "https://www.google.com/search?q=hello" - ); - assert_eq!(resolve_omnibox("about:blank"), "about:blank"); - } - - #[test] - fn model_insert_remove() { - let mut m = TabModel::default(); - let a = m.insert("a", true); - let b = m.insert("b", true); - assert_eq!(m.active_id(), Some(b)); - let c = m.insert("c", false); - assert_eq!(m.active_id(), Some(b)); - assert_eq!(m.index_of(c), Some(2)); - m.remove(b); - assert_eq!(m.active_id(), Some(c)); - m.remove(c); - assert_eq!(m.active_id(), Some(a)); - m.activate_offset(1); - assert_eq!(m.active_id(), Some(a)); - } - - #[test] - fn hosts() { - assert_eq!(host_of("https://www.makepad.nl/x"), Some("makepad.nl".into())); - assert_eq!(host_of("nope"), None); - } -} diff --git a/apps/mpbrowser/src/theme.rs b/apps/mpbrowser/src/theme.rs deleted file mode 100644 index 1483559bd..000000000 --- a/apps/mpbrowser/src/theme.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! The browser-chrome palette. Theming lives in splash: the chrome reads -//! `mod.mpb_theme.*` (tab strip, toolbar, omnibox, icon roles), which this -//! module evaluates into the VM before the UI modules. -//! -//! Under makepad-wm the roles come from the WM's theme.splash -//! (`MPWM_THEME_SPLASH`, line-scanned by `mp_theme`, the family bridge); -//! standalone runs get Chrome's own dark palette. - -use makepad_widgets::*; - -/// Chrome-dark roles, keyed like the mpwm theme so one mapping serves both. -#[derive(Clone, Debug)] -pub struct Palette { - /// Tab strip background (the "frame"). - pub darker_background: String, - /// Active tab + toolbar. - pub background: String, - /// Hovered inactive tab. - pub dark_background: String, - /// Button hover squares, omnibox focus fill. - pub lighter_background: String, - pub foreground: String, - pub dark_foreground: String, - pub bright_foreground: String, - pub muted: String, - pub selection: String, - pub accent: String, -} - -impl Palette { - pub fn chrome_dark() -> Self { - Self { - darker_background: "#202124".into(), - background: "#35363a".into(), - dark_background: "#2b2c2f".into(), - lighter_background: "#3c4043".into(), - foreground: "#e8eaed".into(), - dark_foreground: "#9aa0a6".into(), - bright_foreground: "#ffffff".into(), - muted: "#5f6368".into(), - selection: "#264f78".into(), - accent: "#8ab4f8".into(), - } - } - - /// The WM palette when mpwm exported one, else Chrome dark. - pub fn current() -> Self { - let fallback = Self::chrome_dark(); - let Some(p) = mp_theme::current() else { - return fallback; - }; - Self { - darker_background: p.hex("darker_background", &fallback.darker_background), - background: p.hex("background", &fallback.background), - dark_background: p.hex("dark_background", &fallback.dark_background), - lighter_background: p.hex("lighter_background", &fallback.lighter_background), - foreground: p.hex("foreground", &fallback.foreground), - dark_foreground: p.hex("dark_foreground", &fallback.dark_foreground), - bright_foreground: p.hex("bright_foreground", &fallback.bright_foreground), - muted: p.hex("muted", &fallback.muted), - selection: p.hex("selection", &fallback.selection), - accent: p.hex("accent", &fallback.accent), - } - } - - /// The `mod.mpb_theme = {...}` splash source. Runtime-evaluated, so plain - /// `#hex` (the `#x` escape is a proc-macro-only hazard). - pub fn splash_source(&self) -> String { - format!( - "mod.mpb_theme = {{\n\ - \x20 darker_background: {}\n\ - \x20 background: {}\n\ - \x20 dark_background: {}\n\ - \x20 lighter_background: {}\n\ - \x20 foreground: {}\n\ - \x20 dark_foreground: {}\n\ - \x20 bright_foreground: {}\n\ - \x20 muted: {}\n\ - \x20 selection: {}\n\ - \x20 accent: {}\n\ - }}\n\ - true\n", - self.darker_background, - self.background, - self.dark_background, - self.lighter_background, - self.foreground, - self.dark_foreground, - self.bright_foreground, - self.muted, - self.selection, - self.accent, - ) - } - - /// Evaluate `mod.mpb_theme` into the VM. Call after - /// `makepad_widgets::script_mod(vm)` and before the chrome modules. - pub fn apply(&self, vm: &mut ScriptVm) { - let script_mod_id = ScriptMod { - cargo_manifest_path: env!("CARGO_MANIFEST_DIR").to_string(), - module_path: "mpb_theme".to_string(), - file: "mpb_theme.splash".to_string(), - line: 0, - column: 0, - code: self.splash_source(), - values: vec![], - }; - vm.eval(script_mod_id); - for e in vm.take_errors() { - log!("mpbrowser theme: {}", e); - } - } - - /// What CEF fills the page with before the site has composited anything, - /// and behind area a reflow has not reached yet. The same colour as the - /// ground the page quad sits on, so neither a cold tab nor a mid-resize - /// page shows a black hole. - pub fn page_background_argb(&self) -> u32 { - parse_argb(&self.darker_background).unwrap_or(0xff20_2124) - } - - /// The new-tab page: a data URL in the theme's colours, so a fresh tab - /// never flashes white. - pub fn new_tab_url(&self) -> String { - let html = format!( - "New Tab\ -
\ -
mpbrowser
Type a URL or search in the box above
\ -
", - bg = self.darker_background, - fg = self.foreground, - fgb = self.bright_foreground, - fgd = self.dark_foreground, - ); - format!("data:text/html;charset=utf-8,{}", percent_encode(&html)) - } -} - -pub fn parse_hex(s: &str) -> Option { - let s = s.trim().trim_start_matches('#'); - if s.len() != 6 { - return None; - } - let r = u8::from_str_radix(&s[0..2], 16).ok()?; - let g = u8::from_str_radix(&s[2..4], 16).ok()?; - let b = u8::from_str_radix(&s[4..6], 16).ok()?; - Some(vec4( - r as f32 / 255.0, - g as f32 / 255.0, - b as f32 / 255.0, - 1.0, - )) -} - -/// A `#rrggbb` role as opaque ARGB (`0xFFRRGGBB`) — the form CEF wants for -/// `cef_browser_settings_t::background_color`. -pub fn parse_argb(s: &str) -> Option { - let s = s.trim().trim_start_matches('#'); - if s.len() != 6 { - return None; - } - let rgb = u32::from_str_radix(s, 16).ok()?; - Some(0xff00_0000 | rgb) -} - -/// Minimal percent-encoding for a `data:` URL payload. -pub fn percent_encode(s: &str) -> String { - let mut out = String::with_capacity(s.len() * 3); - for b in s.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char) - } - _ => out.push_str(&format!("%{:02X}", b)), - } - } - out -} - -/// Is this new-tab-page URL (never shown in the omnibox)? -pub fn is_new_tab_url(url: &str) -> bool { - url.starts_with("data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Chtml%3E%3Chead%3E%3Cmeta%20charset%3Dutf-8%3E%3Ctitle%3ENew%20Tab") - || url == "about:blank" -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn new_tab_url_is_recognised() { - let p = Palette::chrome_dark(); - assert!(is_new_tab_url(&p.new_tab_url())); - assert!(!is_new_tab_url("https://makepad.nl")); - } - - #[test] - fn hex_parses() { - let c = parse_hex("#8ab4f8").unwrap(); - assert!((c.x - 0x8a as f32 / 255.0).abs() < 1e-6); - assert!(parse_hex("#12345").is_none()); - } - - #[test] - fn argb_is_opaque() { - // CEF paints the page on this before the site composites; a - // transparent (or zero) colour is what makes a page open black. - assert_eq!(parse_argb("#202124"), Some(0xff20_2124)); - assert_eq!(parse_argb("202124"), Some(0xff20_2124)); - assert_eq!(parse_argb("#nope"), None); - assert_eq!( - Palette::chrome_dark().page_background_argb(), - 0xff20_2124, - "the CEF fill must match the themed ground the page sits on" - ); - } -} diff --git a/apps/mpbrowser/src/webview.rs b/apps/mpbrowser/src/webview.rs deleted file mode 100644 index eff0b6166..000000000 --- a/apps/mpbrowser/src/webview.rs +++ /dev/null @@ -1,966 +0,0 @@ -//! The page area. One widget hosts every tab's CEF browser; the active -//! tab's texture is what gets drawn, input goes to the active browser, -//! background tabs are told they are hidden so they stop painting. -//! -//! Rendering: with `shared_texture_enabled` CEF paints on the GPU into pooled -//! IOSurfaces and `libs/cef` blits each one into a Makepad-owned IOSurface -//! texture (`Cx::create_iosurface_render_texture`) that the quad samples — -//! no CPU readback anywhere. The classic `on_paint` BGRA upload stays as the -//! fallback (`MAKEPAD_CEF_SOFTWARE=1`, or a CEF build without GPU paint). - -use crate::tabs::{TabId, TabModel, TabSummary}; -// The surface policy is the stock Browser widget's — one source of truth for -// how a CEF page survives a resize. -use makepad_widgets::browser::{ - needs_new_surface, surface_alloc, Browser as BrowserKeys, RESIZE_INTERVAL, SETTLE, -}; -use makepad_widgets::image::DrawImage; -use makepad_widgets::*; - -script_mod! { - use mod.prelude.widgets_internal.* - use mod.widgets.* - - mod.widgets.WebViewBase = #(WebView::register_widget(vm)) - - mod.widgets.WebView = set_type_default() do mod.widgets.WebViewBase{ - width: Fill - height: Fill - draw_empty +: { - color: uniform(mod.mpb_theme.darker_background) - pixel: fn() { - return self.color - } - } - draw_status +: { - color: mod.mpb_theme.dark_foreground - text_style: theme.font_regular{ - font_size: 11 - } - } - } -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub enum WebViewAction { - #[default] - None, - /// Titles / URLs / loading state / favicons / the tab list changed. - TabsChanged, -} - -/// What the chrome shows for the active tab. -#[derive(Clone, Debug, Default)] -pub struct ActiveInfo { - pub id: Option, - pub url: String, - pub title: String, - pub loading: bool, - pub can_go_back: bool, - pub can_go_forward: bool, - pub render_mode: String, - pub accelerated_frames: u64, - pub last_blit_micros: u64, -} - -#[derive(Script, ScriptHook, Widget)] -pub struct WebView { - #[uid] - uid: WidgetUid, - #[source] - source: ScriptObjectRef, - #[walk] - walk: Walk, - #[redraw] - #[live] - draw_bg: DrawImage, - #[live] - draw_empty: DrawQuad, - #[live] - draw_status: DrawText, - #[rust] - tabs: TabModel, - /// `cef_initialize` runs on the frame after the chrome first drew, so - /// the window is up before Chromium's processes spawn. - #[rust] - cef_ready: bool, - #[rust] - cef_init_frame: NextFrame, - #[rust] - cef_init_requested: bool, - #[rust] - cef_init_error: Option, - #[rust] - first_frame_logged: bool, - #[rust] - pump_timer: Timer, - #[rust] - pressed_buttons: MouseButton, - #[rust] - suppress_next_paste_shortcut: bool, - #[rust] - pump_started: bool, -} - -/// Env-gated resize tracing (`MPB_TRACE=1`): timestamps + sizes on every -/// draw, resize and target swap. Debug rig — not for committing. -pub fn trace_on() -> bool { - static ON: std::sync::OnceLock = std::sync::OnceLock::new(); - *ON.get_or_init(|| std::env::var_os("MPB_TRACE").is_some()) -} - -macro_rules! trace { - ($($arg:tt)*) => { - if crate::webview::trace_on() { - eprintln!("[mpb-trace {:>7}us] {}", crate::uptime_us(), format!($($arg)*)); - } - }; -} - -impl WebView { - const PUMP_INTERVAL: f64 = 1.0 / 120.0; - - // ------------------------------------------------------------------ - // Tab operations (called by the app from chrome actions / shortcuts) - // ------------------------------------------------------------------ - - pub fn new_tab(&mut self, cx: &mut Cx, url: &str, activate: bool) -> TabId { - let id = self.tabs.insert(url, activate); - if activate { - self.apply_active_visibility(); - self.focus(cx); - } - self.notify(cx); - id - } - - pub fn close_tab(&mut self, cx: &mut Cx, id: TabId) { - if let Some((tab, _was_active)) = self.tabs.remove(id) { - // Dropping the tab closes its browser. - drop(tab); - } - self.apply_active_visibility(); - self.notify(cx); - } - - pub fn activate(&mut self, cx: &mut Cx, id: TabId) { - if self.tabs.activate(id) { - self.apply_active_visibility(); - self.focus(cx); - self.notify(cx); - } - } - - pub fn activate_offset(&mut self, cx: &mut Cx, delta: isize) { - self.tabs.activate_offset(delta); - self.apply_active_visibility(); - self.focus(cx); - self.notify(cx); - } - - pub fn activate_index(&mut self, cx: &mut Cx, index: usize) { - self.tabs.activate_index(index); - self.apply_active_visibility(); - self.focus(cx); - self.notify(cx); - } - - pub fn active_id(&self) -> Option { - self.tabs.active_id() - } - - pub fn tab_count(&self) -> usize { - self.tabs.len() - } - - pub fn summaries(&self) -> Vec { - self.tabs.summaries() - } - - pub fn active_info(&self) -> ActiveInfo { - let Some(tab) = self.tabs.active() else { - return ActiveInfo::default(); - }; - let (accelerated_frames, last_blit_micros) = tab - .browser - .as_ref() - .map(|b| { - let s = b.accelerated_stats(); - (s.frames, s.last_blit_micros) - }) - .unwrap_or((0, 0)); - ActiveInfo { - id: Some(tab.id), - url: tab.url.clone(), - title: tab.display_title(), - loading: tab.loading, - can_go_back: tab.can_go_back, - can_go_forward: tab.can_go_forward, - render_mode: format!("{:?}", tab.render_mode), - accelerated_frames, - last_blit_micros, - } - } - - pub fn navigate(&mut self, cx: &mut Cx, url: &str) { - if let Some(tab) = self.tabs.active_mut() { - tab.url = url.to_string(); - tab.title.clear(); - tab.favicon = None; - tab.loading = true; - match &mut tab.browser { - Some(browser) => { - if let Err(err) = browser.set_url(url) { - log!("navigate failed: {err}"); - } - } - None => tab.initial_url = url.to_string(), - } - } - self.focus(cx); - self.notify(cx); - } - - pub fn go_back(&mut self, cx: &mut Cx) { - if let Some(browser) = self.active_browser() { - let _ = browser.go_back(); - } - self.focus(cx); - } - - pub fn go_forward(&mut self, cx: &mut Cx) { - if let Some(browser) = self.active_browser() { - let _ = browser.go_forward(); - } - self.focus(cx); - } - - pub fn reload(&mut self, cx: &mut Cx) { - if let Some(browser) = self.active_browser() { - let _ = browser.reload(); - } - self.focus(cx); - } - - pub fn stop(&mut self, cx: &mut Cx) { - if let Some(browser) = self.active_browser() { - let _ = browser.stop_load(); - } - self.focus(cx); - } - - pub fn focus(&mut self, cx: &mut Cx) { - let area = self.draw_bg.area(); - if area.is_valid(cx) { - cx.set_key_focus(area); - } - if let Some(browser) = self.active_browser() { - let _ = browser.set_focus(true); - } - } - - // ------------------------------------------------------------------ - - fn notify(&mut self, cx: &mut Cx) { - cx.widget_action(self.uid, WebViewAction::TabsChanged); - self.redraw(cx); - } - - fn active_browser(&mut self) -> Option<&mut makepad_cef::Browser> { - self.tabs.active_mut().and_then(|t| t.browser.as_mut()) - } - - /// Background tabs stop painting; the active one is shown. - fn apply_active_visibility(&mut self) { - let active = self.tabs.active; - for (i, tab) in self.tabs.tabs.iter_mut().enumerate() { - if let Some(browser) = &mut tab.browser { - let _ = browser.set_hidden(i != active); - } - } - } - - fn ensure_active_browser(&mut self, cx: &mut Cx, width: usize, height: usize, dpi: f32) { - if !self.cef_ready { - return; - } - let active = self.tabs.active; - let Some(tab) = self.tabs.tabs.get_mut(active) else { - return; - }; - if tab.browser.is_none() && tab.init_error.is_none() { - match makepad_cef::Browser::new(&tab.initial_url, width, height, dpi) { - Ok(browser) => { - tab.browser = Some(browser); - } - Err(err) => { - let message = err.to_string(); - log!("CEF browser creation failed: {message}"); - tab.init_error = Some(message); - } - } - } - if tab.browser.is_none() { - return; - } - if let Some(browser) = &mut tab.browser { - let _ = browser.set_hidden(false); - } - - let now = std::time::Instant::now(); - if tab.wanted_size != Some((width, height)) { - tab.wanted_size = Some((width, height)); - tab.wanted_at = Some(now); - } - Self::sync_browser_size(tab, width, height, dpi, now); - Self::sync_accel_surface(cx, tab, width, height, now); - } - - /// Tell CEF the page size, at most once per `RESIZE_INTERVAL`; anything - /// faster is remembered and applied by the next pump, so the final size of - /// a drag always lands. - fn sync_browser_size( - tab: &mut crate::tabs::Tab, - width: usize, - height: usize, - dpi: f32, - now: std::time::Instant, - ) { - if tab.resized_to == Some((width, height)) { - // `Browser::resize` no-ops on an unchanged size; this still lets a - // dpi change through. - if let Some(browser) = &mut tab.browser { - if let Err(err) = browser.resize(width, height, dpi) { - log!("CEF resize failed: {err}"); - } - } - tab.deferred_resize = None; - return; - } - let due = tab - .resized_at - .is_none_or(|last| now.duration_since(last) >= RESIZE_INTERVAL); - if !due { - tab.deferred_resize = Some((width, height, dpi)); - return; - } - if let Some(browser) = &mut tab.browser { - if let Err(err) = browser.resize(width, height, dpi) { - log!("CEF resize failed: {err}"); - } - } - trace!("was_resized {}x{}", width, height); - tab.resized_to = Some((width, height)); - tab.resized_at = Some(now); - tab.deferred_resize = None; - } - - /// GPU path: hand the browser a Makepad-owned IOSurface to copy into. - /// - /// The surface is over-allocated (grid-rounded) and grows only when the - /// page outgrows it; it shrinks only once the size has settled. A new - /// surface is BLANK, so it goes to `pending_texture` and the last good one - /// keeps being drawn until CEF has put a frame in the new one — that is - /// what keeps a drag-resize from flashing the themed ground. - fn sync_accel_surface( - cx: &mut Cx, - tab: &mut crate::tabs::Tab, - width: usize, - height: usize, - now: std::time::Instant, - ) { - #[cfg(target_os = "macos")] - { - if !tab.browser.as_ref().is_some_and(|b| b.is_accelerated()) { - return; - } - let want = surface_alloc(width, height); - let settled = tab - .wanted_at - .is_some_and(|since| now.duration_since(since) >= SETTLE); - if !needs_new_surface(tab.accel_target_size, tab.pending_alloc, want, settled) { - return; - } - trace!( - "surface {:?} -> {}x{} for page {}x{}", - tab.accel_target_size, - want.0, - want.1, - width, - height - ); - let (texture, iosurface, _id) = cx.create_iosurface_render_texture(want.0, want.1); - let Some(browser) = &mut tab.browser else { - return; - }; - match browser.set_accelerated_target(iosurface, want.0, want.1) { - Ok(()) => { - tab.accel_target_size = Some(want); - if tab.painted && tab.texture.is_some() { - // Keep showing the last good frame until this one has - // one of its own. - tab.pending_texture = Some(texture); - tab.pending_alloc = Some(want); - } else { - tab.texture = Some(texture); - tab.texture_alloc = Some(want); - tab.texture_valid = None; - tab.pending_texture = None; - tab.pending_alloc = None; - } - } - Err(err) => { - log!("accelerated target failed, software frames only: {err}"); - tab.accel_target_size = None; - tab.pending_texture = None; - tab.pending_alloc = None; - } - } - } - #[cfg(not(target_os = "macos"))] - { - let _ = (cx, tab, width, height, now); - } - } - - fn apply_software_frame(cx: &mut Cx, tab: &mut crate::tabs::Tab, frame: makepad_cef::Frame) { - let size = (frame.width, frame.height); - match &tab.texture { - Some(texture) - if tab.accel_target_size.is_none() - && texture.get_format(cx).vec_width_height() == Some(size) => - { - texture.set_data_u32(cx, frame.width, frame.height, frame.pixels); - } - _ => { - tab.accel_target_size = None; - tab.pending_texture = None; - tab.pending_alloc = None; - tab.texture = Some(Texture::new_with_format( - cx, - TextureFormat::VecBGRAu8_32 { - data: Some(frame.pixels), - width: frame.width, - height: frame.height, - updated: TextureUpdated::Full, - }, - )); - } - } - // An upload texture is exactly the page: the whole thing is valid. - tab.texture_alloc = Some(size); - tab.texture_valid = Some(size); - tab.painted = true; - } - - fn favicon_texture(cx: &mut Cx, frame: makepad_cef::Frame) -> Texture { - Texture::new_with_format( - cx, - TextureFormat::VecBGRAu8_32 { - data: Some(frame.pixels), - width: frame.width, - height: frame.height, - updated: TextureUpdated::Full, - }, - ) - } - - /// One pump: run CEF's message loop, collect frames and navigation - /// changes from every tab, open queued popups as tabs. - fn pump(&mut self, cx: &mut Cx) { - makepad_cef::do_message_loop_work(); - let active = self.tabs.active; - let mut changed = false; - let mut redraw = false; - let mut popups = Vec::new(); - for (i, tab) in self.tabs.tabs.iter_mut().enumerate() { - let Some(browser) = &mut tab.browser else { - continue; - }; - let mut latest = None; - while let Some(frame) = browser.take_frame() { - latest = Some(frame); - } - let counter = browser.accelerated_frame_counter(); - if counter != tab.accel_frame_counter { - let stats = browser.accelerated_stats(); - let valid = (stats.last_copy_width, stats.last_copy_height); - if stats.target_frames > 0 && valid.0 > 0 && valid.1 > 0 { - // The surface CEF is painting into now holds a real frame: - // if it was the pending one, this is the moment to swap — - // never before, so the page area never goes blank. - if let (Some(texture), Some(alloc)) = - (tab.pending_texture.take(), tab.pending_alloc.take()) - { - trace!( - "promote pending {}x{} (valid {}x{})", - alloc.0, - alloc.1, - valid.0, - valid.1 - ); - tab.texture = Some(texture); - tab.texture_alloc = Some(alloc); - } - tab.texture_valid = Some(valid); - tab.painted = true; - } - if i == active { - trace!( - "paint landed {}x{} (+{} frames, surface {:?})", - stats.last_width, - stats.last_height, - counter - tab.accel_frame_counter, - tab.accel_target_size - ); - } - tab.accel_frame_counter = counter; - if i == active { - redraw = true; - } - if !self.first_frame_logged { - self.first_frame_logged = true; - log!("mpbrowser: first page frame at {} ms", crate::uptime_ms()); - } - } - let generation = browser.nav_generation(); - if generation != tab.nav_generation { - tab.nav_generation = generation; - tab.title = browser.title(); - let url = browser.url(); - if !url.is_empty() { - tab.url = url; - } - tab.loading = browser.is_loading(); - tab.can_go_back = browser.can_go_back(); - tab.can_go_forward = browser.can_go_forward(); - if let Some(favicon) = browser.take_favicon() { - tab.favicon = Some(Self::favicon_texture(cx, favicon)); - } - popups.extend(browser.take_popup_requests()); - changed = true; - } - let mode = browser.render_mode(); - if mode != tab.render_mode { - tab.render_mode = mode; - log!("tab {:?} render mode: {:?}", tab.id, mode); - changed = true; - } - if let Some(frame) = latest { - Self::apply_software_frame(cx, tab, frame); - if i == active { - redraw = true; - } - } - // A resize that arrived faster than CEF can take them: apply the - // last one now, so the end of a drag always reaches the page. - if let Some((w, h, dpi)) = tab.deferred_resize { - Self::sync_browser_size(tab, w, h, dpi, std::time::Instant::now()); - if i == active { - redraw = true; - } - } - } - for url in popups { - self.tabs.insert(&url, true); - self.apply_active_visibility(); - changed = true; - redraw = true; - } - if changed { - cx.widget_action(self.uid, WebViewAction::TabsChanged); - } - if redraw { - self.redraw(cx); - } - } - - // ------------------------------------------------------------------ - // Input routing (same mapping as the stock Browser widget) - // ------------------------------------------------------------------ - - fn browser_rect(&self, cx: &mut Cx) -> Option { - let area = self.draw_bg.area(); - if area.is_valid(cx) { - Some(area.rect(cx)) - } else { - None - } - } - - /// CEF takes mouse coordinates in view points (it applies the device - /// scale factor itself). - fn cef_position(&self, cx: &mut Cx, abs: Vec2d) -> Option<(i32, i32)> { - let rect = self.browser_rect(cx)?; - let local = abs - rect.pos; - Some((local.x.round() as i32, local.y.round() as i32)) - } - - fn send_mouse_move(&mut self, cx: &mut Cx, abs: Vec2d, modifiers: KeyModifiers, leave: bool) { - let Some((x, y)) = self.cef_position(cx, abs) else { - return; - }; - let m = BrowserKeys::cef_modifiers(modifiers, self.pressed_buttons); - if let Some(browser) = self.active_browser() { - let _ = browser.send_mouse_move(x, y, m, leave); - } - } - - fn send_mouse_click( - &mut self, - cx: &mut Cx, - abs: Vec2d, - modifiers: KeyModifiers, - button: Option, - mouse_up: bool, - click_count: i32, - ) { - let Some((x, y)) = self.cef_position(cx, abs) else { - return; - }; - let m = BrowserKeys::cef_modifiers(modifiers, self.pressed_buttons); - let b = BrowserKeys::cef_mouse_button(button); - if let Some(browser) = self.active_browser() { - let _ = browser.send_mouse_click(x, y, m, b, mouse_up, click_count.max(1)); - } - } - - fn send_mouse_wheel(&mut self, cx: &mut Cx, abs: Vec2d, modifiers: KeyModifiers, delta: Vec2d) { - let Some((x, y)) = self.cef_position(cx, abs) else { - return; - }; - let m = BrowserKeys::cef_modifiers(modifiers, self.pressed_buttons) - | makepad_cef::EVENTFLAG_PRECISION_SCROLLING_DELTA; - if let Some(browser) = self.active_browser() { - let _ = browser.send_mouse_wheel(x, y, m, delta.x.round() as i32, delta.y.round() as i32); - } - } - - fn send_key(&mut self, key_event: &KeyEvent, event_type: i32) { - let modifiers = BrowserKeys::key_event_modifiers(key_event); - let windows_key_code = BrowserKeys::windows_key_code(key_event.key_code); - let character = if key_event.modifiers.control - || key_event.modifiers.alt - || key_event.modifiers.logo - { - 0 - } else { - BrowserKeys::key_char(key_event.key_code, key_event.modifiers.shift) - .map(|ch| ch as u16) - .unwrap_or(0) - }; - let send_char = event_type == makepad_cef::KEY_EVENT_KEYDOWN - && character != 0 - && !key_event.modifiers.control - && !key_event.modifiers.alt - && !key_event.modifiers.logo - && BrowserKeys::sends_char_on_keydown(key_event.key_code); - if let Some(browser) = self.active_browser() { - let _ = browser.send_key_event( - event_type, - modifiers, - windows_key_code, - windows_key_code, - character, - character, - false, - ); - if send_char { - let _ = browser.send_key_event( - makepad_cef::KEY_EVENT_CHAR, - modifiers, - windows_key_code, - windows_key_code, - character, - character, - false, - ); - } - } - } - - fn update_ime_spot(&self, cx: &mut Cx, pos: Vec2d) { - let area = self.draw_bg.area(); - if area.is_valid(cx) { - cx.show_text_ime(area, pos); - } - } -} - -impl Widget for WebView { - fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) { - if let Event::Startup = event { - if !self.pump_started { - self.pump_started = true; - self.pump_timer = cx.start_interval(Self::PUMP_INTERVAL); - } - } - if let Event::Shutdown = event { - self.tabs.tabs.clear(); - return; - } - if !self.cef_ready && self.cef_init_frame.is_event(event).is_some() { - // The chrome has been drawn: bring Chromium up now. - match makepad_cef::initialize() { - Ok(()) => { - self.cef_ready = true; - log!( - "mpbrowser: CEF {} initialized at {} ms", - makepad_cef::CEF_VERSION, - crate::uptime_ms() - ); - } - Err(err) => { - let message = err.to_string(); - log!("CEF initialize failed: {message}"); - self.cef_init_error = Some(message); - } - } - self.redraw(cx); - } - if self.pump_timer.is_event(event).is_some() && self.cef_ready { - self.pump(cx); - } - - match event.hits_with_capture_overload(cx, self.draw_bg.area(), true) { - Hit::KeyFocus(_) => { - if let Some(browser) = self.active_browser() { - let _ = browser.set_focus(true); - } - if let Some(rect) = self.browser_rect(cx) { - self.update_ime_spot(cx, rect.pos); - } - } - Hit::KeyFocusLost(_) => { - if let Some(browser) = self.active_browser() { - let _ = browser.set_focus(false); - } - cx.hide_text_ime(); - self.suppress_next_paste_shortcut = false; - } - Hit::FingerDown(fe) => { - let button = fe.mouse_button().unwrap_or(MouseButton::PRIMARY); - self.pressed_buttons.insert(button); - cx.set_key_focus(self.draw_bg.area()); - if let Some(browser) = self.active_browser() { - let _ = browser.set_focus(true); - } - self.update_ime_spot(cx, fe.abs); - self.send_mouse_move(cx, fe.abs, fe.modifiers, false); - self.send_mouse_click( - cx, - fe.abs, - fe.modifiers, - Some(button), - false, - fe.tap_count as i32, - ); - } - Hit::FingerMove(fe) => { - self.send_mouse_move(cx, fe.abs, fe.modifiers, false); - } - Hit::FingerUp(fe) => { - let button = fe.mouse_button().unwrap_or(MouseButton::PRIMARY); - self.send_mouse_move(cx, fe.abs, fe.modifiers, false); - self.send_mouse_click( - cx, - fe.abs, - fe.modifiers, - Some(button), - true, - fe.tap_count as i32, - ); - self.pressed_buttons.remove(button); - } - Hit::FingerHoverIn(fe) | Hit::FingerHoverOver(fe) => { - self.send_mouse_move(cx, fe.abs, fe.modifiers, false); - } - Hit::FingerHoverOut(fe) => { - self.send_mouse_move(cx, fe.abs, fe.modifiers, true); - } - Hit::FingerScroll(fe) => { - self.send_mouse_wheel(cx, fe.abs, fe.modifiers, fe.scroll); - } - Hit::KeyDown(key_event) => { - // Browser-level shortcuts (Cmd+T/W/L/R/[ ]) are the app's; - // they never reach the page. - if key_event.modifiers.logo && crate::is_app_shortcut(&key_event) { - return; - } - if self.suppress_next_paste_shortcut - && key_event.key_code == KeyCode::KeyV - && key_event.modifiers.is_primary() - { - self.suppress_next_paste_shortcut = false; - } else { - self.send_key(&key_event, makepad_cef::KEY_EVENT_KEYDOWN); - } - } - Hit::KeyUp(key_event) => { - if key_event.modifiers.logo && crate::is_app_shortcut(&key_event) { - return; - } - self.send_key(&key_event, makepad_cef::KEY_EVENT_KEYUP); - } - Hit::TextInput(text_event) => { - let ime_pos = self - .browser_rect(cx) - .map(|rect| rect.pos) - .unwrap_or_default(); - self.update_ime_spot(cx, ime_pos); - if text_event.was_paste { - self.suppress_next_paste_shortcut = true; - } - let modifiers = BrowserKeys::cef_modifiers(cx.keyboard.modifiers(), MouseButton::empty()); - let char_data = BrowserKeys::char_event_data(&text_event.input); - if let Some(browser) = self.active_browser() { - if text_event.was_paste || text_event.replace_last || char_data.is_none() { - let _ = browser.ime_commit_text(&text_event.input); - } else if let Some((windows_key_code, character)) = char_data { - let _ = browser.send_key_event( - makepad_cef::KEY_EVENT_CHAR, - modifiers, - windows_key_code, - windows_key_code, - character, - character, - false, - ); - } - } - } - _ => {} - } - } - - fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { - let rect = cx.peek_walk_turtle(walk); - let dpi = cx.current_dpi_factor() as f32; - let width = (rect.size.x.max(1.0) * dpi as f64).round().max(1.0) as usize; - let height = (rect.size.y.max(1.0) * dpi as f64).round().max(1.0) as usize; - - self.ensure_active_browser(cx, width, height, dpi); - - let shown = self.tabs.active().filter(|t| t.painted).and_then(|t| { - t.texture - .clone() - .map(|texture| (texture, t.texture_alloc, t.texture_valid)) - }); - // Themed ground under the page, so a tab that has not painted yet - // (or a transparent page) never shows black. - self.draw_empty.draw_abs(cx, rect); - if !self.cef_ready { - let status = match &self.cef_init_error { - Some(err) => format!("browser engine failed to start: {err}"), - None => "starting browser engine…".to_string(), - }; - self.draw_status - .draw_abs(cx, rect.pos + dvec2(16.0, 14.0), &status); - if !self.cef_init_requested && self.cef_init_error.is_none() { - self.cef_init_requested = true; - self.cef_init_frame = cx.new_next_frame(); - } - } - match shown { - Some((texture, alloc, valid)) => { - // Sample only the part of the surface that holds page pixels - // and stretch it over the current rect: mid-drag that is the - // last good frame at a slightly older size (a fraction of a - // percent of scale), never a blank margin. - let scale = match (alloc, valid) { - (Some((aw, ah)), Some((vw, vh))) if aw > 0 && ah > 0 && vw > 0 && vh > 0 => { - vec2( - (vw as f32 / aw as f32).min(1.0), - (vh as f32 / ah as f32).min(1.0), - ) - } - _ => vec2(1.0, 1.0), - }; - self.draw_bg.image_scale = scale; - self.draw_bg.draw_vars.set_texture(0, &texture); - self.draw_bg.opacity = 1.0; - } - None => { - self.draw_bg.image_scale = vec2(1.0, 1.0); - self.draw_bg.draw_vars.empty_texture(0); - self.draw_bg.opacity = 0.0; - } - } - if let Some(tab) = self.tabs.active() { - trace!( - "draw rect {}x{} px, surface {:?}, valid {:?}, pending {:?}, painted={}", - width, - height, - tab.accel_target_size, - tab.texture_valid, - tab.pending_alloc, - tab.painted - ); - } - self.draw_bg.draw_walk(cx, walk); - cx.add_nav_stop(self.draw_bg.area(), NavRole::TextInput, Inset::default()); - DrawStep::done() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use makepad_widgets::browser::SURFACE_GRID; - - #[test] - fn surface_is_grid_rounded() { - assert_eq!(surface_alloc(1280, 860), (1280, 1024)); - assert_eq!(surface_alloc(1281, 1025), (1536, 1280)); - assert_eq!(surface_alloc(0, 0), (SURFACE_GRID, SURFACE_GRID)); - } - - /// The whole point of the grid: a drag walks many page sizes inside one - /// surface instead of handing CEF a blank one at every step (which is - /// what made the page area flash the themed ground for a whole drag). - #[test] - fn a_drag_walks_many_sizes_per_surface() { - let mut current = Some(surface_alloc(1600, 900)); - let mut steps = 0; - let mut swaps = 0; - for w in (1600..2600).step_by(8) { - let want = surface_alloc(w, 900); - steps += 1; - if needs_new_surface(current, None, want, false) { - swaps += 1; - current = Some(want); - } - } - assert_eq!(steps, 125); - // 1600 -> 2592 crosses the 1792 / 2048 / 2304 / 2560 lines: four - // surfaces for 125 drag steps, instead of 125. - assert_eq!(swaps, 4); - } - - #[test] - fn surface_grows_at_once_and_shrinks_only_when_settled() { - let current = Some((1536, 1024)); - // Outgrowing the surface would clip the blit: no waiting. - assert!(needs_new_surface(current, None, (1792, 1024), false)); - // Smaller page, still moving: keep the surface (and the frame in it). - assert!(!needs_new_surface(current, None, (1024, 1024), false)); - // Settled: hand back the slack. - assert!(needs_new_surface(current, None, (1024, 1024), true)); - // A bigger surface is already on its way; do not queue another. - assert!(!needs_new_surface( - current, - Some((1792, 1024)), - (1792, 1024), - true - )); - assert!(needs_new_surface( - current, - Some((1792, 1024)), - (2048, 1024), - false - )); - // Nothing yet: allocate. - assert!(needs_new_surface(None, None, (1280, 1024), false)); - } -} diff --git a/apps/mpfiles/Cargo.toml b/apps/mpfiles/Cargo.toml deleted file mode 100644 index 83b2c464a..000000000 --- a/apps/mpfiles/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "mpfiles" -version = "0.1.0" -edition = "2021" - -[dependencies] -makepad-widgets = { path = "../../widgets" } -mp-theme = { path = "../../libs/mp_theme" } -mp-wm-api = { path = "../../libs/mp_wm_api" } -# The ask panel's local model: an in-process Qwen GGUF on makepad-ggml, loaded -# only when the panel is first opened. -makepad-ai-hub = { path = "../../libs/ai/hub", default-features = false, features = ["llm"] } diff --git a/apps/mpfiles/resources/icons/archive.svg b/apps/mpfiles/resources/icons/archive.svg deleted file mode 100644 index fe331e3a2..000000000 --- a/apps/mpfiles/resources/icons/archive.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/apps/mpfiles/resources/icons/audio.svg b/apps/mpfiles/resources/icons/audio.svg deleted file mode 100644 index 7f3500eb4..000000000 --- a/apps/mpfiles/resources/icons/audio.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/mpfiles/resources/icons/back.svg b/apps/mpfiles/resources/icons/back.svg deleted file mode 100644 index 2ba808fa0..000000000 --- a/apps/mpfiles/resources/icons/back.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/mpfiles/resources/icons/bookmark.svg b/apps/mpfiles/resources/icons/bookmark.svg deleted file mode 100644 index 5894fde2e..000000000 --- a/apps/mpfiles/resources/icons/bookmark.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/chat.svg b/apps/mpfiles/resources/icons/chat.svg deleted file mode 100644 index fcc7e61ac..000000000 --- a/apps/mpfiles/resources/icons/chat.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/check.svg b/apps/mpfiles/resources/icons/check.svg deleted file mode 100644 index d6c1aff8c..000000000 --- a/apps/mpfiles/resources/icons/check.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/clock.svg b/apps/mpfiles/resources/icons/clock.svg deleted file mode 100644 index 33fb727cf..000000000 --- a/apps/mpfiles/resources/icons/clock.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/mpfiles/resources/icons/close.svg b/apps/mpfiles/resources/icons/close.svg deleted file mode 100644 index 9b51a468b..000000000 --- a/apps/mpfiles/resources/icons/close.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/code.svg b/apps/mpfiles/resources/icons/code.svg deleted file mode 100644 index 9189eb290..000000000 --- a/apps/mpfiles/resources/icons/code.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/mpfiles/resources/icons/compact.svg b/apps/mpfiles/resources/icons/compact.svg deleted file mode 100644 index 6bf963e0c..000000000 --- a/apps/mpfiles/resources/icons/compact.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/mpfiles/resources/icons/delete-forever.svg b/apps/mpfiles/resources/icons/delete-forever.svg deleted file mode 100644 index 8cf648bfe..000000000 --- a/apps/mpfiles/resources/icons/delete-forever.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/eye.svg b/apps/mpfiles/resources/icons/eye.svg deleted file mode 100644 index eb961549d..000000000 --- a/apps/mpfiles/resources/icons/eye.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/mpfiles/resources/icons/file.svg b/apps/mpfiles/resources/icons/file.svg deleted file mode 100644 index 8c27588cd..000000000 --- a/apps/mpfiles/resources/icons/file.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/mpfiles/resources/icons/filter.svg b/apps/mpfiles/resources/icons/filter.svg deleted file mode 100644 index 10d7e97df..000000000 --- a/apps/mpfiles/resources/icons/filter.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/mpfiles/resources/icons/folder.svg b/apps/mpfiles/resources/icons/folder.svg deleted file mode 100644 index 9303502a3..000000000 --- a/apps/mpfiles/resources/icons/folder.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/mpfiles/resources/icons/forward.svg b/apps/mpfiles/resources/icons/forward.svg deleted file mode 100644 index c008067e9..000000000 --- a/apps/mpfiles/resources/icons/forward.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/mpfiles/resources/icons/grid.svg b/apps/mpfiles/resources/icons/grid.svg deleted file mode 100644 index 8947e6a5f..000000000 --- a/apps/mpfiles/resources/icons/grid.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/mpfiles/resources/icons/home.svg b/apps/mpfiles/resources/icons/home.svg deleted file mode 100644 index 479e7b416..000000000 --- a/apps/mpfiles/resources/icons/home.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/mpfiles/resources/icons/image.svg b/apps/mpfiles/resources/icons/image.svg deleted file mode 100644 index 69007888a..000000000 --- a/apps/mpfiles/resources/icons/image.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/apps/mpfiles/resources/icons/info.svg b/apps/mpfiles/resources/icons/info.svg deleted file mode 100644 index 94fe1c9c2..000000000 --- a/apps/mpfiles/resources/icons/info.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/list.svg b/apps/mpfiles/resources/icons/list.svg deleted file mode 100644 index b5aa58753..000000000 --- a/apps/mpfiles/resources/icons/list.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/mpfiles/resources/icons/menu-dots.svg b/apps/mpfiles/resources/icons/menu-dots.svg deleted file mode 100644 index 4bb7d6708..000000000 --- a/apps/mpfiles/resources/icons/menu-dots.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/mpfiles/resources/icons/network.svg b/apps/mpfiles/resources/icons/network.svg deleted file mode 100644 index 404f9fa9f..000000000 --- a/apps/mpfiles/resources/icons/network.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/mpfiles/resources/icons/newfolder.svg b/apps/mpfiles/resources/icons/newfolder.svg deleted file mode 100644 index 34cbc7ef8..000000000 --- a/apps/mpfiles/resources/icons/newfolder.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/pdf.svg b/apps/mpfiles/resources/icons/pdf.svg deleted file mode 100644 index 2bc3e7c64..000000000 --- a/apps/mpfiles/resources/icons/pdf.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/mpfiles/resources/icons/reload.svg b/apps/mpfiles/resources/icons/reload.svg deleted file mode 100644 index f499bb741..000000000 --- a/apps/mpfiles/resources/icons/reload.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/search.svg b/apps/mpfiles/resources/icons/search.svg deleted file mode 100644 index 8a5655580..000000000 --- a/apps/mpfiles/resources/icons/search.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/mpfiles/resources/icons/star.svg b/apps/mpfiles/resources/icons/star.svg deleted file mode 100644 index 9f2a2d9e1..000000000 --- a/apps/mpfiles/resources/icons/star.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/mpfiles/resources/icons/terminal.svg b/apps/mpfiles/resources/icons/terminal.svg deleted file mode 100644 index d9be6b5c9..000000000 --- a/apps/mpfiles/resources/icons/terminal.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/text.svg b/apps/mpfiles/resources/icons/text.svg deleted file mode 100644 index 3799d7e3d..000000000 --- a/apps/mpfiles/resources/icons/text.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/mpfiles/resources/icons/trash.svg b/apps/mpfiles/resources/icons/trash.svg deleted file mode 100644 index 5a464e198..000000000 --- a/apps/mpfiles/resources/icons/trash.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/mpfiles/resources/icons/treemap.svg b/apps/mpfiles/resources/icons/treemap.svg deleted file mode 100644 index 4090a09e2..000000000 --- a/apps/mpfiles/resources/icons/treemap.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/treemap25.svg b/apps/mpfiles/resources/icons/treemap25.svg deleted file mode 100644 index e1f27c8c1..000000000 --- a/apps/mpfiles/resources/icons/treemap25.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/mpfiles/resources/icons/treemap3d.svg b/apps/mpfiles/resources/icons/treemap3d.svg deleted file mode 100644 index 62b274c03..000000000 --- a/apps/mpfiles/resources/icons/treemap3d.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/mpfiles/resources/icons/twist-down.svg b/apps/mpfiles/resources/icons/twist-down.svg deleted file mode 100644 index 20a67ecad..000000000 --- a/apps/mpfiles/resources/icons/twist-down.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/twist-right.svg b/apps/mpfiles/resources/icons/twist-right.svg deleted file mode 100644 index 31607accf..000000000 --- a/apps/mpfiles/resources/icons/twist-right.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/video.svg b/apps/mpfiles/resources/icons/video.svg deleted file mode 100644 index 6f7f6d383..000000000 --- a/apps/mpfiles/resources/icons/video.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/apps/mpfiles/src/bookmarks.rs b/apps/mpfiles/src/bookmarks.rs deleted file mode 100644 index 46a43c2e5..000000000 --- a/apps/mpfiles/src/bookmarks.rs +++ /dev/null @@ -1,185 +0,0 @@ -//! The sidebar's bookmarks: folders the user keeps, in their own section. -//! -//! Bookmarks are the one piece of mpfiles state that outlives the process, so -//! the format is the one a person can fix in an editor when it goes wrong: one -//! absolute path per line, in the order the sidebar shows them. That is also -//! what GNOME Files stores (`~/.config/gtk-3.0/bookmarks`), minus the URI -//! scheme nobody here needs. -//! -//! Nothing in this module touches the UI, so all of it is unit-testable. - -use std::{ - fs, - path::{Path, PathBuf}, -}; - -/// How many bookmarks the sidebar has room for. The section is a fixed set of -/// slots in the DSL, so the model has to agree with it: a bookmark past the -/// last slot would be saved and never shown, which is worse than refusing it. -pub const MAX_BOOKMARKS: usize = 12; - -/// The bookmarks file for a given home directory. -pub fn config_path(home: &Path) -> PathBuf { - home.join(".config").join("mpfiles").join("bookmarks") -} - -/// The bookmark list, in sidebar order, and where it is persisted. -#[derive(Clone, Debug, Default)] -pub struct Bookmarks { - file: PathBuf, - list: Vec, -} - -impl Bookmarks { - /// Read the user's bookmarks. A missing file is an empty list, not an - /// error: the first run of a fresh install must not look broken. - pub fn load(home: &Path) -> Self { - let file = config_path(home); - let list = fs::read_to_string(&file) - .map(|text| parse(&text)) - .unwrap_or_default(); - Self { file, list } - } - - /// A list held in memory only — for tests, and for a home we cannot write. - pub fn in_memory(list: Vec) -> Self { - Self { - file: PathBuf::new(), - list, - } - } - - pub fn list(&self) -> &[PathBuf] { - &self.list - } - - pub fn contains(&self, path: &Path) -> bool { - self.list.iter().any(|p| p == path) - } - - /// Bookmark `path`. False when it is already there or the sidebar is full - /// — either way nothing was added and the caller should say so. - pub fn add(&mut self, path: &Path) -> bool { - if self.contains(path) || self.list.len() >= MAX_BOOKMARKS { - return false; - } - self.list.push(path.to_path_buf()); - self.persist(); - true - } - - /// Drop `path` from the sidebar. False when it was never there. - pub fn remove(&mut self, path: &Path) -> bool { - let Some(at) = self.list.iter().position(|p| p == path) else { - return false; - }; - self.list.remove(at); - self.persist(); - true - } - - /// Write the list back. A failure is silent by design: a read-only home - /// must not stop the user from using a bookmark for this session. - fn persist(&self) { - if self.file.as_os_str().is_empty() { - return; - } - if let Some(dir) = self.file.parent() { - let _ = fs::create_dir_all(dir); - } - let _ = fs::write(&self.file, render(&self.list)); - } -} - -/// One path per line; blank lines and `#` comments are skipped so a -/// hand-edited file with a note in it still loads. -fn parse(text: &str) -> Vec { - let mut out = Vec::new(); - for line in text.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let path = PathBuf::from(line); - if !out.contains(&path) { - out.push(path); - } - if out.len() >= MAX_BOOKMARKS { - break; - } - } - out -} - -/// The file's text, newline-terminated so appending by hand works. -fn render(list: &[PathBuf]) -> String { - let mut out = String::new(); - for path in list { - out.push_str(&path.to_string_lossy()); - out.push('\n'); - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_one_path_per_line_skipping_notes() { - let list = parse("# my folders\n/a/b\n\n/c/d\n/a/b\n"); - assert_eq!(list, [PathBuf::from("/a/b"), PathBuf::from("/c/d")]); - } - - #[test] - fn renders_what_it_parses() { - let list = vec![PathBuf::from("/a/b"), PathBuf::from("/c d/e")]; - assert_eq!(parse(&render(&list)), list); - } - - #[test] - fn adds_removes_and_refuses_duplicates() { - let mut marks = Bookmarks::in_memory(Vec::new()); - assert!(marks.add(Path::new("/a"))); - assert!(!marks.add(Path::new("/a"))); - assert!(marks.contains(Path::new("/a"))); - assert!(marks.remove(Path::new("/a"))); - assert!(!marks.remove(Path::new("/a"))); - assert!(marks.list().is_empty()); - } - - #[test] - fn stops_at_the_last_sidebar_slot() { - let mut marks = Bookmarks::in_memory(Vec::new()); - for i in 0..MAX_BOOKMARKS { - assert!(marks.add(Path::new(&format!("/p{i}"))), "{i}"); - } - assert!(!marks.add(Path::new("/one-too-many"))); - assert_eq!(marks.list().len(), MAX_BOOKMARKS); - } - - #[test] - fn survives_a_round_trip_through_a_real_file() { - let home = std::env::temp_dir().join("mpfiles-test-bookmarks"); - let _ = fs::remove_dir_all(&home); - fs::create_dir_all(&home).unwrap(); - - let mut marks = Bookmarks::load(&home); - assert!(marks.list().is_empty(), "a fresh home has no bookmarks"); - assert!(marks.add(Path::new("/tmp/one"))); - assert!(marks.add(Path::new("/tmp/two"))); - - // A second process sees exactly what the first one saved. - let reread = Bookmarks::load(&home); - assert_eq!( - reread.list(), - [PathBuf::from("/tmp/one"), PathBuf::from("/tmp/two")] - ); - assert!(config_path(&home).is_file(), "the list is on disk where it says"); - - marks.remove(Path::new("/tmp/one")); - assert_eq!(Bookmarks::load(&home).list(), [PathBuf::from("/tmp/two")]); - - fs::remove_dir_all(&home).ok(); - } -} diff --git a/apps/mpfiles/src/chat_agent.rs b/apps/mpfiles/src/chat_agent.rs deleted file mode 100644 index 911e06423..000000000 --- a/apps/mpfiles/src/chat_agent.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! The file browser's thin adapter to the shared in-process local chat engine. - -use makepad_ai_hub::{ - hub::{AiHub, ChatConfig}, - hub_chat::HubChatSession, - local_llm::{LocalLlmConfig, ToolSpec}, -}; -use makepad_widgets::makepad_platform::thread::SignalToUI; - -use std::{ - path::{Path, PathBuf}, - sync::Arc, -}; - -pub use makepad_ai_hub::local_llm::ChatEvent; - -/// Where the weights live, relative to the checkout this was built from. -pub const MODEL_FILE: &str = "local/models/Qwen3.5-9B-UD-Q4_K_XL.gguf"; -/// The environment variable that overrides it. -pub const MODEL_ENV: &str = "MPFILES_CHAT_MODEL"; - -pub struct ChatAgent { - session: HubChatSession, -} - -impl ChatAgent { - /// Start loading the model. Nothing blocks: the load happens on the hub's - /// worker and reports itself through [`ChatAgent::poll`]. - pub fn start(model: PathBuf, system_prompt: String, tools: Vec) -> Self { - let config = ChatConfig { - llm: LocalLlmConfig::new(model), - system_prompt, - tools, - wake: Some(Arc::new(SignalToUI::set_ui_signal)), - }; - Self { - session: AiHub::in_process().start_local_chat(config), - } - } - - pub fn send_user_turn(&self, text: String) { - self.session.send_user_turn(text); - } - - pub fn send_tool_results(&self, results: Vec<(String, bool)>) { - self.session.send_tool_results(results); - } - - pub fn cancel(&self) { - self.session.cancel(); - } - - pub fn poll(&self) -> Vec { - self.session.poll() - } -} - -/// Where the weights are, or `None` when this machine has none. -/// -/// `MPFILES_CHAT_MODEL` wins; otherwise the file is looked for relative to the -/// working directory, then up from the binary (which finds `target/release` -/// runs from anywhere), then in the checkout this binary was compiled in. -pub fn model_path() -> Option { - if let Some(from_env) = std::env::var_os(MODEL_ENV) { - let path = PathBuf::from(from_env); - return path.is_file().then_some(path); - } - let relative = Path::new(MODEL_FILE); - if relative.is_file() { - return Some(relative.to_path_buf()); - } - if let Ok(exe) = std::env::current_exe() { - for base in exe.ancestors() { - let candidate = base.join(relative); - if candidate.is_file() { - return Some(candidate); - } - } - } - let checkout = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .map(|root| root.join(relative))?; - checkout.is_file().then_some(checkout) -} diff --git a/apps/mpfiles/src/chat_panel.rs b/apps/mpfiles/src/chat_panel.rs deleted file mode 100644 index 36a412d06..000000000 --- a/apps/mpfiles/src/chat_panel.rs +++ /dev/null @@ -1,336 +0,0 @@ -//! The chat panel: the transcript widget, the state it draws from, and the -//! panel's own shape. -//! -//! The panel lives on the right of the window and slides out over nothing — -//! it is a column in the body row, so opening it narrows the folder view -//! rather than covering it. Everything it shows comes from [`ChatState`], -//! which the shell hands down as the event scope; the transcript itself is a -//! `PortalList` so a long conversation costs the same as a short one. - -use makepad_widgets::*; - -/// Who said one line of the transcript. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum ChatVoice { - /// The person, as they typed it. - User, - /// The model. - #[default] - Assistant, - /// A tool it ran — "looked at ~/local/maps — 12 entries". Dim, because it - /// is what happened rather than what was said. - Tool, - /// The app itself: loading, errors, interruptions. - Info, -} - -#[derive(Clone, Debug)] -pub struct ChatLine { - pub voice: ChatVoice, - pub text: String, -} - -/// Everything the panel draws. Handed to the widget tree as the event scope. -#[derive(Default)] -pub struct ChatState { - pub lines: Vec, - /// The answer being written right now, shown as a live last row. - pub pending: String, -} - -/// The most lines kept. A file question is a short conversation, and a -/// transcript that grows without limit is a leak with a scrollbar. -const MAX_LINES: usize = 400; - -impl ChatState { - pub fn push(&mut self, voice: ChatVoice, text: impl Into) { - self.lines.push(ChatLine { - voice, - text: text.into(), - }); - if self.lines.len() > MAX_LINES { - let cut = self.lines.len() - MAX_LINES; - self.lines.drain(..cut); - } - } - - /// Turn whatever has streamed in so far into a real line. - pub fn commit_pending(&mut self) -> bool { - let text = std::mem::take(&mut self.pending); - let text = text.trim(); - if text.is_empty() { - return false; - } - let text = text.to_string(); - self.push(ChatVoice::Assistant, text); - true - } - - /// How many rows the list draws: the lines, plus the one being written. - pub fn row_count(&self) -> usize { - self.lines.len() + usize::from(!self.pending.trim().is_empty()) - } -} - -script_mod! { - use mod.prelude.widgets.* - use mod.widgets.* - - mod.widgets.MpfTranscriptBase = #(MpfTranscript::register_widget(vm)) - - let ChatLine = View{ - width: Fill - height: Fit - padding: Inset{left: 14 right: 12 top: 3 bottom: 3} - line_label := Label{ - width: Fill - height: Fit - draw_text +: { - color: mod.mpf.fg - text_style: theme.font_regular{font_size: 9.5} - } - } - } - - let ChatButton = RectView{ - width: Fit - height: 26 - padding: Inset{left: 12 right: 12} - align: Align{x: 0.5 y: 0.5} - cursor: MouseCursor.Hand - draw_bg +: { - color: mod.mpf.bg_light - border_color: mod.mpf.muted - border_size: 1.0 - } - chat_button_label := Label{ - draw_text +: { - color: mod.mpf.fg_bright - text_style: theme.font_regular{font_size: 9.0} - } - } - } - - mod.widgets.MpfChatPanel = SolidView{ - visible: false - width: 340 - height: Fill - flow: Down - draw_bg +: {color: mod.mpf.bg_dark} - - chat_header := SolidView{ - width: Fill - height: 36 - flow: Right - padding: Inset{left: 16 right: 10} - align: Align{y: 0.5} - draw_bg +: {color: mod.mpf.bg_light} - Label{ - width: Fill - text: "Ask about these files" - draw_text +: { - color: mod.mpf.fg_bright - text_style: theme.font_bold{font_size: 10.0} - } - } - chat_close := View{ - width: 20 - height: 20 - align: Align{x: 0.5 y: 0.5} - cursor: MouseCursor.Hand - Icon{ - icon_walk: Walk{width: 10 height: 10} - draw_icon +: { - svg: crate_resource("self://resources/icons/close.svg") - color: mod.mpf.fg_dim - } - } - } - } - - chat_status := Label{ - width: Fill - height: Fit - padding: Inset{left: 14 right: 12 top: 6 bottom: 4} - max_lines: 3 - text: "The model loads the first time you open this." - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 8.5} - } - } - - chat_transcript := mod.widgets.MpfTranscriptBase{ - width: Fill - height: Fill - chat_list := PortalList{ - width: Fill - height: Fill - UserLine := ChatLine{ - padding: Inset{left: 14 right: 12 top: 9 bottom: 3} - line_label +: { - draw_text +: { - color: mod.mpf.fg_bright - text_style: theme.font_bold{font_size: 9.5} - } - } - } - AssistantLine := ChatLine{} - ToolLine := ChatLine{ - padding: Inset{left: 22 right: 12 top: 2 bottom: 2} - line_label +: { - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 8.5} - } - } - } - InfoLine := ChatLine{ - padding: Inset{left: 14 right: 12 top: 2 bottom: 2} - line_label +: { - draw_text +: { - color: mod.mpf.accent - text_style: theme.font_regular{font_size: 8.5} - } - } - } - } - } - - chat_about := SolidView{ - width: Fill - height: Fit - padding: Inset{left: 14 right: 12 top: 5 bottom: 5} - draw_bg +: {color: mod.mpf.bg} - chat_about_label := Label{ - width: Fill - height: Fit - max_lines: 2 - text: "about: this folder" - draw_text +: { - color: mod.mpf.fg - text_style: theme.font_regular{font_size: 8.5} - } - } - } - - chat_input_row := View{ - width: Fill - height: Fit - flow: Right - spacing: 6 - padding: Inset{left: 12 right: 12 top: 8 bottom: 4} - align: Align{y: 0.5} - chat_input_box := View{ - width: Fill - height: 28 - chat_input := MpfInput{ - empty_text: "what is this?" - } - } - chat_send := ChatButton{ - chat_button_label +: {text: "Ask"} - } - chat_stop := ChatButton{ - visible: false - chat_button_label +: { - text: "Stop" - draw_text +: {color: mod.mpf.accent} - } - } - } - - chat_hint := Label{ - width: Fill - height: Fit - padding: Inset{left: 14 right: 12 bottom: 8} - max_lines: 2 - text: "Reads only — it can look at your files and never change them." - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 8.0} - } - } - } -} - -/// The transcript: a `PortalList` over the [`ChatState`] in the scope. -#[derive(Script, ScriptHook, Widget)] -pub struct MpfTranscript { - #[source] - source: ScriptObjectRef, - #[deref] - view: View, -} - -impl Widget for MpfTranscript { - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - while let Some(item) = self.view.draw_walk(cx, scope, walk).step() { - let Some(mut list) = item.borrow_mut::() else { - continue; - }; - let Some(chat) = scope.data.get_mut::() else { - continue; - }; - let total = chat.row_count(); - list.set_item_range(cx, 0, total); - while let Some(index) = list.next_visible_item(cx) { - // The list fills its viewport past the range it was given; - // the rows past the end have nothing to draw. - if index >= total { - continue; - } - let (voice, text) = match chat.lines.get(index) { - Some(line) => (line.voice, line.text.clone()), - None => (ChatVoice::Assistant, chat.pending.trim_end().to_string()), - }; - let template = match voice { - ChatVoice::User => id!(UserLine), - ChatVoice::Assistant => id!(AssistantLine), - ChatVoice::Tool => id!(ToolLine), - ChatVoice::Info => id!(InfoLine), - }; - let item = list.item(cx, index, template); - item.label(cx, ids!(line_label)).set_text(cx, &text); - item.draw_all(cx, &mut Scope::empty()); - } - } - DrawStep::done() - } - - fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - self.view.handle_event(cx, event, scope); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_pending_answer_is_a_row_until_it_is_committed() { - let mut chat = ChatState::default(); - assert_eq!(chat.row_count(), 0); - chat.push(ChatVoice::User, "what is this?"); - assert_eq!(chat.row_count(), 1); - chat.pending.push_str("It is "); - assert_eq!(chat.row_count(), 2); - assert!(chat.commit_pending()); - assert_eq!(chat.row_count(), 2); - assert_eq!(chat.lines[1].text, "It is"); - // Committing nothing adds nothing. - assert!(!chat.commit_pending()); - assert_eq!(chat.row_count(), 2); - } - - #[test] - fn the_transcript_stops_growing() { - let mut chat = ChatState::default(); - for i in 0..MAX_LINES + 50 { - chat.push(ChatVoice::Info, format!("line {i}")); - } - assert_eq!(chat.lines.len(), MAX_LINES); - // The oldest went, the newest stayed. - assert_eq!(chat.lines.last().unwrap().text, format!("line {}", MAX_LINES + 49)); - } -} diff --git a/apps/mpfiles/src/chat_tools.rs b/apps/mpfiles/src/chat_tools.rs deleted file mode 100644 index ac072de6d..000000000 --- a/apps/mpfiles/src/chat_tools.rs +++ /dev/null @@ -1,607 +0,0 @@ -//! What the chat panel's model is allowed to do: look, and nothing else. -//! -//! Four tools, all read-only — list a folder, read the head of a text file, -//! stat one path, and measure where a folder's bytes are. There is no write, -//! no move, no delete and no shell here, and there is no way to add one from -//! the model's side: [`run`] is a closed match over four names. -//! -//! Every path the model names goes through [`resolve`] first. It expands `~`, -//! folds `.` and `..` away *lexically* (so `~/../../etc` is refused before the -//! disk is touched at all), then canonicalises — which is what resolves any -//! symlink — and refuses anything that does not land inside the user's home. -//! A tool can therefore be handed any string at all and still only ever read -//! something the person running the app could already open in the browser. -//! -//! The tools run on a worker thread of their own, one job at a time in the -//! order they were asked for. Measuring a folder is a disk walk, and a file -//! browser that stops painting because its chat panel is counting bytes would -//! be worse than one with no chat panel. - -use std::{ - path::{Component, Path, PathBuf}, - sync::mpsc::{channel, Receiver, Sender}, - thread, - time::{Duration, Instant}, -}; - -use makepad_ai_hub::local_llm::{arg, ToolSpec}; - -use crate::{ - model::{self, FileEntry}, - vfs::vfs, -}; - -/// The most entries one `list_dir` ever returns. A folder with ten thousand -/// files in it answers the question "what is in here" with the first two -/// hundred and a count, not with ten thousand lines of context. -const LIST_LIMIT: usize = 200; -/// The most bytes `read_file` will ever hand back. -const READ_LIMIT: usize = 16 * 1024; -/// The default, and the ceiling, for `treemap_summary`'s child count. -const SUMMARY_TOP: usize = 12; -/// How long one `treemap_summary` may spend walking before it answers with -/// what it has and says the numbers are a floor. -const MEASURE_BUDGET: Duration = Duration::from_secs(4); -/// How deep that walk goes, and how many entries it will look at. -const MEASURE_DEPTH: usize = 10; -const MEASURE_ENTRIES: usize = 400_000; - -/// The tools, exactly as the model is told about them. -pub fn tools() -> Vec { - vec![ - ToolSpec::new( - "list_dir", - "List what is directly inside a folder: each entry's name, whether it is a folder, its kind and its size. Bounded to the first 200 entries. Use this before saying anything about what a folder contains.", - r#"{"type":"object","properties":{"path":{"type":"string","description":"folder path; ~ means the home folder, and a relative path is read from the folder the user is in"}},"required":["path"]}"#, - ), - ToolSpec::new( - "read_file", - "Read the beginning of a text file (at most 16 kB). Binary files are refused with a note of what they are instead. Use this to answer questions about what a file actually says.", - r#"{"type":"object","properties":{"path":{"type":"string"},"max_bytes":{"type":"integer","description":"how much to read, up to 16384"}},"required":["path"]}"#, - ), - ToolSpec::new( - "stat", - "One path's kind, size and modification time. Cheap — use it when you only need to know what something is.", - r#"{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}"#, - ), - ToolSpec::new( - "treemap_summary", - "Where a folder's bytes actually are: its heaviest direct children with their recursive sizes and file counts. This is what the treemap draws. Use it for 'what is taking up the space' questions.", - r#"{"type":"object","properties":{"path":{"type":"string"},"top":{"type":"integer","description":"how many children to list, up to 12"}},"required":["path"]}"#, - ), - ] -} - -/// One tool call, as it goes to the worker. -pub struct ToolJob { - pub name: String, - pub args: Vec<(String, String)>, - /// The folder the user is looking at: what a relative path is read from. - pub cwd: PathBuf, - pub home: PathBuf, -} - -/// One tool call, as it comes back. -pub struct ToolOutcome { - /// The dim line the transcript shows — "looked at ~/local/maps — 12 entries". - pub note: String, - /// What the model is told. - pub text: String, - pub is_error: bool, -} - -/// The tool worker: one thread, one job at a time, results in call order. -pub struct ToolRunner { - jobs: Sender, - results: Receiver, -} - -impl Default for ToolRunner { - fn default() -> Self { - Self::new() - } -} - -impl ToolRunner { - pub fn new() -> Self { - let (jobs, job_rx) = channel::(); - let (result_tx, results) = channel(); - thread::spawn(move || { - while let Ok(job) = job_rx.recv() { - if result_tx.send(run(&job)).is_err() { - return; - } - makepad_widgets::makepad_platform::thread::SignalToUI::set_ui_signal(); - } - }); - Self { jobs, results } - } - - pub fn submit(&self, job: ToolJob) { - let _ = self.jobs.send(job); - } - - pub fn drain(&self) -> Vec { - self.results.try_iter().collect() - } -} - -/// Run one tool. The whole of what the model can do to a filesystem. -pub fn run(job: &ToolJob) -> ToolOutcome { - let raw = arg(&job.args, "path"); - let resolved = resolve(raw, &job.home, &job.cwd); - let path = match resolved { - Ok(path) => path, - Err(error) => { - return ToolOutcome { - note: format!("refused {}", short(Path::new(raw), &job.home)), - text: error, - is_error: true, - } - } - }; - let shown = short(&path, &job.home); - match job.name.as_str() { - "list_dir" => finish(list_dir(&path), format!("looked at {shown}"), shown), - "read_file" => { - let max = number(arg(&job.args, "max_bytes")).unwrap_or(READ_LIMIT); - finish(read_file(&path, max), format!("read {shown}"), shown) - } - "stat" => finish(stat(&path), format!("checked {shown}"), shown), - "treemap_summary" => { - let top = number(arg(&job.args, "top")) - .unwrap_or(SUMMARY_TOP) - .clamp(1, SUMMARY_TOP); - finish(summary(&path, top), format!("measured {shown}"), shown) - } - other => ToolOutcome { - note: format!("unknown tool {other}"), - text: format!("there is no tool called {other}"), - is_error: true, - }, - } -} - -/// A tool's result plus the one-line note the transcript shows. The note gets -/// the tool's own tail ("— 12 entries") when it succeeded. -fn finish(result: Result<(String, String), String>, verb: String, shown: String) -> ToolOutcome { - match result { - Ok((tail, text)) => ToolOutcome { - note: if tail.is_empty() { - verb - } else { - format!("{verb} — {tail}") - }, - text, - is_error: false, - }, - Err(error) => ToolOutcome { - note: format!("could not read {shown}"), - text: error, - is_error: true, - }, - } -} - -// ------------------------------------------------------------- the sandbox - -/// The path the model named, as a real path inside the user's home — or an -/// explanation of why it is not going to get one. -pub fn resolve(raw: &str, home: &Path, cwd: &Path) -> Result { - let wanted = expand(raw, home, cwd); - // Lexically first, so a path that walks out of the home is refused without - // the disk being touched at all. - if !within(&wanted, home) { - return Err(format!( - "refused: {} is outside {} — this assistant only looks inside the home folder", - wanted.display(), - home.display() - )); - } - // Then for real: canonicalising is what follows a symlink, and a link out - // of the home is exactly the case the lexical check cannot see. - let real = match wanted.canonicalize() { - Ok(real) => real, - // The demo filesystem has no paths on disk at all, and neither does a - // path that is simply not there; both are the same answer here. - Err(_) if crate::vfs::is_demo() => wanted.clone(), - Err(error) => return Err(format!("{}: {error}", wanted.display())), - }; - let real_home = home.canonicalize().unwrap_or_else(|_| home.to_path_buf()); - if !within(&real, &real_home) { - return Err(format!( - "refused: {} leads outside {} — this assistant only looks inside the home folder", - wanted.display(), - home.display() - )); - } - Ok(real) -} - -/// `~`, relative paths and `.`/`..` folded away, without touching the disk. -pub fn expand(raw: &str, home: &Path, cwd: &Path) -> PathBuf { - let raw = raw.trim().trim_matches('"'); - let joined = if raw.is_empty() || raw == "." { - cwd.to_path_buf() - } else if raw == "~" { - home.to_path_buf() - } else if let Some(rest) = raw.strip_prefix("~/") { - home.join(rest) - } else { - let path = Path::new(raw); - if path.is_absolute() { - path.to_path_buf() - } else { - cwd.join(path) - } - }; - normalize(&joined) -} - -/// `.` and `..` resolved textually. `..` past the root stays at the root, -/// which is what every filesystem does and what keeps the check below honest. -fn normalize(path: &Path) -> PathBuf { - let mut out = PathBuf::new(); - for part in path.components() { - match part { - Component::CurDir => {} - Component::ParentDir => { - if !out.pop() { - // Nothing above the root: keep it, so the result stays - // absolute and the containment check still means something. - out.push(Component::RootDir.as_os_str()); - } - } - other => out.push(other.as_os_str()), - } - } - if out.as_os_str().is_empty() { - out.push(Component::RootDir.as_os_str()); - } - out -} - -/// Is `path` the home folder, or something inside it? -pub fn within(path: &Path, home: &Path) -> bool { - path == home || path.starts_with(home) -} - -/// `~/rest` for anything under the home, the full path otherwise. -pub fn short(path: &Path, home: &Path) -> String { - match path.strip_prefix(home) { - Ok(rest) if rest.as_os_str().is_empty() => "~".to_string(), - Ok(rest) => format!("~/{}", rest.display()), - Err(_) => path.display().to_string(), - } -} - -fn number(text: &str) -> Option { - text.trim().parse::().ok() -} - -// ---------------------------------------------------------------- the tools - -fn list_dir(path: &Path) -> Result<(String, String), String> { - if !vfs().is_dir(path) { - return Err(format!("{} is not a folder", path.display())); - } - let entries = vfs().read_dir(path, false)?; - let total = entries.len(); - let mut out = format!("{} — {total} entries", path.display()); - if total > LIST_LIMIT { - out.push_str(&format!(" (first {LIST_LIMIT} shown)")); - } - out.push('\n'); - for entry in entries.iter().take(LIST_LIMIT) { - out.push_str(&format!( - "{} {:<10} {}\n", - if entry.is_dir { "dir " } else { "file" }, - entry.size_text(), - entry.name, - )); - } - Ok((format!("{total} entries"), out)) -} - -fn read_file(path: &Path, max_bytes: usize) -> Result<(String, String), String> { - if vfs().is_dir(path) { - return Err(format!( - "{} is a folder — use list_dir on it", - path.display() - )); - } - let real = vfs().real_path(path); - let data = std::fs::read(&real).map_err(|e| format!("{}: {e}", path.display()))?; - let size = data.len(); - let kind = model::kind_for(path, false); - let looked_at = data.len().min(4096); - if data[..looked_at].contains(&0) { - return Ok(( - "binary".to_string(), - format!( - "{} is a {} of {} — not text, so there is nothing to read out of it here", - path.display(), - kind.label().to_lowercase(), - model::format_size(size as u64, false), - ), - )); - } - let cut = size.min(max_bytes.clamp(1, READ_LIMIT)); - let text = match std::str::from_utf8(&data[..cut]) { - Ok(text) => text.to_string(), - Err(error) if error.valid_up_to() > cut / 2 => { - String::from_utf8_lossy(&data[..error.valid_up_to()]).into_owned() - } - Err(_) => { - return Ok(( - "binary".to_string(), - format!( - "{} is a {} of {} — not text", - path.display(), - kind.label().to_lowercase(), - model::format_size(size as u64, false), - ), - )) - } - }; - let mut out = format!( - "{} — {}{}\n", - path.display(), - model::format_size(size as u64, false), - if cut < size { - format!(", first {} shown", model::format_size(cut as u64, false)) - } else { - String::new() - }, - ); - out.push_str(&text); - Ok((model::format_size(cut as u64, false), out)) -} - -fn stat(path: &Path) -> Result<(String, String), String> { - let entry = entry_for(path)?; - let mut out = format!( - "{}\nkind: {}\nsize: {}\nmodified: {}", - path.display(), - entry.kind_text(), - if entry.is_dir { - entry.size_text() - } else { - model::format_size(entry.size, false) - }, - entry.modified_text(), - ); - if !entry.permissions.is_empty() { - out.push_str(&format!("\npermissions: {}", entry.permissions)); - } - Ok((entry.kind_text().to_lowercase(), out)) -} - -/// The entry for one path: straight off the disk when there is one, out of the -/// parent's listing otherwise (which is the only way the demo can answer). -fn entry_for(path: &Path) -> Result { - if let Some(entry) = model::entry_at(path) { - return Ok(entry); - } - let parent = path - .parent() - .ok_or_else(|| format!("{} has no parent to look in", path.display()))?; - vfs() - .read_dir(parent, true)? - .into_iter() - .find(|e| e.path == path) - .ok_or_else(|| format!("there is nothing at {}", path.display())) -} - -fn summary(path: &Path, top: usize) -> Result<(String, String), String> { - if !vfs().is_dir(path) { - // A file has no children; saying so beats an empty table. - return stat(path); - } - let entries = vfs().read_dir(path, false)?; - let deadline = Instant::now() + MEASURE_BUDGET; - let mut budget = MEASURE_ENTRIES; - let mut measured: Vec<(String, u64, u32, bool)> = Vec::new(); - let mut complete = true; - for entry in &entries { - if entry.is_dir { - let (bytes, files, done) = measure(&entry.path, deadline, &mut budget, 0); - complete &= done; - measured.push((entry.name.clone(), bytes, files, done)); - } else { - measured.push((entry.name.clone(), entry.size, 1, true)); - } - } - let total: u64 = measured.iter().map(|m| m.1).sum(); - let files: u32 = measured.iter().map(|m| m.2).sum(); - measured.sort_by(|a, b| b.1.cmp(&a.1)); - let shown = measured.len().min(top); - let mut out = format!( - "{} — {} in {} files across {} entries{}\n", - path.display(), - model::format_size(total, false), - files, - entries.len(), - if complete { - "" - } else { - " (the walk was cut short, so the sizes are a floor)" - }, - ); - for (name, bytes, count, done) in measured.iter().take(shown) { - out.push_str(&format!( - "{:>10}{} {:>5.1}% {} ({} files)\n", - model::format_size(*bytes, false), - if *done { " " } else { "+" }, - *bytes as f64 * 100.0 / total.max(1) as f64, - name, - count, - )); - } - if measured.len() > shown { - out.push_str(&format!("…and {} smaller\n", measured.len() - shown)); - } - Ok((format!("{} entries", entries.len()), out)) -} - -/// A folder's recursive bytes and file count, bounded by a deadline, an entry -/// budget and a depth. Returns false when it ran out of one of them — a number -/// that stopped early is a floor, and the caller says so rather than passing -/// it off as the answer. -fn measure(path: &Path, deadline: Instant, budget: &mut usize, depth: usize) -> (u64, u32, bool) { - if depth >= MEASURE_DEPTH || *budget == 0 || Instant::now() >= deadline { - return (0, 0, false); - } - // Never walk through a link: the tree below it is somebody else's, and it - // can lead straight back to where we started. - if std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) { - return (0, 0, true); - } - if model::skip_for_scan(path) { - return (0, 0, true); - } - let Ok(entries) = vfs().read_dir(path, true) else { - return (0, 0, true); - }; - let mut bytes = 0u64; - let mut files = 0u32; - let mut complete = true; - for entry in entries { - *budget = budget.saturating_sub(1); - if entry.is_dir { - let (child_bytes, child_files, done) = measure(&entry.path, deadline, budget, depth + 1); - bytes += child_bytes; - files += child_files; - complete &= done; - } else { - bytes += entry.size; - files += 1; - } - if *budget == 0 || Instant::now() >= deadline { - return (bytes, files, false); - } - } - (bytes, files, complete) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn home() -> PathBuf { - PathBuf::from("/Users/someone") - } - - #[test] - fn a_tilde_path_lands_in_the_home() { - let cwd = home().join("Documents"); - assert_eq!( - expand("~/Documents/notes", &home(), &cwd), - home().join("Documents/notes") - ); - assert_eq!(expand("~", &home(), &cwd), home()); - // Nothing at all means "where the user is". - assert_eq!(expand("", &home(), &cwd), cwd); - } - - #[test] - fn a_relative_path_is_read_from_the_folder_the_user_is_in() { - let cwd = home().join("Pictures"); - assert_eq!(expand("holiday", &home(), &cwd), cwd.join("holiday")); - assert_eq!(expand("./holiday/..", &home(), &cwd), cwd); - } - - #[test] - fn dot_dot_is_folded_away_before_anything_is_read() { - let cwd = home().join("Documents"); - assert_eq!(expand("~/a/../b", &home(), &cwd), home().join("b")); - assert_eq!(expand("../Pictures", &home(), &cwd), home().join("Pictures")); - // Past the root it stops at the root rather than going negative. - assert_eq!(expand("/../../..", &home(), &cwd), PathBuf::from("/")); - } - - #[test] - fn paths_outside_the_home_are_refused() { - let cwd = home(); - for escape in [ - "/etc/passwd", - "~/../../etc/passwd", - "../../../etc", - "/Users/someone_else/Documents", - "/", - ] { - let error = resolve(escape, &home(), &cwd) - .expect_err(&format!("{escape} should have been refused")); - assert!( - error.contains("refused"), - "{escape} gave the wrong reason: {error}" - ); - } - } - - #[test] - fn a_sibling_whose_name_starts_with_the_home_is_not_inside_it() { - // The string "/Users/someone-backup" starts with "/Users/someone", - // and a prefix test on strings rather than components would let it in. - assert!(!within(Path::new("/Users/someone-backup/x"), &home())); - assert!(within(Path::new("/Users/someone/x"), &home())); - assert!(within(&home(), &home())); - } - - #[test] - fn the_home_itself_resolves() { - // Uses the real home, because resolve() canonicalises. - let real_home = model::home_dir(); - let resolved = resolve("~", &real_home, &real_home); - assert!(resolved.is_ok(), "{resolved:?}"); - } - - #[test] - fn every_tool_has_a_schema_and_a_safe_name() { - let tools = tools(); - assert_eq!(tools.len(), 4); - for tool in &tools { - assert!(tool - .name - .chars() - .all(|c| c.is_ascii_lowercase() || c == '_')); - assert!(tool.parameters.starts_with('{')); - assert!(tool.parameters.contains("\"properties\"")); - assert!(!tool.description.is_empty()); - } - // Nothing that writes, moves, deletes or runs anything. - for forbidden in ["write", "delete", "move", "rename", "run", "exec", "shell"] { - assert!( - !tools.iter().any(|t| t.name.contains(forbidden)), - "a {forbidden} tool must never exist here" - ); - } - } - - #[test] - fn an_unknown_tool_is_an_error_not_a_panic() { - let home = model::home_dir(); - let outcome = run(&ToolJob { - name: "rm_rf".to_string(), - args: vec![("path".to_string(), "~".to_string())], - cwd: home.clone(), - home, - }); - assert!(outcome.is_error); - assert!(outcome.text.contains("no tool called")); - } - - #[test] - fn a_refused_path_never_reaches_a_tool() { - let home = model::home_dir(); - let outcome = run(&ToolJob { - name: "read_file".to_string(), - args: vec![("path".to_string(), "/etc/passwd".to_string())], - cwd: home.clone(), - home, - }); - assert!(outcome.is_error); - assert!(outcome.text.contains("refused")); - assert!(!outcome.text.contains("root:")); - } -} diff --git a/apps/mpfiles/src/contents.rs b/apps/mpfiles/src/contents.rs deleted file mode 100644 index 45f551d9b..000000000 --- a/apps/mpfiles/src/contents.rs +++ /dev/null @@ -1,1671 +0,0 @@ -//! The folder body: four views over one model. -//! -//! Icons, List, Compact and the Treemap all read the same `rows` + `selected`, -//! so switching a view never loses the selection, the filter or the sort. Only -//! the visible page draws, which is why the drawing code can tell which list it -//! was handed from `mode` alone. -//! -//! Selection is held as a set of **paths**, not indices. That is what lets a -//! re-sort, a re-listing, a rename or a folder expanding under the cursor -//! leave the selection exactly where the user put it — an index-based -//! selection silently moves to whatever file slid into that slot. - -use makepad_widgets::*; - -use std::{ - collections::{HashMap, HashSet}, - path::{Path, PathBuf}, -}; - -use crate::{ - model::{format_size, FileEntry, SortKey, SortSpec}, - theme::Palette, - thumbs::{clear_thumb, fill_thumb, Thumbs}, - treemap_view::{TreemapAction, TreemapViewRef, TreemapViewWidgetExt}, -}; - -/// Cells in a grid row. The row template carries this many; the ones past the -/// column count for the current width are hidden, and hidden views take no -/// layout, so the visible cells always split the width evenly. -pub const GRID_MAX_COLUMNS: usize = 12; -/// Width the auto-fitted Name column leaves for the vertical scroll bar, so -/// the last column's text never ends up underneath it. -const SCROLL_BAR_ALLOWANCE: f64 = 16.0; -/// One indent step of the List view's folder tree, in points. -const INDENT_STEP: f64 = 15.0; - -/// The four icon sizes Cmd+plus and Cmd+minus cycle: (tile width, row height, -/// thumbnail height). The name always gets its two lines under the picture, so -/// only the picture grows. -pub const ZOOM_LEVELS: [(f64, f64, f64); 4] = [ - (102.0, 100.0, 38.0), - (132.0, 128.0, 56.0), - (176.0, 168.0, 90.0), - (236.0, 220.0, 140.0), -]; -/// The size a window opens at — the one round two shipped. -pub const DEFAULT_ZOOM: usize = 1; - -/// The columns a fresh window shows. Created and Permissions are off until -/// the user picks them out of the column menu. -pub const DEFAULT_COLUMNS: [SortKey; 4] = [ - SortKey::Name, - SortKey::Size, - SortKey::Kind, - SortKey::Modified, -]; - -script_mod! { - use mod.prelude.widgets.* - use mod.widgets.* - - let FileTile = View{ - width: Fill - height: Fill - flow: Overlay - cursor: MouseCursor.Hand - tile_sel := SolidView{ - visible: false - width: Fill - height: Fill - draw_bg +: {color: mod.mpf.sel} - } - tile_body := View{ - width: Fill - height: Fill - flow: Down - spacing: 6 - padding: Inset{left: 4 right: 4 top: 10 bottom: 6} - align: Align{x: 0.5 y: 0.0} - tile_thumb := MpfThumb{ - width: Fill - height: 56 - } - tile_name_slot := View{ - width: Fill - // Two lines at this size need 40pt; 34 clipped the descenders - // of the second one. - height: 40 - flow: Overlay - tile_name := Label{ - width: Fill - height: Fill - align: Align{x: 0.5 y: 0.0} - max_lines: 2 - text_overflow: TextOverflow.Ellipsis - draw_text +: { - color: mod.mpf.fg - text_style: theme.font_regular{font_size: 9.5} - } - } - // The inline editor sits in a View of its own because only - // View, Label, Image and Button carry `visible`. - tile_edit_box := View{ - visible: false - width: Fill - height: 22 - tile_edit := MpfInput{} - } - } - } - } - - let CompactRow = View{ - width: Fill - height: 26 - flow: Overlay - cursor: MouseCursor.Hand - row_sel := SolidView{ - visible: false - width: Fill - height: Fill - draw_bg +: {color: mod.mpf.sel} - } - row_body := View{ - width: Fill - height: Fill - flow: Right - spacing: 9 - // The right padding clears the scroll bar so the size column's - // last glyph is not clipped against it. - padding: Inset{left: 16 right: 24} - align: Align{y: 0.5} - row_thumb := MpfThumb{ - width: 18 - height: 18 - img +: {width: 18 height: 18} - } - row_name_slot := View{ - width: Fill - height: Fill - flow: Overlay - align: Align{y: 0.5} - row_name := Label{ - width: Fill - max_lines: 1 - text_overflow: TextOverflow.Ellipsis - draw_text +: { - color: mod.mpf.fg - text_style: theme.font_regular{font_size: 9.5} - } - } - row_edit_box := View{ - visible: false - width: Fill - height: 22 - row_edit := MpfInput{} - } - } - // The size right-aligns inside its own box: a Label's own align - // does not push its ink to the box edge, and text that runs on - // ends up under the scroll bar. - row_size_box := View{ - width: 96 - height: Fill - align: Align{x: 1.0 y: 0.5} - row_size := Label{ - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 9.0} - } - } - } - } - } - - mod.widgets.FileContentsBase = #(FileContents::register_widget(vm)) - mod.widgets.FileContents = set_type_default() do mod.widgets.FileContentsBase{ - width: Fill - height: Fill - - icons_page := View{ - width: Fill - height: Fill - icons_list := PortalList{ - width: Fill - height: Fill - scroll_bar: ScrollBar{} - GridRow := View{ - width: Fill - height: 128 - flow: Right - padding: Inset{left: 12 right: 12} - c0 := FileTile{} - c1 := FileTile{} - c2 := FileTile{} - c3 := FileTile{} - c4 := FileTile{} - c5 := FileTile{} - c6 := FileTile{} - c7 := FileTile{} - c8 := FileTile{} - c9 := FileTile{} - c10 := FileTile{} - c11 := FileTile{} - } - } - } - - list_page := View{ - visible: false - width: Fill - height: Fill - list_grid := DataGrid{ - width: Fill - height: Fill - rows: 0 - cols: 4 - show_row_headers: false - zebra_stripes: true - allow_col_resize: true - allow_col_reorder: false - default_row_height: 28.0 - col_header_height: 30.0 - cell_pad_x: 12.0 - color_bg: mod.mpf.bg - color_cell: mod.mpf.bg - color_cell_alt: mod.mpf.stripe - color_text: mod.mpf.fg - color_header: mod.mpf.bg_light - color_header_active: mod.mpf.hover - color_header_text: mod.mpf.fg_dim - color_selection: mod.mpf.sel_soft - color_selection_border: mod.mpf.accent - color_drag_marker: mod.mpf.accent - color_resize_guide: mod.mpf.muted - draw_cell +: { - border_color: uniform(mod.mpf.bg) - } - draw_text +: { - color: mod.mpf.fg - text_style: theme.font_regular{font_size: 9.5} - } - draw_text_bold +: { - color: mod.mpf.fg_dim - text_style: theme.font_bold{font_size: 9.0} - } - NameCell := View{ - width: Fill - height: Fill - flow: Right - spacing: 7 - padding: Inset{left: 6 right: 6} - align: Align{y: 0.5} - // The tree indent: a spacer whose width is the row's depth. - cell_indent := View{ - width: 0 - height: 1 - } - // The disclosure triangle. A folder that can be opened - // shows one; everything else shows an empty box of the - // same width so the names still line up. - // The triangle is an icon, not a character: ▸ and ▾ are - // not in the UI font and came out as empty .notdef boxes. - cell_twist := View{ - width: 13 - height: Fill - flow: Overlay - align: Align{x: 0.5 y: 0.5} - cursor: MouseCursor.Hand - twist_closed := View{ - visible: false - width: Fill - height: Fill - align: Align{x: 0.5 y: 0.5} - Icon{ - icon_walk: Walk{width: 7 height: 7} - draw_icon +: { - svg: crate_resource("self://resources/icons/twist-right.svg") - color: mod.mpf.fg_dim - } - } - } - twist_open := View{ - visible: false - width: Fill - height: Fill - align: Align{x: 0.5 y: 0.5} - Icon{ - icon_walk: Walk{width: 7 height: 7} - draw_icon +: { - svg: crate_resource("self://resources/icons/twist-down.svg") - color: mod.mpf.fg - } - } - } - } - cell_thumb := MpfThumb{ - width: 18 - height: 18 - img +: {width: 18 height: 18} - } - cell_name_slot := View{ - width: Fill - height: Fill - flow: Overlay - align: Align{y: 0.5} - cell_name := Label{ - width: Fill - max_lines: 1 - text_overflow: TextOverflow.Ellipsis - draw_text +: { - color: mod.mpf.fg - text_style: theme.font_regular{font_size: 9.5} - } - } - cell_edit_box := View{ - visible: false - width: Fill - height: 22 - cell_edit := MpfInput{} - } - } - } - } - } - - compact_page := View{ - visible: false - width: Fill - height: Fill - compact_list := PortalList{ - width: Fill - height: Fill - scroll_bar: ScrollBar{} - CompactRow := CompactRow{} - } - } - - treemap_page := View{ - visible: false - width: Fill - height: Fill - treemap := MpfTreemap{} - } - } -} - -/// The ways to look at a folder. The last three are one treemap under three -/// projections — flat, extruded, perspective — sharing scan, camera and pick. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum ViewMode { - #[default] - Icons, - List, - Compact, - Treemap, -} - -impl ViewMode { - pub fn label(self) -> &'static str { - match self { - ViewMode::Icons => "Icons", - ViewMode::List => "List", - ViewMode::Compact => "Compact", - ViewMode::Treemap => "Treemap", - } - } - - /// Whether this mode shows the treemap page, whatever the projection. - pub fn is_treemap(self) -> bool { - matches!(self, ViewMode::Treemap) - } -} - -/// One line of the display list: an entry, and where it sits in the List -/// view's folder tree. -#[derive(Clone, Debug)] -pub struct Row { - pub entry: FileEntry, - /// 0 for the folder's own entries, 1 for the children of an expanded - /// folder, and so on. Only the List view draws it. - pub depth: usize, - /// True when this folder can be opened in place — a folder, in the List - /// view, that we have not found to be empty. - pub expandable: bool, - pub expanded: bool, -} - -/// What a click in the body means to the app. -#[derive(Clone, Debug)] -pub enum FileContentsAction { - Open(FileEntry), - Selected(FileEntry), - /// A column header was clicked; the listing is re-ordered. - Sorted, - /// The view changed what it is saying about itself and the status line - /// should ask it again — the treemap picking something the listing does - /// not hold, which is most of the map. - Restated, - /// The inline editor was confirmed: `path` should become `name`. - Renamed(PathBuf, String), - /// The inline editor was dismissed with nothing changed. - RenameCancelled, - /// A drag of these paths ended at this window point. Only the shell knows - /// what is under it. - Dropped(Vec, DVec2), - /// A folder in the List tree was opened and its children are not loaded. - NeedChildren(PathBuf), - /// The map's filter chip was clicked away; the filter controls should - /// show themselves cleared. - MapFilterCleared, - /// A secondary press: open the context menu at `at`, for `entry` when the - /// press landed on one and for the folder itself when it landed on the - /// empty space. - Context { - at: DVec2, - entry: Option, - }, -} - -/// Where one row was drawn this frame. -#[derive(Clone, Copy, Debug)] -pub struct HitRect { - pub position: usize, - pub rect: Rect, -} - -/// A secondary press, resolved against what was drawn. -#[derive(Clone, Debug)] -pub struct ContextHit { - pub at: DVec2, - pub position: Option, - /// The target when it is not in the current listing at all — a file the - /// treemap found several folders down. The menu acts on it exactly as it - /// acts on a row, because it is exactly as real a file. - pub off_list: Option, -} - -/// The colors the body sets from Rust. Everything else comes straight from -/// `mod.mpf`; only the fast text-cell path needs a `Vec4f` in hand. -#[derive(Clone, Copy)] -pub struct Colors { - pub dim: Vec4f, - pub selection: Vec4f, -} - -impl Default for Colors { - fn default() -> Self { - let palette = Palette::tokyo_night(); - Self { - dim: Palette::vec4(&palette.fg_dim), - selection: Palette::vec4(&palette.sel), - } - } -} - -/// A press the body wants the shell to look at. It carries nothing: its only -/// job is to make sure an `Actions` event happens at all, because a secondary -/// press that no widget claims produces no actions of its own and the shell's -/// action handler would never run. -#[derive(Clone, Debug, Default, PartialEq)] -pub enum ContentsPing { - #[default] - Ping, -} - -#[derive(Script, ScriptHook, Widget)] -pub struct FileContents { - #[deref] - view: View, - /// The current folder's own entries, as the worker read them. - #[rust] - entries: Vec, - /// Children of folders the List view has expanded, keyed by folder. - #[rust] - children: HashMap>, - #[rust] - expanded: HashSet, - /// Folders whose children have been asked for but not yet delivered. - #[rust] - pending: HashSet, - /// The display list every view walks: filtered, sorted, and — in the List - /// view — flattened out of the expanded tree. - #[rust] - rows: Vec, - #[rust] - filter: String, - #[rust] - sort: SortSpec, - /// The selection, by path so it survives everything that renumbers rows. - #[rust] - selected: HashSet, - /// The last path the user landed on: the one Space previews, Cmd+I - /// describes, and Shift+click extends from. - #[rust] - anchor: Option, - #[rust] - mode: ViewMode, - /// Tiles per row in the icons view, from the current width. - #[rust] - grid_columns: usize, - #[rust] - last_width: f64, - #[rust] - colors: Colors, - #[rust] - thumbs: Thumbs, - #[rust] - grid_ready: bool, - /// Set once the user drags a column divider: from then on the widths are - /// theirs for the rest of the session and the Name column stops - /// auto-fitting to the window. - #[rust] - columns_user_sized: bool, - /// The columns the list view shows, in order. Name always leads; the - /// rest are the user's to pick. - #[rust] - columns: Vec, - /// The window width the Name column was last fitted to. Re-fitting only - /// when this changes is what lets a column drag stick: fitting every - /// frame would pull the width back under the user's hand. - #[rust] - fitted_width: f64, - /// The icon size, an index into [`ZOOM_LEVELS`]. - #[rust] - zoom: usize, - /// The file being renamed in place, if any. - #[rust] - renaming: Option, - /// The editor cannot take key focus until it has been drawn once and has - /// an area to focus; this is the frame after. - #[rust] - rename_focus: NextFrame, - #[rust] - rename_tries: usize, - /// The file whose editor has already been filled with its current name. - /// Filling it every frame would erase what the user is typing. - #[rust] - rename_seeded: Option, - /// Where a press started, so a release somewhere else reads as a drag. - #[rust] - press_at: Option, - /// The screen rectangle of every row drawn this frame, by display - /// position. A context menu has to know what is under the pointer even - /// though the widget under it swallowed the press, so the answer comes - /// from geometry the drawing already knows. - #[rust] - hit_rects: Vec, - /// The whole body, for telling "inside the folder view" from "somewhere - /// else in the window". - #[rust] - body_rect: Rect, - /// A secondary press waiting to be reported to the shell. - #[rust] - pending_context: Option, -} - -impl FileContents { - // -------------------------------------------------------------- model - - pub fn set_entries(&mut self, cx: &mut Cx, entries: Vec) { - self.entries = entries; - // A new listing is a new folder: nothing it expanded still applies. - self.children.clear(); - self.expanded.clear(); - self.pending.clear(); - self.selected.clear(); - self.anchor = None; - self.renaming = None; - self.reorder(cx); - } - - /// Deliver the children of a folder the List tree expanded. - pub fn set_children(&mut self, cx: &mut Cx, folder: &Path, entries: Vec) { - self.pending.remove(folder); - self.children.insert(folder.to_path_buf(), entries); - self.reorder(cx); - } - - pub fn set_filter(&mut self, cx: &mut Cx, filter: String) { - self.filter = filter; - self.reorder(cx); - } - - pub fn sort(&self) -> SortSpec { - self.sort - } - - pub fn set_sort(&mut self, cx: &mut Cx, sort: SortSpec) { - self.sort = sort; - self.reorder(cx); - } - - pub fn zoom(&self) -> usize { - self.zoom - } - - /// Step the icon size. Returns the tile width now in force, for the - /// status line. - pub fn set_zoom(&mut self, cx: &mut Cx, zoom: usize) -> f64 { - self.zoom = zoom.min(ZOOM_LEVELS.len() - 1); - self.view.redraw(cx); - ZOOM_LEVELS[self.zoom].0 - } - - /// Rebuild the display order from the filter, the sort and the expansions. - fn reorder(&mut self, cx: &mut Cx) { - let needle = self.filter.to_lowercase(); - let mut rows = Vec::with_capacity(self.entries.len()); - let tree = self.mode == ViewMode::List; - self.push_rows(&self.entries.clone(), 0, &needle, tree, &mut rows); - self.rows = rows; - self.sync_grid_selection(cx); - self.view.redraw(cx); - } - - /// Append one level of the tree, sorted, then recurse into whatever of it - /// is expanded. - fn push_rows( - &self, - entries: &[FileEntry], - depth: usize, - needle: &str, - tree: bool, - out: &mut Vec, - ) { - let mut order: Vec = (0..entries.len()) - .filter(|i| { - // A filter only ever hides entries at the top level: hiding a - // child would leave its parent claiming to be open onto - // nothing. - depth > 0 || needle.is_empty() || entries[*i].name.to_lowercase().contains(needle) - }) - .collect(); - crate::model::sort_indices(entries, &mut order, self.sort); - for index in order { - let entry = entries[index].clone(); - let expanded = self.expanded.contains(&entry.path); - let expandable = tree && entry.is_dir && entry.child_count.unwrap_or(1) > 0; - out.push(Row { - depth, - expandable, - expanded: expanded && expandable, - entry: entry.clone(), - }); - if !expanded || !expandable { - continue; - } - if let Some(children) = self.children.get(&entry.path) { - self.push_rows(children, depth + 1, needle, tree, out); - } - } - } - - pub fn set_mode(&mut self, cx: &mut Cx, mode: ViewMode) { - let was_tree = self.mode == ViewMode::List; - self.mode = mode; - self.cancel_rename(cx); - self.view - .view(cx, ids!(icons_page)) - .set_visible(cx, mode == ViewMode::Icons); - self.view - .view(cx, ids!(list_page)) - .set_visible(cx, mode == ViewMode::List); - self.view - .view(cx, ids!(compact_page)) - .set_visible(cx, mode == ViewMode::Compact); - self.view - .view(cx, ids!(treemap_page)) - .set_visible(cx, mode.is_treemap()); - // The tree only exists in the List view, so leaving it flattens the - // rows and entering it can bring them back. - if was_tree != (mode == ViewMode::List) { - self.reorder(cx); - } - self.view.redraw(cx); - } - - /// The treemap, for the shell to point at a folder and drain. - pub fn treemap(&self, cx: &mut Cx) -> TreemapViewRef { - self.view.treemap_view(cx, ids!(treemap)) - } - - pub fn set_colors(&mut self, cx: &mut Cx, colors: Colors) { - self.colors = colors; - self.view.redraw(cx); - } - - pub fn len(&self) -> usize { - self.rows.len() - } - - pub fn total(&self) -> usize { - self.entries.len() - } - - /// Thumbnails decoded and resident, for the status line. - pub fn thumbs_resident(&self) -> usize { - self.thumbs.resident() - } - - /// The columns the list view shows, in order. - pub fn columns(&self) -> Vec { - if self.columns.is_empty() { - DEFAULT_COLUMNS.to_vec() - } else { - self.columns.clone() - } - } - - /// Show or hide one column. Name is the row's identity and always stays. - pub fn toggle_column(&mut self, cx: &mut Cx, column: SortKey) { - if column == SortKey::Name { - return; - } - let mut columns = self.columns(); - match columns.iter().position(|c| *c == column) { - Some(at) => { - columns.remove(at); - // Sorting by a column nobody can see is a sort with no - // indicator: fall back to the name order. - if self.sort.key == column { - self.sort = SortSpec::default(); - self.reorder(cx); - } - } - // Keep the natural order rather than appending: the columns read - // the same however they were switched on. - None => { - columns.push(column); - columns.sort_by_key(|c| { - SortKey::ALL.iter().position(|a| a == c).unwrap_or(usize::MAX) - }); - } - } - self.columns = columns; - // The widths belong to the column set that asked for them. - self.grid_ready = false; - self.columns_user_sized = false; - self.fitted_width = 0.0; - let grid = self.view.data_grid(cx, ids!(list_grid)); - grid.redraw(cx); - self.view.redraw(cx); - } - - /// Push the current column set's labels and widths into the grid. - fn apply_columns(&self, grid: &mut DataGrid) { - let columns = self.columns(); - grid.set_col_labels(columns.iter().map(|c| c.label().to_string()).collect()); - for (index, column) in columns.iter().enumerate() { - grid.set_col_width(index, column.default_width()); - } - } - - // ---------------------------------------------------------- selection - - /// The entry the shell acts on: the one the user last landed on. - pub fn selected_entry(&self) -> Option { - let anchor = self.anchor.as_ref()?; - self.rows - .iter() - .find(|r| &r.entry.path == anchor) - .map(|r| r.entry.clone()) - } - - /// Everything selected, in display order — what copy, trash and batch - /// rename operate on. - pub fn selected_entries(&self) -> Vec { - self.rows - .iter() - .filter(|r| self.selected.contains(&r.entry.path)) - .map(|r| r.entry.clone()) - .collect() - } - - pub fn selection_count(&self) -> usize { - self.selected.len() - } - - /// Put the selection on exactly these paths (as far as they are on - /// screen), with the first as the anchor. Used after an operation lands. - pub fn select_paths(&mut self, cx: &mut Cx, paths: &[PathBuf]) { - self.selected = paths.iter().cloned().collect(); - self.anchor = paths.first().cloned(); - let anchor = self.anchor.clone(); - self.treemap(cx).set_selected(cx, anchor); - if let Some(anchor) = self.anchor.clone() { - if let Some(position) = self.rows.iter().position(|r| r.entry.path == anchor) { - self.scroll_into_view(cx, position); - } - } - self.sync_grid_selection(cx); - self.view.redraw(cx); - } - - pub fn select_all(&mut self, cx: &mut Cx) { - self.selected = self.rows.iter().map(|r| r.entry.path.clone()).collect(); - if self.anchor.is_none() { - self.anchor = self.rows.first().map(|r| r.entry.path.clone()); - } - self.sync_grid_selection(cx); - self.view.redraw(cx); - } - - pub fn clear_selection(&mut self, cx: &mut Cx) { - self.selected.clear(); - self.anchor = None; - self.sync_grid_selection(cx); - self.view.redraw(cx); - } - - /// A click at `position` in the display list, with the modifiers that - /// decide what it means. - fn click(&mut self, cx: &mut Cx, position: usize, modifiers: KeyModifiers) { - let Some(row) = self.rows.get(position) else { - return; - }; - let path = row.entry.path.clone(); - if modifiers.shift { - // Extend from the anchor: the run between the two, inclusive. - let from = self - .anchor - .as_ref() - .and_then(|a| self.rows.iter().position(|r| &r.entry.path == a)) - .unwrap_or(position); - let (lo, hi) = (from.min(position), from.max(position)); - self.selected = self.rows[lo..=hi] - .iter() - .map(|r| r.entry.path.clone()) - .collect(); - } else if modifiers.logo || modifiers.control { - // Toggle one out of the set without disturbing the rest. - if !self.selected.remove(&path) { - self.selected.insert(path.clone()); - } - } else { - self.selected.clear(); - self.selected.insert(path.clone()); - } - self.anchor = Some(path); - self.sync_grid_selection(cx); - self.view.redraw(cx); - } - - /// Keep the grid's own scroll on the anchor. The row highlight is painted - /// by the cell drawing instead of by `GridSelection`, because a selection - /// made of scattered Cmd-clicks is not a rectangle and the grid's own - /// overlay can only draw rectangles. - fn sync_grid_selection(&mut self, cx: &mut Cx) { - let grid = self.view.data_grid(cx, ids!(list_grid)); - grid.set_selection(cx, None); - let row = self - .anchor - .as_ref() - .and_then(|a| self.rows.iter().position(|r| &r.entry.path == a)); - if let Some(row) = row { - grid.scroll_cell_into_view(cx, row, 0); - } - } - - /// Move the selection by `amount` display positions and return what is - /// now selected. `extend` grows the selection instead of replacing it. - pub fn move_selection( - &mut self, - cx: &mut Cx, - amount: isize, - extend: bool, - ) -> Option { - if self.rows.is_empty() { - self.selected.clear(); - self.anchor = None; - return None; - } - let current = self - .anchor - .as_ref() - .and_then(|a| self.rows.iter().position(|r| &r.entry.path == a)); - let next = match current { - Some(current) => (current as isize + amount).clamp(0, self.rows.len() as isize - 1), - // Nothing selected yet: the first step lands on an end, not on - // whatever index the offset happens to hit. - None if amount < 0 => self.rows.len() as isize - 1, - None => 0, - } as usize; - let path = self.rows[next].entry.path.clone(); - if extend { - self.selected.insert(path.clone()); - } else { - self.selected.clear(); - self.selected.insert(path.clone()); - } - self.anchor = Some(path); - self.sync_grid_selection(cx); - self.scroll_into_view(cx, next); - self.view.redraw(cx); - self.selected_entry() - } - - /// How many display positions one arrow key covers in this view. - pub fn row_stride(&self) -> isize { - match self.mode { - ViewMode::Icons => self.grid_columns.max(1) as isize, - _ => 1, - } - } - - fn scroll_into_view(&mut self, cx: &mut Cx, position: usize) { - match self.mode { - ViewMode::Icons => { - let row = position / self.grid_columns.max(1); - self.view - .portal_list(cx, ids!(icons_list)) - .smooth_scroll_to(cx, row, 90.0, None, 0.0); - } - ViewMode::Compact => { - self.view - .portal_list(cx, ids!(compact_list)) - .smooth_scroll_to(cx, position, 90.0, None, 0.0); - } - // The grid scrolls itself from `sync_grid_selection`; the map has - // no scroll at all. - ViewMode::List | ViewMode::Treemap => {} - } - } - - // ----------------------------------------------------------- renaming - - /// True while an inline editor is open — the shell must then leave the - /// keyboard alone, because a hidden text field keeps key focus. - pub fn is_renaming(&self) -> bool { - self.renaming.is_some() - } - - /// Open the inline editor over `path`'s name. - pub fn begin_rename(&mut self, cx: &mut Cx, path: &Path) -> bool { - if !self.rows.iter().any(|r| r.entry.path == path) { - return false; - } - if self.mode.is_treemap() { - return false; - } - self.renaming = Some(path.to_path_buf()); - self.selected.clear(); - self.selected.insert(path.to_path_buf()); - self.anchor = Some(path.to_path_buf()); - // The field has no area until it has been drawn, so focus waits a - // frame. - self.rename_tries = 0; - self.rename_seeded = None; - self.rename_focus = cx.new_next_frame(); - self.view.redraw(cx); - true - } - - /// Close the editor without renaming anything. - pub fn cancel_rename(&mut self, cx: &mut Cx) { - if self.renaming.take().is_some() { - // A hidden text field would otherwise keep key focus and swallow - // every navigation key from here on. - cx.set_key_focus(Area::Empty); - self.view.redraw(cx); - } - } - - /// The widget hosting the inline editor for the row at `position`, if it - /// is on screen. - fn rename_editor(&self, cx: &mut Cx, position: usize) -> Option { - match self.mode { - ViewMode::Icons => { - let columns = self.grid_columns.max(1); - let list = self.view.portal_list(cx, ids!(icons_list)); - let (row, column) = (position / columns, position % columns); - let (_, item) = list.get_item(row)?; - Some( - item.widget(cx, CELL_IDS[column]) - .text_input(cx, ids!(tile_edit)), - ) - } - ViewMode::Compact => { - let list = self.view.portal_list(cx, ids!(compact_list)); - let (_, item) = list.get_item(position)?; - Some(item.text_input(cx, ids!(row_edit))) - } - ViewMode::List => { - let grid = self.view.data_grid(cx, ids!(list_grid)); - let (_, item) = grid.get_item(position, 0)?; - Some(item.text_input(cx, ids!(cell_edit))) - } - ViewMode::Treemap => None, - } - } - - /// The display position of the row being renamed. - fn rename_position(&self) -> Option { - let path = self.renaming.as_ref()?; - self.rows.iter().position(|r| &r.entry.path == path) - } - - // ---------------------------------------------------------- thumbnails - - /// Turn finished decodes into textures; true when a redraw is owed. - pub fn drain_thumbs(&mut self, cx: &mut Cx) -> bool { - if self.thumbs.drain(cx) { - self.view.redraw(cx); - return true; - } - false - } - - /// Columns that fit in `width` at `tile_width`, at least one and never - /// more than the row template carries. - fn columns_for(width: f64, tile_width: f64) -> usize { - (((width - 24.0) / tile_width).floor() as isize).clamp(1, GRID_MAX_COLUMNS as isize) - as usize - } - - // ---------------------------------------------------------------- draw - - fn draw_icons(&mut self, cx: &mut Cx2d, list: &mut PortalList) { - let columns = self.grid_columns.max(1); - let rows = self.rows.len().div_ceil(columns); - let (tile_width, row_height, thumb_height) = - ZOOM_LEVELS[self.zoom.min(ZOOM_LEVELS.len() - 1)]; - // The picture never touches the tile's edges: the name below it needs - // the same optical margin the small size already had. - let thumb_width = (tile_width - 24.0).max(24.0); - let renaming = self.rename_position(); - list.set_item_range(cx, 0, rows); - while let Some(row) = list.next_visible_item(cx) { - let mut item = list.item(cx, row, id!(GridRow)); - script_apply_eval!(cx, item, { - height: #(row_height) - }); - if row >= rows { - // Past the last row: blank every cell rather than leave a - // recycled one showing the previous folder. - for column in 0..GRID_MAX_COLUMNS { - item.widget(cx, CELL_IDS[column]).set_visible(cx, false); - } - item.draw_all(cx, &mut Scope::empty()); - continue; - } - for column in 0..GRID_MAX_COLUMNS { - let cell = item.widget(cx, CELL_IDS[column]); - // Every column the width affords stays in the layout even when - // the folder runs out, so a short last row keeps the same tile - // size as a full one instead of stretching to fill it. - cell.set_visible(cx, column < columns); - if column >= columns { - continue; - } - let position = row * columns + column; - // Both the slot and the picture inside it grow with the zoom: - // sizing only the picture would draw it inside a box that is - // still the small size, and clip it. - let mut thumb = cell.widget(cx, ids!(tile_thumb)); - script_apply_eval!(cx, thumb, { - height: #(thumb_height) - }); - let mut img = thumb.widget(cx, ids!(img)); - script_apply_eval!(cx, img, { - width: #(thumb_width) - height: #(thumb_height) - }); - if position >= self.rows.len() { - cell.widget(cx, ids!(tile_sel)).set_visible(cx, false); - cell.widget(cx, ids!(tile_edit_box)).set_visible(cx, false); - cell.label(cx, ids!(tile_name)).set_text(cx, ""); - clear_thumb(cx, &thumb); - continue; - } - let entry = self.rows[position].entry.clone(); - let editing = renaming == Some(position); - cell.widget(cx, ids!(tile_sel)) - .set_visible(cx, self.selected.contains(&entry.path)); - cell.widget(cx, ids!(tile_name)) - .set_visible(cx, !editing); - cell.widget(cx, ids!(tile_edit_box)).set_visible(cx, editing); - cell.label(cx, ids!(tile_name)).set_text(cx, &entry.name); - if editing && self.rename_seeded.as_deref() != Some(entry.path.as_path()) { - self.rename_seeded = Some(entry.path.clone()); - cell.text_input(cx, ids!(tile_edit)).set_text(cx, &entry.name); - } - fill_thumb(cx, &thumb, &entry, &mut self.thumbs); - } - item.draw_all(cx, &mut Scope::empty()); - // The tile rectangles are only final once the row has been drawn. - for column in 0..columns.min(GRID_MAX_COLUMNS) { - let position = row * columns + column; - if position >= self.rows.len() { - break; - } - let rect = item.widget(cx, CELL_IDS[column]).area().rect(cx); - if rect.size.x > 0.0 { - self.hit_rects.push(HitRect { position, rect }); - } - } - } - } - - fn draw_compact(&mut self, cx: &mut Cx2d, list: &mut PortalList) { - let renaming = self.rename_position(); - list.set_item_range(cx, 0, self.rows.len()); - while let Some(position) = list.next_visible_item(cx) { - let item = list.item(cx, position, id!(CompactRow)); - if position >= self.rows.len() { - // A row past the end still has to be cleared: a recycled item - // that is never repopulated keeps the last row's highlight. - item.widget(cx, ids!(row_sel)).set_visible(cx, false); - item.widget(cx, ids!(row_edit_box)).set_visible(cx, false); - item.widget(cx, ids!(row_name)).set_visible(cx, true); - item.label(cx, ids!(row_name)).set_text(cx, ""); - item.label(cx, ids!(row_size)).set_text(cx, ""); - let thumb = item.widget(cx, ids!(row_thumb)); - clear_thumb(cx, &thumb); - item.draw_all(cx, &mut Scope::empty()); - continue; - } - let entry = self.rows[position].entry.clone(); - let editing = renaming == Some(position); - item.widget(cx, ids!(row_sel)) - .set_visible(cx, self.selected.contains(&entry.path)); - item.widget(cx, ids!(row_name)).set_visible(cx, !editing); - item.widget(cx, ids!(row_edit_box)).set_visible(cx, editing); - item.label(cx, ids!(row_name)).set_text(cx, &entry.name); - if editing && self.rename_seeded.as_deref() != Some(entry.path.as_path()) { - self.rename_seeded = Some(entry.path.clone()); - item.text_input(cx, ids!(row_edit)).set_text(cx, &entry.name); - } - item.label(cx, ids!(row_size)) - .set_text(cx, &format_size(entry.size, entry.is_dir)); - let thumb = item.widget(cx, ids!(row_thumb)); - fill_thumb(cx, &thumb, &entry, &mut self.thumbs); - item.draw_all(cx, &mut Scope::empty()); - let rect = item.area().rect(cx); - if rect.size.x > 0.0 { - self.hit_rects.push(HitRect { position, rect }); - } - } - } - - fn draw_list(&mut self, cx: &mut Cx2d, grid: &mut DataGrid) { - if self.columns.is_empty() { - self.columns = DEFAULT_COLUMNS.to_vec(); - } - let columns = self.columns.clone(); - if !self.grid_ready { - self.grid_ready = true; - self.apply_columns(grid); - } - // Name takes whatever the other columns leave, so the table fills the - // window instead of ending in a band of empty background. This runs - // when the width it was fitted to changes — never every frame, which - // would undo a column drag while it is happening — and not at all - // once the user has sized a column themselves. - if !self.columns_user_sized && (self.last_width - self.fitted_width).abs() > 0.5 { - self.fitted_width = self.last_width; - let fixed: f64 = (1..columns.len()).map(|i| grid.col_width(i)).sum(); - let name_width = (self.last_width - fixed - SCROLL_BAR_ALLOWANCE).max(160.0); - grid.set_col_width(0, name_width); - } - grid.set_grid_size(self.rows.len(), columns.len()); - grid.set_sort_indicator( - columns - .iter() - .position(|c| *c == self.sort.key) - .map(|col| (col, self.sort.ascending)), - ); - let renaming = self.rename_position(); - while let Some(cell) = grid.next_cell(cx) { - if cell.row >= self.rows.len() { - continue; - } - let Some(column) = columns.get(cell.col).copied() else { - continue; - }; - let row = self.rows[cell.row].clone(); - let entry = &row.entry; - let picked = self.selected.contains(&entry.path); - let bg = picked.then_some(self.colors.selection); - if column == SortKey::Name { - // The name column carries the icon, the tree's indent and its - // disclosure triangle. - let Some(item) = grid.item(cx, cell.row, cell.col, id!(NameCell)) else { - continue; - }; - let indent = row.depth as f64 * INDENT_STEP; - let mut spacer = item.widget(cx, ids!(cell_indent)); - script_apply_eval!(cx, spacer, { - width: #(indent) - }); - item.widget(cx, ids!(twist_closed)) - .set_visible(cx, row.expandable && !row.expanded); - item.widget(cx, ids!(twist_open)) - .set_visible(cx, row.expandable && row.expanded); - let editing = renaming == Some(cell.row); - item.widget(cx, ids!(cell_name)).set_visible(cx, !editing); - item.widget(cx, ids!(cell_edit_box)).set_visible(cx, editing); - item.label(cx, ids!(cell_name)).set_text(cx, &entry.name); - if editing && self.rename_seeded.as_deref() != Some(entry.path.as_path()) { - self.rename_seeded = Some(entry.path.clone()); - item.text_input(cx, ids!(cell_edit)).set_text(cx, &entry.name); - } - let thumb = item.widget(cx, ids!(cell_thumb)); - fill_thumb(cx, &thumb, entry, &mut self.thumbs); - grid.draw_item(cx, &cell, &item, bg); - // The name cell starts at the row's left edge, so widening it - // to the table's width is the row. - self.hit_rects.push(HitRect { - position: cell.row, - rect: Rect { - pos: cell.rect.pos, - size: dvec2(self.last_width, cell.rect.size.y), - }, - }); - continue; - } - let (text, align) = (column.text(entry), column.align()); - grid.cell_text_styled( - cx, - &cell, - &text, - CellStyle { - align, - color: Some(self.colors.dim), - bg, - ..CellStyle::default() - }, - ); - } - } - - // -------------------------------------------------------------- events - - pub fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) -> Vec { - let mut out = Vec::new(); - if let Some(hit) = self.pending_context.take() { - let entry = hit.off_list.clone().or_else(|| { - hit.position - .and_then(|p| self.rows.get(p)) - .map(|r| r.entry.clone()) - }); - out.push(FileContentsAction::Context { at: hit.at, entry }); - return out; - } - // The inline editor is read before anything else: Enter and Escape in - // a field are never navigation. - if let Some(position) = self.rename_position() { - let path = self.renaming.clone().unwrap_or_default(); - if let Some(field) = self.rename_editor(cx, position) { - if let Some((text, _)) = field.returned(actions) { - self.cancel_rename(cx); - out.push(FileContentsAction::Renamed(path, text)); - return out; - } - if field.escaped(actions) { - self.cancel_rename(cx); - out.push(FileContentsAction::RenameCancelled); - return out; - } - } - } - - match self.mode { - ViewMode::Icons => { - let list = self.view.portal_list(cx, ids!(icons_list)); - let columns = self.grid_columns.max(1); - for (row, item) in list.items_with_actions(actions) { - for column in 0..GRID_MAX_COLUMNS.min(columns) { - let position = row * columns + column; - if position >= self.rows.len() { - continue; - } - let tile = item.view(cx, CELL_IDS[column]); - if let Some(event) = tile.finger_down(actions) { - self.press_at = Some(event.abs); - out.push(self.hit(cx, position, event.tap_count >= 2, event.modifiers)); - } - if let Some(event) = tile.finger_up(actions) { - self.drop(&mut out, event.abs); - } - } - } - } - ViewMode::Compact => { - let list = self.view.portal_list(cx, ids!(compact_list)); - for (position, item) in list.items_with_actions(actions) { - if position >= self.rows.len() { - continue; - } - let row = item.as_view(); - if let Some(event) = row.finger_down(actions) { - self.press_at = Some(event.abs); - out.push(self.hit(cx, position, event.tap_count >= 2, event.modifiers)); - } - if let Some(event) = row.finger_up(actions) { - self.drop(&mut out, event.abs); - } - } - } - ViewMode::List => { - // A click on the disclosure triangle opens the folder in - // place; it must be read before the grid's own cell click so - // it does not also count as "select this row". - let grid = self.view.data_grid(cx, ids!(list_grid)); - let mut twisted = None; - for (row, col, item) in grid.cell_widgets_with_actions(actions) { - if col != 0 || row >= self.rows.len() { - continue; - } - if item.view(cx, ids!(cell_twist)).finger_down(actions).is_some() { - twisted = Some(row); - } - } - if let Some(row) = twisted { - if let Some(action) = self.toggle_expand(cx, row) { - out.push(action); - } - return out; - } - // The grid emits CellClicked *and then* CellDoubleClicked for - // the second tap, so the whole batch has to be read before - // deciding: acting on the first one would turn every - // double-click into a plain select. - let mut hit: Option<(usize, bool, KeyModifiers)> = None; - let mut sorted = false; - for action in grid.actions(actions) { - match action { - DataGridAction::CellClicked { row, modifiers, .. } - if row < self.rows.len() => - { - hit = Some((row, false, modifiers)); - } - DataGridAction::CellDoubleClicked { row, .. } - if row < self.rows.len() => - { - let modifiers = hit.map(|h| h.2).unwrap_or_default(); - hit = Some((row, true, modifiers)); - } - DataGridAction::ColumnResized { .. } => { - self.columns_user_sized = true; - } - DataGridAction::HeaderClicked { col, .. } => { - let Some(key) = self.columns.get(col).copied() else { - continue; - }; - let sort = if self.sort.key == key { - SortSpec { - key, - ascending: !self.sort.ascending, - } - } else { - SortSpec { - key, - ascending: true, - } - }; - self.set_sort(cx, sort); - sorted = true; - } - _ => {} - } - } - if let Some((row, open, modifiers)) = hit { - out.push(self.hit(cx, row, open, modifiers)); - } - if sorted { - out.push(FileContentsAction::Sorted); - } - } - ViewMode::Treemap => { - // Every action from the map, not just the first: a secondary - // click emits its pick *and* its context request in one - // batch, and dropping either would lose the menu or the - // selection. - let map_uid = self.treemap(cx).widget_uid(); - let map_actions: Vec = actions - .iter() - .filter_map(|a| a.as_widget_action().filter(|wa| wa.widget_uid == map_uid)) - .map(|wa| wa.cast::()) - .collect(); - for action in map_actions { - match action { - // Picking is picking. The old rule — anything not in the - // current listing means "go there" — made a single click - // on any rectangle below the top level throw the whole - // browser somewhere else, which is the opposite of what a - // map is for. Deeper picks live on the map's own readout; - // only the ones the listing also holds reach the shell. - TreemapAction::Selected(path) => { - self.selected.clear(); - self.selected.insert(path.clone()); - self.anchor = Some(path.clone()); - if let Some(entry) = self - .rows - .iter() - .find(|r| r.entry.path == path) - .map(|r| r.entry.clone()) - { - out.push(FileContentsAction::Selected(entry)); - } else { - // Below the listing, so there is no row to - // describe — but the status line still has to - // stop saying what the *last* pick was. - out.push(FileContentsAction::Restated); - } - } - // The map was showing something that is not there any - // more — deleted by something other than this app since - // the folder was measured. It has already dropped it; the - // listing should hear about it too. - TreemapAction::Vanished(path) => { - self.selected.remove(&path); - out.push(FileContentsAction::Restated); - } - TreemapAction::FilterCleared => { - out.push(FileContentsAction::MapFilterCleared); - } - // A secondary click that stayed a click: the menu opens - // exactly as it would have on the press, only now it is - // certain no pan was meant. - TreemapAction::Context(at) => { - self.open_context(cx, at); - } - TreemapAction::None => {} - } - } - } - } - out - } - - /// A press landed on the row at `position`. - fn hit( - &mut self, - cx: &mut Cx, - position: usize, - open: bool, - modifiers: KeyModifiers, - ) -> FileContentsAction { - // A press anywhere else ends an inline rename, exactly as clicking - // away from a field does in every file manager. - if self.renaming.is_some() && self.rename_position() != Some(position) { - self.cancel_rename(cx); - } - self.click(cx, position, modifiers); - let entry = self.rows[position].entry.clone(); - if open { - FileContentsAction::Open(entry) - } else { - FileContentsAction::Selected(entry) - } - } - - /// A release; when it happened far enough from the press it is a drag, - /// and the shell gets to decide what is under it. - fn drop(&mut self, out: &mut Vec, at: DVec2) { - let Some(from) = self.press_at.take() else { - return; - }; - if (at - from).length() < 12.0 { - return; - } - let paths: Vec = self - .selected_entries() - .into_iter() - .map(|e| e.path) - .collect(); - if !paths.is_empty() { - out.push(FileContentsAction::Dropped(paths, at)); - } - } - - /// Resolve a secondary press into a menu target, selecting what it landed - /// on when that is not already part of the selection — which is what every - /// file manager does, and what keeps a right-click on one of five selected - /// files from throwing the other four away. - fn open_context(&mut self, cx: &mut Cx, at: DVec2) { - let mut off_list = None; - let position = match self.mode { - ViewMode::Treemap => { - let path = self.treemap(cx).path_at(at); - let position = path - .as_ref() - .and_then(|p| self.rows.iter().position(|r| r.entry.path == *p)); - // Most of the map is below the folder being listed, so most - // right-clicks land on something the rows do not know about. - // The entry is read straight off the disk instead — a menu - // that refuses to act on what the map is showing would be - // useless for the one job the map exists for. - if position.is_none() { - off_list = path.as_deref().and_then(crate::model::entry_at); - } - position - } - _ => self - .hit_rects - .iter() - .find(|hit| hit.rect.contains(at)) - .map(|hit| hit.position), - }; - if let Some(position) = position { - let path = self.rows[position].entry.path.clone(); - if !self.selected.contains(&path) { - self.selected.clear(); - self.selected.insert(path.clone()); - self.anchor = Some(path); - self.sync_grid_selection(cx); - self.view.redraw(cx); - } - } - self.pending_context = Some(ContextHit { - at, - position, - off_list, - }); - let uid = self.widget_uid(); - cx.widget_action(uid, ContentsPing::Ping); - } - - /// Open or close the folder on `row` in the List tree. - fn toggle_expand(&mut self, cx: &mut Cx, row: usize) -> Option { - let path = self.rows.get(row)?.entry.path.clone(); - if !self.rows[row].expandable { - return None; - } - if self.expanded.remove(&path) { - self.reorder(cx); - return None; - } - self.expanded.insert(path.clone()); - self.reorder(cx); - if self.children.contains_key(&path) || !self.pending.insert(path.clone()) { - return None; - } - Some(FileContentsAction::NeedChildren(path)) - } -} - -/// `ids!` paths for the row template's cells, indexed by column. -const CELL_IDS: [&[LiveId]; GRID_MAX_COLUMNS] = [ - ids!(c0), - ids!(c1), - ids!(c2), - ids!(c3), - ids!(c4), - ids!(c5), - ids!(c6), - ids!(c7), - ids!(c8), - ids!(c9), - ids!(c10), - ids!(c11), -]; - -impl Widget for FileContents { - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - // The row template has a fixed cell count; how many of them are used - // follows the width we are actually given and the icon size. - let width = cx.turtle().rect().size.x; - if width > 1.0 { - self.last_width = width; - } - let tile_width = ZOOM_LEVELS[self.zoom.min(ZOOM_LEVELS.len() - 1)].0; - self.grid_columns = Self::columns_for(self.last_width.max(tile_width), tile_width); - self.body_rect = cx.turtle().rect(); - self.hit_rects.clear(); - while let Some(step) = self.view.draw_walk(cx, scope, walk).step() { - // Only the page for `mode` is visible, so whatever list this is, - // `mode` says which one. - match self.mode { - ViewMode::Icons => { - if let Some(mut list) = step.borrow_mut::() { - self.draw_icons(cx, &mut list); - } - } - ViewMode::Compact => { - if let Some(mut list) = step.borrow_mut::() { - self.draw_compact(cx, &mut list); - } - } - ViewMode::List => { - if let Some(mut grid) = step.borrow_mut::() { - self.draw_list(cx, &mut grid); - } - } - ViewMode::Treemap => {} - } - } - DrawStep::done() - } - - fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - // A context menu has to know what is under the pointer even when the - // tile, row or grid under it swallows the press — and each of the four - // views swallows it differently. The raw button event is the one place - // where the answer is the same for all of them, so it is read here and - // resolved against the rectangles the last draw recorded. - if let Event::MouseDown(press) = event { - let secondary = press.button.is_secondary() - || (press.button.is_primary() && press.modifiers.control); - // Not on the treemap: there a secondary press may be the start of - // a right-drag pan, so the map itself decides on release and - // reports a clean click as `TreemapAction::Context`. - if secondary && self.mode != ViewMode::Treemap && self.body_rect.contains(press.abs) { - self.open_context(cx, press.abs); - } - } - self.view.handle_event(cx, event, scope); - // An editor that has not been drawn since it was revealed has no area, - // and focusing an empty area focuses nothing — so this waits for the - // frame that gives it one, rather than assuming the next frame does. - if self.rename_focus.is_event(event).is_some() { - let field = self - .rename_position() - .and_then(|position| self.rename_editor(cx, position)); - let drawn = field - .as_ref() - .map(|f| f.area().rect(cx).size.x >= 1.0) - .unwrap_or(false); - match field.filter(|_| drawn) { - Some(field) => { - self.rename_tries = 0; - field.take_key_focus(cx); - if let Some(mut inner) = field.borrow_mut() { - inner.select_all(cx); - } - } - None if self.renaming.is_some() && self.rename_tries < 8 => { - self.rename_tries += 1; - self.rename_focus = cx.new_next_frame(); - } - None => {} - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn columns_follow_the_width_and_the_icon_size() { - let medium = ZOOM_LEVELS[DEFAULT_ZOOM].0; - assert_eq!(FileContents::columns_for(0.0, medium), 1); - assert_eq!(FileContents::columns_for(200.0, medium), 1); - assert_eq!(FileContents::columns_for(700.0, medium), 5); - // Never more cells than the row template carries. - assert_eq!( - FileContents::columns_for(9000.0, medium), - GRID_MAX_COLUMNS - ); - // A bigger icon means fewer of them across the same window. - let biggest = ZOOM_LEVELS[ZOOM_LEVELS.len() - 1].0; - assert!( - FileContents::columns_for(1200.0, biggest) - < FileContents::columns_for(1200.0, medium) - ); - } - - #[test] - fn the_zoom_levels_only_ever_grow() { - for pair in ZOOM_LEVELS.windows(2) { - assert!(pair[1].0 > pair[0].0, "tile width"); - assert!(pair[1].1 > pair[0].1, "row height"); - assert!(pair[1].2 > pair[0].2, "thumbnail height"); - } - assert!(DEFAULT_ZOOM < ZOOM_LEVELS.len()); - } -} diff --git a/apps/mpfiles/src/demo.rs b/apps/mpfiles/src/demo.rs deleted file mode 100644 index 5aab0576b..000000000 --- a/apps/mpfiles/src/demo.rs +++ /dev/null @@ -1,1359 +0,0 @@ -//! The demo filesystem: a whole fake home, in memory, for screen recordings. -//! -//! `--demo` (or `MPFILES_DEMO=1`, see [`crate::vfs::demo_requested`]) points -//! the browser at [`DemoVfs`] instead of the real disk, so a recording can -//! show `mpfiles` doing real work — thumbnails, Space preview, rename, copy, -//! the treemap, undo — without a single one of the user's own files ever -//! appearing on screen. It is not a mock of those features: every operation -//! genuinely mutates a real tree, and every thumbnailable file has a real, -//! repo-safe asset behind it (see [`Vfs::real_path`]) so the same decoders -//! and viewers the real filesystem uses render something real. -//! -//! The tree is built once, deterministically — a seeded PRNG, never the -//! clock — so two runs (and two recordings) show byte-identical sizes and -//! dates. Everything after that lives behind a [`Mutex`], because the -//! [`Vfs`] trait hands out `&self`: an in-memory filesystem still needs -//! interior mutability to survive a rename. - -use std::{ - fs, - path::{Path, PathBuf}, - sync::{atomic::AtomicBool, atomic::Ordering, Mutex}, -}; - -use crate::{ - model::{self, FileEntry, SortSpec}, - ops::{OpKind, OpRequest, Undo}, - treemap::{Node, ScanProgress}, - vfs::{outcome_message, OpOutcome, Vfs}, -}; - -/// The demo's home. Rooted somewhere that cannot be mistaken for a real -/// path and reads cleanly in the breadcrumb — `/Demo`, `/Demo/Documents`, -/// and so on. -const VIRTUAL_HOME: &str = "/Demo"; - -/// Where a trashed demo file goes; a plain hidden folder under the virtual -/// home, exactly the way `~/.Trash` sits under a real one. -const TRASH_NAME: &str = ".Trash"; - -/// The anchor "now" every seeded date is measured back from. A fixed -/// constant, not [`std::time::SystemTime::now`] — that is what keeps the -/// tree byte-identical across runs instead of drifting a little further -/// from "today" every time someone records a demo. (2026-08-27 00:00:00 -/// UTC, chosen simply because it postdates every asset this module reads.) -const DEMO_NOW_SECS: u64 = 1_787_788_800; - -/// Modified times are spread somewhere in this window before [`DEMO_NOW_SECS`]. -const TWO_YEARS_SECS: u64 = 63_072_000; - -/// A file's created time sits at most this far before its modified time. -const THIRTY_DAYS_SECS: u64 = 2_592_000; - -/// The PRNG's seed. Any nonzero constant works; this one has no meaning -/// beyond "not zero, not a round number that looks like a bug". -const SEED: u64 = 0x9E37_79B9_7F4A_7C15; - -/// The repo root this process almost always already has as its current -/// directory. [`repo_asset`] tries the current directory first and this -/// second, so the demo still finds its assets when launched some other way. -const REPO_ROOT_FALLBACK: &str = "/Users/admin/makepad/makepad"; - -// --------------------------------------------------------------------- -// A tiny, deterministic PRNG -// --------------------------------------------------------------------- - -/// xorshift64* — plenty of spread for sizes and dates, and small enough not -/// to be worth a `rand` dependency for a module whose only requirement is -/// "the same numbers every time". -struct Rng(u64); - -impl Rng { - /// `seed` is forced odd: xorshift's state never leaves zero once it - /// gets there, so a zero (or even, which can shift down to zero) seed - /// would make every "random" number the same number. - fn new(seed: u64) -> Self { - Rng(seed | 1) - } - - fn next_u64(&mut self) -> u64 { - let mut x = self.0; - x ^= x << 13; - x ^= x >> 7; - x ^= x << 17; - self.0 = x; - x - } - - /// A value in `[lo, hi)`. - fn range(&mut self, lo: u64, hi: u64) -> u64 { - lo + self.next_u64() % (hi - lo) - } -} - -/// A modified/created pair somewhere in the last two years, never in the -/// future and never zero (zero reads as "unknown" everywhere this app -/// formats a timestamp, which a seeded file must never claim to be). -fn seeded_age(rng: &mut Rng) -> (u64, u64) { - let modified = DEMO_NOW_SECS - rng.range(0, TWO_YEARS_SECS); - let created = modified.saturating_sub(rng.range(0, THIRTY_DAYS_SECS)); - (modified, created) -} - -/// A file's size: the real asset's own byte count when it has one (so a -/// thumbnail and its properties panel never disagree), else a plausible -/// number for its kind from the seeded RNG. -fn seeded_size(real: Option<&Path>, rng: &mut Rng, range: (u64, u64)) -> u64 { - if let Some(path) = real { - if let Ok(meta) = fs::metadata(path) { - return meta.len(); - } - } - rng.range(range.0, range.1) -} - -// --------------------------------------------------------------------- -// Finding real, repo-safe assets to back the virtual files -// --------------------------------------------------------------------- - -/// Resolve `relative` against the repo root: the current directory first -/// (the normal case — this process starts in the repo root), then -/// [`REPO_ROOT_FALLBACK`]. `None` when neither has it, which a caller -/// treats the same as "no real asset" rather than an error — a demo file -/// with a missing backing asset just falls back to its type icon. -fn repo_asset(relative: &str) -> Option { - if let Ok(cwd) = std::env::current_dir() { - let candidate = cwd.join(relative); - if candidate.exists() { - return Some(candidate); - } - } - let fallback = Path::new(REPO_ROOT_FALLBACK).join(relative); - fallback.exists().then_some(fallback) -} - -/// Every file directly inside a repo-relative directory whose extension is -/// one of `exts` (case-insensitive), sorted by path. The sort is what makes -/// this deterministic: `read_dir` order is whatever the OS feels like -/// handing back, and two demo trees built in the same checkout must pick -/// the same assets in the same order every time. A missing directory is -/// simply an empty pool, never an error. -fn discover_repo_files(relative_dir: &str, exts: &[&str]) -> Vec { - let Some(dir) = repo_asset(relative_dir) else { - return Vec::new(); - }; - let Ok(read_dir) = fs::read_dir(&dir) else { - return Vec::new(); - }; - let mut out: Vec = read_dir - .flatten() - .map(|entry| entry.path()) - .filter(|path| path.is_file()) - .filter(|path| { - path.extension() - .map(|ext| exts.iter().any(|e| ext.eq_ignore_ascii_case(e))) - .unwrap_or(false) - }) - .collect(); - out.sort(); - out -} - -/// The window manager's own desktop backgrounds, when this machine has any -/// — the one place outside the repo this module is allowed to look (every -/// other asset is repo-safe), and entirely optional: an absent directory -/// just means the wallpaper pool falls back to the repo's own photos. -/// Never looks anywhere else under the user's home. -fn discover_wallpapers() -> Vec { - let Some(home) = std::env::var_os("HOME") else { - return Vec::new(); - }; - let themes_dir = PathBuf::from(home).join(".config/mpwm/themes"); - let Ok(theme_entries) = fs::read_dir(&themes_dir) else { - return Vec::new(); - }; - let mut theme_dirs: Vec = theme_entries - .flatten() - .map(|entry| entry.path()) - .filter(|path| path.is_dir()) - .collect(); - theme_dirs.sort(); - - let mut out = Vec::new(); - for theme_dir in theme_dirs { - let Ok(bg_entries) = fs::read_dir(theme_dir.join("backgrounds")) else { - continue; - }; - let mut files: Vec = bg_entries - .flatten() - .map(|entry| entry.path()) - .filter(|path| path.is_file()) - .collect(); - files.sort(); - out.extend(files); - } - out -} - -/// One real file per kind of virtual file, cycled through in order. A -/// deterministic sort (see [`discover_repo_files`]) plus deterministic -/// cycling is what makes two [`DemoVfs`] instances identical: nothing here -/// ever consults `read_dir` order or the clock. -struct Pools { - videos: Vec, - photos: Vec, - wallpapers: Vec, - pdfs: Vec, - screenshots: Vec, - csvs: Vec, - txts: Vec, - mds: Vec, - rss: Vec, - tomls: Vec, -} - -impl Pools { - fn discover() -> Self { - let photos = discover_repo_files("local/mb3d", &["jpg", "jpeg"]); - - let mut wallpapers = discover_wallpapers(); - if wallpapers.is_empty() { - // No mpwm theme on this machine: the repo's own photos are - // still real images, just not desktop backgrounds. - wallpapers = photos.clone(); - } - - // The AI-generated clips lead the pool: they are the richest thing in - // the repo to look at, which is what a demo of a file browser wants - // behind its video thumbnails and previews. - let mut videos = discover_repo_files("local/ai_content_app", &["mp4"]); - videos.extend(discover_video_cache()); - videos.extend(discover_repo_files("local/flowtest/real", &["mp4"])); - videos.extend(discover_repo_files("local/flowtest", &["mp4"])); - - let mut pdfs = discover_repo_files("local/rotorquant/paper", &["pdf"]); - pdfs.extend(repo_asset("local/retourformulier-techpunt-ned.pdf")); - - let screenshots: Vec = [ - "examples/splash/window_0_frame_000000.png", - "examples/map/window_0_frame_000000.png", - ] - .into_iter() - .filter_map(repo_asset) - .collect(); - - let csvs = discover_repo_files("box3d", &["csv"]); - let txts = discover_repo_files("local/mb3d", &["txt"]); - let mds: Vec = ["AGENTS.md", "README.md"].into_iter().filter_map(repo_asset).collect(); - let rss = discover_repo_files("apps/mpfiles/src", &["rs"]); - let tomls: Vec = ["Cargo.toml"].into_iter().filter_map(repo_asset).collect(); - - Pools { videos, photos, wallpapers, pdfs, screenshots, csvs, txts, mds, rss, tomls } - } -} - -/// The VJ's decoder cache, when this machine has one. It is read-only extra -/// volume for the demo's video pool and entirely optional — a machine that has -/// never run the VJ gets the repo's own clips and nothing is missing. -fn discover_video_cache() -> Vec { - let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { - return Vec::new(); - }; - let mut out = Vec::new(); - for side in ["media-video-a", "media-video-b"] { - let dir = home.join(".makepad-vj").join(side).join("decoder-input"); - let Ok(read) = std::fs::read_dir(&dir) else { - continue; - }; - let mut found: Vec = read - .flatten() - .map(|e| e.path()) - .filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("mp4"))) - .collect(); - // `read_dir` order is not stable across machines and the demo tree - // must be, so the names are sorted before anything uses them. - found.sort(); - out.extend(found); - } - out -} - -/// Take the next item of `pool`, wrapping around once it runs out. `None` -/// when the pool is empty — the caller's file simply gets no real asset. -fn cycle(pool: &[PathBuf], index: &mut usize) -> Option { - if pool.is_empty() { - return None; - } - let item = pool[*index % pool.len()].clone(); - *index += 1; - Some(item) -} - -// --------------------------------------------------------------------- -// The tree -// --------------------------------------------------------------------- - -/// One node of the demo's tree: a folder with children, or a file with a -/// size, two timestamps and — maybe — a real asset behind it. Unlike -/// [`FileEntry`] this carries no full path: a node only knows its own -/// name, and the path is rebuilt by whoever is walking the tree, the same -/// way a real directory entry does not know its own parent either. -#[derive(Clone, Debug)] -struct VNode { - name: String, - is_dir: bool, - /// A file's own size; always `0` for a folder — a folder's size is the - /// fold of its children, computed by whoever needs it, exactly the way - /// [`FileEntry::size`] is `0` for a directory too. - size: u64, - modified_secs: u64, - created_secs: u64, - /// The real file [`Vfs::real_path`] hands back for this node; always - /// `None` for a folder. - real_asset: Option, - children: Vec, -} - -fn folder(name: &str, children: Vec) -> VNode { - VNode { - name: name.to_string(), - is_dir: true, - size: 0, - modified_secs: DEMO_NOW_SECS, - created_secs: DEMO_NOW_SECS, - real_asset: None, - children, - } -} - -/// Builds the seeded tree. One `Builder` lives exactly as long as -/// [`build_root`]'s call to it: the RNG state and the per-kind cycle -/// counters are what make repeated calls to `b.photo(...)` etc. hand out a -/// different (but, across two whole trees, identical) size/date/asset every -/// time. -struct Builder { - rng: Rng, - pools: Pools, - video_i: usize, - photo_i: usize, - wallpaper_i: usize, - pdf_i: usize, - png_i: usize, - csv_i: usize, - txt_i: usize, - md_i: usize, - rs_i: usize, - toml_i: usize, -} - -impl Builder { - fn new() -> Self { - Builder { - rng: Rng::new(SEED), - pools: Pools::discover(), - video_i: 0, - photo_i: 0, - wallpaper_i: 0, - pdf_i: 0, - png_i: 0, - csv_i: 0, - txt_i: 0, - md_i: 0, - rs_i: 0, - toml_i: 0, - } - } - - fn file(&mut self, name: &str, real: Option, size_range: (u64, u64)) -> VNode { - let size = seeded_size(real.as_deref(), &mut self.rng, size_range); - let (modified_secs, created_secs) = seeded_age(&mut self.rng); - VNode { name: name.to_string(), is_dir: false, size, modified_secs, created_secs, real_asset: real, children: Vec::new() } - } - - fn video(&mut self, name: &str) -> VNode { - let real = cycle(&self.pools.videos, &mut self.video_i); - self.file(name, real, (8_000_000, 120_000_000)) - } - - fn photo(&mut self, name: &str) -> VNode { - let real = cycle(&self.pools.photos, &mut self.photo_i); - self.file(name, real, (1_000_000, 6_000_000)) - } - - fn wallpaper(&mut self, name: &str) -> VNode { - let real = cycle(&self.pools.wallpapers, &mut self.wallpaper_i); - self.file(name, real, (1_000_000, 6_000_000)) - } - - fn pdf(&mut self, name: &str) -> VNode { - let real = cycle(&self.pools.pdfs, &mut self.pdf_i); - self.file(name, real, (100_000, 4_000_000)) - } - - /// A pdf pinned to one specific repo-relative asset rather than the - /// cycling pool — for the one file (`retourformulier.pdf`) whose real - /// name and content should actually agree. - fn pdf_exact(&mut self, name: &str, relative: &str) -> VNode { - let real = repo_asset(relative); - self.file(name, real, (100_000, 4_000_000)) - } - - fn screenshot(&mut self, name: &str) -> VNode { - let real = cycle(&self.pools.screenshots, &mut self.png_i); - self.file(name, real, (200_000, 3_000_000)) - } - - fn csv(&mut self, name: &str) -> VNode { - let real = cycle(&self.pools.csvs, &mut self.csv_i); - self.file(name, real, (1_000, 40_000)) - } - - fn code(&mut self, name: &str) -> VNode { - let real = cycle(&self.pools.rss, &mut self.rs_i); - self.file(name, real, (1_000, 40_000)) - } - - fn markdown(&mut self, name: &str) -> VNode { - let real = cycle(&self.pools.mds, &mut self.md_i); - self.file(name, real, (500, 20_000)) - } - - fn toml(&mut self, name: &str) -> VNode { - let real = cycle(&self.pools.tomls, &mut self.toml_i); - self.file(name, real, (200, 5_000)) - } - - fn text(&mut self, name: &str) -> VNode { - let real = cycle(&self.pools.txts, &mut self.txt_i); - self.file(name, real, (200, 20_000)) - } - - /// No repo-safe audio asset exists (see the module doc comment's list - /// of sources), so every track stays unmapped: it still gets the audio - /// icon and a plausible size, just no waveform or playback preview. - fn audio(&mut self, name: &str) -> VNode { - self.file(name, None, (3_000_000, 9_000_000)) - } - - /// Junk with no kind-appropriate repo-safe asset to point at (an - /// archive, an installer): unmapped by design, per the module's rule - /// that a wrong-kind mapping is worse than no mapping at all. - fn junk(&mut self, name: &str, size_range: (u64, u64)) -> VNode { - self.file(name, None, size_range) - } -} - -/// The whole seeded tree, rooted at [`VIRTUAL_HOME`]. See the module doc -/// comment for why this is deterministic, and the struct-level docs on -/// [`Builder`] for how the cycling works. -fn build_root() -> VNode { - let mut b = Builder::new(); - - let invoices = folder( - "invoices", - vec![ - b.pdf("invoice-2024-014.pdf"), - b.pdf("invoice-2024-021.pdf"), - b.csv("invoice-2024-033.csv"), - b.pdf("invoice-2024-045.pdf"), - b.csv("invoice-2024-058.csv"), - b.pdf("invoice-2024-067.pdf"), - b.pdf("invoice-2024-079.pdf"), - b.csv("invoice-2024-090.csv"), - ], - ); - let documents = folder( - "Documents", - vec![ - invoices, - b.markdown("notes.md"), - b.csv("budget.csv"), - b.csv("contacts.csv"), - b.pdf_exact("retourformulier.pdf", "local/retourformulier-techpunt-ned.pdf"), - ], - ); - - let vacation = folder( - "vacation-2026", - (42..54).map(|n| b.photo(&format!("IMG_{n:04}.jpg"))).collect(), - ); - let wallpapers = folder( - "wallpapers", - ["sunrise-ridge.jpg", "neon-drift.jpg", "atlas-peaks.jpg", "coral-fade.jpg", "midnight-grid.jpg", "velvet-dune.jpg"] - .iter() - .map(|n| b.wallpaper(n)) - .collect(), - ); - let pictures = folder("Pictures", vec![vacation, wallpapers]); - - let videos = folder( - "Videos", - [ - "neon-city-loop.mp4", - "ocean-drone.mp4", - "dancing-crowd.mp4", - "sunset-timelapse.mp4", - "tunnel-drive.mp4", - "plasma-bloom.mp4", - "paper-lanterns.mp4", - "rooftop-rain.mp4", - "glass-forest.mp4", - "harbour-lights.mp4", - ] - .iter() - .map(|n| b.video(n)) - .collect(), - ); - - let midnight_hours = folder( - "Midnight Hours", - vec![ - b.audio("01 Intro.mp3"), - b.audio("02 Wavelength.mp3"), - b.audio("03 Undertow.mp3"), - b.audio("04 Skyline.mp3"), - b.audio("05 Afterglow.mp3"), - ], - ); - let analog_drift = folder( - "Analog Drift", - vec![ - b.audio("01 Static Bloom.mp3"), - b.audio("02 Vector Sun.mp3"), - b.audio("03 Coastline.mp3"), - b.audio("04 Nightbus.mp3"), - b.audio("05 Drift Home.mp3"), - ], - ); - let music = folder("Music", vec![midnight_hours, analog_drift]); - - let downloads = folder( - "Downloads", - vec![ - b.junk("project-assets.zip", (5_000_000, 80_000_000)), - b.junk("App-Installer.pkg", (20_000_000, 300_000_000)), - b.screenshot("screenshot-2026-03-14.png"), - b.pdf("report-draft.pdf"), - b.csv("export-data.csv"), - b.code("scratch.rs"), - // Downloads is where a video lands before anyone files it. - b.video("trailer-cut-v3.mp4"), - b.video("clip_from_chat.mp4"), - ], - ); - - let atlas_src = folder("src", vec![b.code("main.rs"), b.code("lib.rs"), b.code("render.rs")]); - let atlas = folder("atlas", vec![b.toml("Cargo.toml"), b.markdown("README.md"), atlas_src]); - let proj_notes = folder("notes", vec![b.markdown("TODO.md"), b.text("ideas.txt")]); - let projects = folder("Projects", vec![atlas, proj_notes]); - - let trash = folder(TRASH_NAME, Vec::new()); - - folder("Demo", vec![documents, pictures, videos, music, downloads, projects, trash]) -} - -// --------------------------------------------------------------------- -// Tree lookups and edits -// --------------------------------------------------------------------- - -/// The node at `path`, or `None` when `path` is not under [`VIRTUAL_HOME`] -/// or does not exist in the tree — the same "just doesn't resolve" outcome -/// either way, since nothing this module does treats them differently. -fn resolve<'a>(root: &'a VNode, path: &Path) -> Option<&'a VNode> { - let home = Path::new(VIRTUAL_HOME); - if path == home { - return Some(root); - } - let rel = path.strip_prefix(home).ok()?; - let mut node = root; - for component in rel.components() { - let std::path::Component::Normal(part) = component else { return None }; - let name = part.to_string_lossy(); - node = node.children.iter().find(|c| c.name == name)?; - } - Some(node) -} - -/// The mutable twin of [`resolve`]. -fn resolve_mut<'a>(root: &'a mut VNode, path: &Path) -> Option<&'a mut VNode> { - let home = Path::new(VIRTUAL_HOME); - if path == home { - return Some(root); - } - let rel = path.strip_prefix(home).ok()?; - let mut node = root; - for component in rel.components() { - let std::path::Component::Normal(part) = component else { return None }; - let name = part.to_string_lossy().into_owned(); - node = node.children.iter_mut().find(|c| c.name == name)?; - } - Some(node) -} - -/// `path` split into its parent folder and its own name — `None` for a -/// path with neither (the root, or something not path-shaped at all). -fn split_path(path: &Path) -> Option<(PathBuf, String)> { - let parent = path.parent()?.to_path_buf(); - let name = path.file_name()?.to_string_lossy().into_owned(); - Some((parent, name)) -} - -/// Remove and return the child named `name`, or `None` when there is no -/// such child. -fn take_child(parent: &mut VNode, name: &str) -> Option { - let index = parent.children.iter().position(|c| c.name == name)?; - Some(parent.children.remove(index)) -} - -/// Byte total of a subtree: a file's own size, or the recursive fold of a -/// folder's children — never the folder's own (always-zero) `size` field. -/// Bails out with whatever it has already added up once `cancel` is -/// raised, matching [`crate::ops::total_bytes`]'s contract. -fn sum_bytes(node: &VNode, cancel: &AtomicBool) -> u64 { - if !node.is_dir { - return node.size; - } - let mut total = 0u64; - for child in &node.children { - if cancel.load(Ordering::SeqCst) { - break; - } - total += sum_bytes(child, cancel); - } - total -} - -/// `name` split the way [`unique_name`] needs it: a dotfile or an -/// extensionless name reports no extension, which is the signal to put the -/// disambiguating suffix at the very end instead of splicing it into the -/// name's only dot. Mirrors `ops::split_stem_ext` exactly (that one works -/// against the disk, this one against the tree — see the module doc -/// comment on why `ops.rs` isn't reused here). -fn split_stem_ext(name: &str) -> (String, String) { - let path = Path::new(name); - match (path.file_stem(), path.extension()) { - (Some(stem), Some(ext)) => (stem.to_string_lossy().into_owned(), ext.to_string_lossy().into_owned()), - _ => (name.to_string(), String::new()), - } -} - -/// A name for `name` that does not collide with any of `siblings`: "report -/// (2).txt", then "report (3).txt", exactly the way [`crate::ops::unique_path`] -/// disambiguates a real copy on disk — just checked against a folder's -/// children instead of `Path::exists`. -fn unique_name(siblings: &[VNode], name: &str) -> String { - if !siblings.iter().any(|c| c.name == name) { - return name.to_string(); - } - let (stem, ext) = split_stem_ext(name); - let mut n: u64 = 2; - loop { - let candidate = if ext.is_empty() { format!("{name} ({n})") } else { format!("{stem} ({n}).{ext}") }; - if !siblings.iter().any(|c| c.name == candidate) { - return candidate; - } - n += 1; - } -} - -/// Refuses a copy/move whose destination is one of the sources or sits -/// inside one of them — mirrors `ops::refuse_into_self`'s rule, just -/// without needing `canonicalize` (there are no symlinks, and no two -/// virtual paths ever alias the same node). -fn refuse_into_self(sources: &[PathBuf], dest_dir: &Path) -> Option { - for source in sources { - if dest_dir == source.as_path() || dest_dir.starts_with(source) { - return Some(format!("Can't copy or move \"{}\" into itself", model::display_name(source))); - } - } - None -} - -// --------------------------------------------------------------------- -// Operations -// --------------------------------------------------------------------- - -fn perform_rename(tree: &mut VNode, request: &OpRequest) -> Result { - let old_path = request.sources.first().ok_or_else(|| "Rename needs a source".to_string())?; - let new_name = request.new_name.as_deref().ok_or_else(|| "Rename needs a new name".to_string())?; - let old_name = old_path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .ok_or_else(|| format!("Can't rename {}", old_path.display()))?; - let new_path = request.dest_dir.join(new_name); - - let parent = resolve_mut(tree, &request.dest_dir) - .ok_or_else(|| format!("No such folder: {}", request.dest_dir.display()))?; - if &new_path != old_path && parent.children.iter().any(|c| c.name == new_name) { - return Err(format!("\"{new_name}\" already exists")); - } - let node = parent - .children - .iter_mut() - .find(|c| c.name == old_name) - .ok_or_else(|| format!("No such file: {}", old_path.display()))?; - node.name = new_name.to_string(); - - Ok(OpOutcome { - message: format!("Renamed to \"{new_name}\""), - undo: Some(Undo::Moved { pairs: vec![(old_path.clone(), new_path.clone())] }), - touched: vec![new_path], - }) -} - -fn perform_new_folder(tree: &mut VNode, request: &OpRequest) -> Result { - let dest = resolve_mut(tree, &request.dest_dir) - .ok_or_else(|| format!("No such folder: {}", request.dest_dir.display()))?; - if !dest.is_dir { - return Err(format!("{} is not a folder", request.dest_dir.display())); - } - let requested = request.new_name.as_deref().unwrap_or("New Folder"); - let name = unique_name(&dest.children, requested); - dest.children.push(VNode { - name: name.clone(), - is_dir: true, - size: 0, - modified_secs: DEMO_NOW_SECS, - created_secs: DEMO_NOW_SECS, - real_asset: None, - children: Vec::new(), - }); - let path = request.dest_dir.join(&name); - - Ok(OpOutcome { - message: outcome_message(OpKind::NewFolder, 1, &path), - undo: Some(Undo::Created { paths: vec![path.clone()] }), - touched: vec![path], - }) -} - -fn perform_copy(tree: &mut VNode, request: &OpRequest) -> Result { - if let Some(message) = refuse_into_self(&request.sources, &request.dest_dir) { - return Err(message); - } - let mut touched = Vec::new(); - for source in &request.sources { - let (parent_path, name) = split_path(source).ok_or_else(|| format!("Can't copy {}", source.display()))?; - let cloned: VNode = { - let parent = resolve(tree, &parent_path).ok_or_else(|| format!("No such folder: {}", parent_path.display()))?; - parent - .children - .iter() - .find(|c| c.name == name) - .ok_or_else(|| format!("No such file: {}", source.display()))? - .clone() - }; - let dest = resolve_mut(tree, &request.dest_dir) - .ok_or_else(|| format!("No such folder: {}", request.dest_dir.display()))?; - let unique = unique_name(&dest.children, &name); - let mut item = cloned; - item.name = unique.clone(); - dest.children.push(item); - touched.push(request.dest_dir.join(&unique)); - } - - Ok(OpOutcome { - message: outcome_message(OpKind::Copy, touched.len(), &request.dest_dir), - undo: Some(Undo::Created { paths: touched.clone() }), - touched, - }) -} - -fn perform_move(tree: &mut VNode, request: &OpRequest) -> Result { - if let Some(message) = refuse_into_self(&request.sources, &request.dest_dir) { - return Err(message); - } - { - let dest = resolve(tree, &request.dest_dir) - .ok_or_else(|| format!("No such folder: {}", request.dest_dir.display()))?; - if !dest.is_dir { - return Err(format!("{} is not a folder", request.dest_dir.display())); - } - } - - let mut moved_pairs = Vec::new(); - let mut touched = Vec::new(); - let mut skipped = 0usize; - for source in &request.sources { - // A cut-and-paste back onto the folder it came from is a no-op, - // not a move that happens to land where it started — same rule as - // `ops::already_there`. - if source.parent() == Some(request.dest_dir.as_path()) { - skipped += 1; - touched.push(source.clone()); - continue; - } - let (parent_path, name) = split_path(source).ok_or_else(|| format!("Can't move {}", source.display()))?; - let node = { - let parent = resolve_mut(tree, &parent_path) - .ok_or_else(|| format!("No such folder: {}", parent_path.display()))?; - take_child(parent, &name).ok_or_else(|| format!("No such file: {}", source.display()))? - }; - let dest = resolve_mut(tree, &request.dest_dir) - .ok_or_else(|| format!("No such folder: {}", request.dest_dir.display()))?; - let unique = unique_name(&dest.children, &name); - let mut item = node; - item.name = unique.clone(); - dest.children.push(item); - let target = request.dest_dir.join(&unique); - moved_pairs.push((source.clone(), target.clone())); - touched.push(target); - } - - if moved_pairs.is_empty() && skipped > 0 { - return Ok(OpOutcome { message: "Nothing to move — already there".to_string(), undo: None, touched }); - } - let message = if skipped > 0 { - format!("Moved {} item(s) ({} already there)", moved_pairs.len(), skipped) - } else { - outcome_message(OpKind::Move, moved_pairs.len(), &request.dest_dir) - }; - Ok(OpOutcome { message, undo: Some(Undo::Moved { pairs: moved_pairs }), touched }) -} - -fn perform_trash(tree: &mut VNode, request: &OpRequest) -> Result { - let trash_path = Path::new(VIRTUAL_HOME).join(TRASH_NAME); - let mut pairs = Vec::new(); - let mut touched = Vec::new(); - for source in &request.sources { - let (parent_path, name) = split_path(source).ok_or_else(|| format!("Can't trash {}", source.display()))?; - let node = { - let parent = resolve_mut(tree, &parent_path) - .ok_or_else(|| format!("No such folder: {}", parent_path.display()))?; - take_child(parent, &name).ok_or_else(|| format!("No such file: {}", source.display()))? - }; - // Seeded at construction and never removed by any operation this - // module supports, so the trash folder always exists here. - let dest = resolve_mut(tree, &trash_path).expect("the demo trash always exists"); - let unique = unique_name(&dest.children, &name); - let mut item = node; - item.name = unique.clone(); - dest.children.push(item); - let target = trash_path.join(&unique); - pairs.push((source.clone(), target.clone())); - touched.push(target); - } - - Ok(OpOutcome { - message: outcome_message(OpKind::Trash, pairs.len(), &trash_path), - undo: Some(Undo::Moved { pairs }), - touched, - }) -} - -/// Erases every source outright — no undo, no trash behind it, per -/// `OpKind::Delete`'s contract. -fn perform_delete(tree: &mut VNode, request: &OpRequest) -> Result { - let mut removed = 0usize; - for source in &request.sources { - let (parent_path, name) = split_path(source).ok_or_else(|| format!("Can't delete {}", source.display()))?; - let parent = - resolve_mut(tree, &parent_path).ok_or_else(|| format!("No such folder: {}", parent_path.display()))?; - take_child(parent, &name).ok_or_else(|| format!("No such file: {}", source.display()))?; - removed += 1; - } - Ok(OpOutcome { - message: format!("Deleted {removed} item{} permanently", if removed == 1 { "" } else { "s" }), - undo: None, - touched: Vec::new(), - }) -} - -fn undo_moved(tree: &mut VNode, pairs: &[(PathBuf, PathBuf)]) -> Result { - let mut restored = Vec::new(); - for (from, to) in pairs { - let (to_parent, to_name) = split_path(to).ok_or_else(|| format!("Can't undo move of {}", to.display()))?; - let node = { - let parent = - resolve_mut(tree, &to_parent).ok_or_else(|| format!("No such folder: {}", to_parent.display()))?; - take_child(parent, &to_name).ok_or_else(|| format!("Nothing to undo at {}", to.display()))? - }; - let (from_parent, from_name) = split_path(from).ok_or_else(|| format!("Can't undo move to {}", from.display()))?; - let dest = resolve_mut(tree, &from_parent) - .ok_or_else(|| format!("No such folder: {}", from_parent.display()))?; - let mut item = node; - item.name = from_name; - dest.children.push(item); - restored.push(from.clone()); - } - Ok(OpOutcome { message: format!("Undid move of {} item(s)", restored.len()), undo: None, touched: restored }) -} - -fn undo_created(tree: &mut VNode, paths: &[PathBuf]) -> Result { - let mut removed = Vec::new(); - for path in paths { - let (parent_path, name) = split_path(path).ok_or_else(|| format!("Can't undo creation of {}", path.display()))?; - let parent = - resolve_mut(tree, &parent_path).ok_or_else(|| format!("No such folder: {}", parent_path.display()))?; - take_child(parent, &name).ok_or_else(|| format!("Nothing to undo at {}", path.display()))?; - removed.push(path.clone()); - } - Ok(OpOutcome { message: format!("Undid creation of {} item(s)", removed.len()), undo: None, touched: removed }) -} - -// --------------------------------------------------------------------- -// Scanning, for the treemap -// --------------------------------------------------------------------- - -/// Entries visited between [`ScanProgress`] reports — the in-memory -/// equivalent of `treemap::PROGRESS_STRIDE`. The tree is tiny compared to a -/// real disk, so this mostly just guarantees the final report; it exists -/// so the demo's `scan` still honours the "bounded rate" half of the -/// contract rather than assuming a small tree makes it moot. -const SCAN_PROGRESS_STRIDE: u64 = 64; - -fn scan_vnode( - node: &VNode, - path: &Path, - cancel: &AtomicBool, - progress: &dyn Fn(ScanProgress), - total: &mut ScanProgress, - since_report: &mut u64, -) -> Option { - if cancel.load(Ordering::Relaxed) { - return None; - } - let kind = model::kind_for(path, node.is_dir) as u8; - let result = if node.is_dir { - let mut children = Vec::with_capacity(node.children.len()); - let mut size = 0u64; - for child in &node.children { - let child_path = path.join(&child.name); - let child_node = scan_vnode(child, &child_path, cancel, progress, total, since_report)?; - size += child_node.size; - children.push(child_node); - } - Node { - files: children.iter().map(|c| c.files).sum(), - modified: children.iter().map(|c| c.modified).max().unwrap_or(0), - name: node.name.clone(), - is_dir: true, - done: true, - denied: false, - size, - kind, - children, - } - } else { - total.files += 1; - total.bytes += node.size; - Node::file_at(node.name.clone(), kind, node.size, (node.modified_secs / 60) as u32) - }; - // Reported at most once every `SCAN_PROGRESS_STRIDE` nodes (folders and - // files both count), the same bounded-rate rule `treemap::scan` keeps — - // a demo tree is small enough that this rarely fires before the final - // report `Vfs::scan` sends once the whole walk is done. - *since_report += 1; - if *since_report >= SCAN_PROGRESS_STRIDE { - *since_report = 0; - progress(*total); - } - Some(result) -} - -// --------------------------------------------------------------------- -// The Vfs -// --------------------------------------------------------------------- - -/// The demo filesystem: a fake home, seeded once and mutated in place by -/// whatever the user does during a recording. Nothing here ever touches -/// `std::fs` except to read the real assets [`Vfs::real_path`] hands out -/// and to `stat` them for a byte-accurate size — the tree itself lives and -/// dies with the process. -pub struct DemoVfs { - root: Mutex, -} - -impl DemoVfs { - /// Builds the seeded tree immediately (it is cheap — a few dozen nodes - /// and a handful of `stat` calls) rather than lazily on first use, so a - /// window that opens straight into the demo home never has to wait for - /// its first listing. - pub fn new() -> Self { - DemoVfs { root: Mutex::new(build_root()) } - } -} - -impl Default for DemoVfs { - fn default() -> Self { - Self::new() - } -} - -impl Vfs for DemoVfs { - fn home(&self) -> PathBuf { - PathBuf::from(VIRTUAL_HOME) - } - - fn read_dir(&self, path: &Path, show_hidden: bool) -> Result, String> { - let tree = self.root.lock().unwrap(); - let node = resolve(&tree, path).ok_or_else(|| format!("No such folder: {}", path.display()))?; - if !node.is_dir { - return Err(format!("{} is not a folder", path.display())); - } - let mut entries = Vec::new(); - for child in &node.children { - // Same rule as `model::read_directory`: a name starting with a - // dot (here, exactly `.Trash`) is hidden unless asked for. - if !show_hidden && child.name.starts_with('.') { - continue; - } - let child_path = path.join(&child.name); - entries.push(FileEntry { - kind: model::kind_for(&child_path, child.is_dir), - name: child.name.clone(), - is_dir: child.is_dir, - size: child.size, - modified_secs: child.modified_secs, - created_secs: child.created_secs, - permissions: if child.is_dir { "rwxr-xr-x".to_string() } else { "rw-r--r--".to_string() }, - child_count: child.is_dir.then(|| child.children.len() as u32), - path: child_path, - }); - } - let mut order: Vec = (0..entries.len()).collect(); - model::sort_indices(&entries, &mut order, SortSpec::default()); - Ok(order.into_iter().map(|i| entries[i].clone()).collect()) - } - - fn is_dir(&self, path: &Path) -> bool { - let tree = self.root.lock().unwrap(); - resolve(&tree, path).is_some_and(|n| n.is_dir) - } - - fn real_path(&self, path: &Path) -> PathBuf { - // A folder never has a real asset (there is nothing to decode), and - // neither does a path that resolves to nothing at all — both fall - // back to the identity, exactly like `RealVfs::real_path`, so the - // caller never has to special-case "no mapping" against "no node". - let tree = self.root.lock().unwrap(); - match resolve(&tree, path) { - Some(node) if !node.is_dir => node.real_asset.clone().unwrap_or_else(|| path.to_path_buf()), - _ => path.to_path_buf(), - } - } - - fn total_bytes(&self, path: &Path, cancel: &AtomicBool) -> u64 { - let tree = self.root.lock().unwrap(); - resolve(&tree, path).map(|node| sum_bytes(node, cancel)).unwrap_or(0) - } - - fn scan(&self, root: &Path, cancel: &AtomicBool, progress: &dyn Fn(ScanProgress)) -> Option { - if cancel.load(Ordering::Relaxed) { - return None; - } - let tree = self.root.lock().unwrap(); - let node = resolve(&tree, root)?; - let mut total = ScanProgress::default(); - let mut since_report = 0u64; - let result = scan_vnode(node, root, cancel, progress, &mut total, &mut since_report)?; - // One last report so a caller that only reads the callback's - // argument after the walk returns still sees the true final tally - // — same guarantee `treemap::scan` makes. - progress(total); - Some(result) - } - - fn perform(&self, request: &OpRequest) -> Result { - let mut tree = self.root.lock().unwrap(); - match request.kind { - OpKind::Rename => perform_rename(&mut tree, request), - OpKind::NewFolder => perform_new_folder(&mut tree, request), - OpKind::Copy => perform_copy(&mut tree, request), - OpKind::Move => perform_move(&mut tree, request), - OpKind::Trash => perform_trash(&mut tree, request), - OpKind::Delete => perform_delete(&mut tree, request), - } - } - - fn perform_undo(&self, undo: &Undo) -> Result { - let mut tree = self.root.lock().unwrap(); - match undo { - Undo::Moved { pairs } => undo_moved(&mut tree, pairs), - Undo::Created { paths } => undo_created(&mut tree, paths), - } - } - - fn is_instant(&self) -> bool { - true - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn listing(vfs: &DemoVfs, path: &str) -> Vec { - vfs.read_dir(Path::new(path), true).unwrap() - } - - fn top_level_folders() -> [&'static str; 6] { - ["Documents", "Pictures", "Videos", "Music", "Downloads", "Projects"] - } - - /// A listing carries enough to prove two trees are identical without - /// pulling in the whole `FileEntry` (whose `path` also embeds the - /// comparison, redundantly, once name is included). - fn fingerprint(entries: &[FileEntry]) -> Vec<(String, bool, u64, u64, u64)> { - entries.iter().map(|e| (e.name.clone(), e.is_dir, e.size, e.modified_secs, e.created_secs)).collect() - } - - #[test] - fn the_tree_is_deterministic() { - let a = DemoVfs::new(); - let b = DemoVfs::new(); - // Depth-first over every folder in the tree, comparing each one's - // listing between the two instances. - let mut stack = vec![PathBuf::from(VIRTUAL_HOME)]; - let mut folders_checked = 0; - while let Some(dir) = stack.pop() { - let la = listing(&a, dir.to_str().unwrap()); - let lb = listing(&b, dir.to_str().unwrap()); - assert_eq!(fingerprint(&la), fingerprint(&lb), "listing of {} differs between two demo trees", dir.display()); - folders_checked += 1; - for entry in &la { - if entry.is_dir { - stack.push(entry.path.clone()); - } - } - } - // Home itself, its six visible children and .Trash, plus every - // folder nested under them. - assert!(folders_checked > 10, "suspiciously few folders walked: {folders_checked}"); - } - - #[test] - fn no_timestamp_is_zero_or_in_the_future() { - let vfs = DemoVfs::new(); - let mut stack = vec![PathBuf::from(VIRTUAL_HOME)]; - let mut files_checked = 0; - while let Some(dir) = stack.pop() { - for entry in listing(&vfs, dir.to_str().unwrap()) { - if entry.is_dir { - stack.push(entry.path.clone()); - continue; - } - files_checked += 1; - assert_ne!(entry.modified_secs, 0, "{} has no modified time", entry.path.display()); - assert_ne!(entry.created_secs, 0, "{} has no created time", entry.path.display()); - assert!(entry.modified_secs <= DEMO_NOW_SECS, "{} is modified in the future", entry.path.display()); - assert!(entry.created_secs <= DEMO_NOW_SECS, "{} is created in the future", entry.path.display()); - } - } - assert!(files_checked > 20, "suspiciously few files walked: {files_checked}"); - } - - #[test] - fn mapped_files_point_at_a_real_asset_of_the_matching_kind() { - let vfs = DemoVfs::new(); - let mut stack = vec![PathBuf::from(VIRTUAL_HOME)]; - let mut mapped = 0; - let mut unmapped = 0; - while let Some(dir) = stack.pop() { - for entry in listing(&vfs, dir.to_str().unwrap()) { - if entry.is_dir { - stack.push(entry.path.clone()); - continue; - } - let real = vfs.real_path(&entry.path); - if real == entry.path { - unmapped += 1; - continue; - } - mapped += 1; - assert!(real.exists(), "{} claims to map to {} which does not exist", entry.path.display(), real.display()); - let virtual_kind = model::kind_for(&entry.path, false); - let real_kind = model::kind_for(&real, false); - assert_eq!( - virtual_kind, real_kind, - "{} ({:?}) maps to {} ({:?}) — kinds disagree", - entry.path.display(), - virtual_kind, - real.display(), - real_kind - ); - } - } - assert!(mapped > 0, "nothing mapped to a real asset at all"); - // Documented in the module's report to the integrator: audio and - // some junk are expected to stay unmapped. - assert!(unmapped > 0, "expected at least the audio tracks to stay unmapped"); - } - - #[test] - fn read_dir_sorts_folders_first_then_by_name() { - let vfs = DemoVfs::new(); - let entries = listing(&vfs, VIRTUAL_HOME); - let first_file = entries.iter().position(|e| !e.is_dir); - let last_folder = entries.iter().rposition(|e| e.is_dir); - if let (Some(first_file), Some(last_folder)) = (first_file, last_folder) { - assert!(last_folder < first_file, "a folder sorted after a file"); - } - let names: Vec<&str> = entries.iter().filter(|e| e.is_dir).map(|e| e.name.as_str()).collect(); - let mut sorted = names.clone(); - sorted.sort_by_key(|n| n.to_lowercase()); - assert_eq!(names, sorted); - } - - #[test] - fn rename_works_collides_and_undoes() { - let vfs = DemoVfs::new(); - let dest_dir = PathBuf::from(VIRTUAL_HOME).join("Documents"); - let old_path = dest_dir.join("notes.md"); - - let outcome = vfs - .perform(&OpRequest { - id: 1, - kind: OpKind::Rename, - sources: vec![old_path.clone()], - dest_dir: dest_dir.clone(), - new_name: Some("journal.md".to_string()), - home: vfs.home(), - }) - .unwrap(); - let new_path = dest_dir.join("journal.md"); - assert_eq!(outcome.touched, vec![new_path.clone()]); - assert!(vfs.read_dir(&dest_dir, true).unwrap().iter().any(|e| e.name == "journal.md")); - assert!(!vfs.read_dir(&dest_dir, true).unwrap().iter().any(|e| e.name == "notes.md")); - - // Renaming onto an existing sibling is refused. - let collide = vfs.perform(&OpRequest { - id: 2, - kind: OpKind::Rename, - sources: vec![new_path.clone()], - dest_dir: dest_dir.clone(), - new_name: Some("budget.csv".to_string()), - home: vfs.home(), - }); - assert!(collide.is_err()); - - let Some(Undo::Moved { pairs }) = outcome.undo else { panic!("expected a Moved undo") }; - let undo_outcome = vfs.perform_undo(&Undo::Moved { pairs }).unwrap(); - assert_eq!(undo_outcome.touched, vec![old_path.clone()]); - assert!(vfs.read_dir(&dest_dir, true).unwrap().iter().any(|e| e.name == "notes.md")); - } - - #[test] - fn copy_into_the_same_folder_gets_a_suffix_and_undoes() { - let vfs = DemoVfs::new(); - let dir = PathBuf::from(VIRTUAL_HOME).join("Documents"); - let source = dir.join("notes.md"); - - let outcome = vfs - .perform(&OpRequest { - id: 1, - kind: OpKind::Copy, - sources: vec![source.clone()], - dest_dir: dir.clone(), - new_name: None, - home: vfs.home(), - }) - .unwrap(); - let copy_path = dir.join("notes (2).md"); - assert_eq!(outcome.touched, vec![copy_path.clone()]); - assert!(vfs.read_dir(&dir, true).unwrap().iter().any(|e| e.name == "notes.md"), "the original must survive its own copy"); - assert!(vfs.read_dir(&dir, true).unwrap().iter().any(|e| e.name == "notes (2).md")); - - let Some(Undo::Created { paths }) = outcome.undo else { panic!("expected a Created undo") }; - vfs.perform_undo(&Undo::Created { paths }).unwrap(); - assert!(!vfs.read_dir(&dir, true).unwrap().iter().any(|e| e.name == "notes (2).md")); - assert!(vfs.read_dir(&dir, true).unwrap().iter().any(|e| e.name == "notes.md")); - } - - #[test] - fn trash_moves_out_and_undo_restores_it() { - let vfs = DemoVfs::new(); - let dir = PathBuf::from(VIRTUAL_HOME).join("Documents"); - let source = dir.join("budget.csv"); - - let outcome = vfs - .perform(&OpRequest { - id: 1, - kind: OpKind::Trash, - sources: vec![source.clone()], - dest_dir: dir.clone(), - new_name: None, - home: vfs.home(), - }) - .unwrap(); - assert!(!vfs.read_dir(&dir, true).unwrap().iter().any(|e| e.name == "budget.csv")); - let trash_path = PathBuf::from(VIRTUAL_HOME).join(".Trash").join("budget.csv"); - assert_eq!(outcome.touched, vec![trash_path.clone()]); - assert!(vfs.read_dir(&PathBuf::from(VIRTUAL_HOME).join(".Trash"), true).unwrap().iter().any(|e| e.name == "budget.csv")); - - let Some(Undo::Moved { pairs }) = outcome.undo else { panic!("expected a Moved undo") }; - vfs.perform_undo(&Undo::Moved { pairs }).unwrap(); - assert!(vfs.read_dir(&dir, true).unwrap().iter().any(|e| e.name == "budget.csv"), "undo must put it back in the same folder"); - } - - #[test] - fn delete_removes_permanently_with_no_undo() { - let vfs = DemoVfs::new(); - let dir = PathBuf::from(VIRTUAL_HOME).join("Documents"); - let source = dir.join("contacts.csv"); - - let outcome = vfs - .perform(&OpRequest { - id: 1, - kind: OpKind::Delete, - sources: vec![source.clone()], - dest_dir: dir.clone(), - new_name: None, - home: vfs.home(), - }) - .unwrap(); - assert!(outcome.undo.is_none()); - assert!(outcome.touched.is_empty()); - assert!(!vfs.read_dir(&dir, true).unwrap().iter().any(|e| e.name == "contacts.csv")); - } - - #[test] - fn total_bytes_matches_the_scans_own_size() { - let vfs = DemoVfs::new(); - let home = PathBuf::from(VIRTUAL_HOME); - let cancel = AtomicBool::new(false); - let total = vfs.total_bytes(&home, &cancel); - assert!(total > 0); - - let scanned = vfs.scan(&home, &cancel, &|_| {}).expect("scan should complete"); - assert_eq!(scanned.size, total); - - // And the scan's own count should agree with a manual walk. - let mut stack = vec![home.clone()]; - let mut file_total = 0u64; - while let Some(dir) = stack.pop() { - for entry in vfs.read_dir(&dir, true).unwrap() { - if entry.is_dir { - stack.push(entry.path.clone()); - } else { - file_total += entry.size; - } - } - } - assert_eq!(file_total, total); - } - - #[test] - fn home_is_demo_and_operations_are_instant() { - let vfs = DemoVfs::new(); - assert_eq!(vfs.home(), PathBuf::from("/Demo")); - assert!(vfs.is_instant()); - assert!(vfs.is_demo()); - } - - #[test] - fn every_expected_top_level_folder_is_present_and_non_empty() { - let vfs = DemoVfs::new(); - let root = listing(&vfs, VIRTUAL_HOME); - for name in top_level_folders() { - let entry = root.iter().find(|e| e.name == name).unwrap_or_else(|| panic!("missing top-level folder {name}")); - assert!(entry.is_dir); - let children = listing(&vfs, &format!("{VIRTUAL_HOME}/{name}")); - assert!(!children.is_empty(), "{name} has no contents"); - } - // The trash exists (it showed up in `root`, which asked to see - // hidden entries too) but is hidden from a normal listing. - assert!(root.iter().any(|e| e.name == ".Trash"), "the trash folder should still exist when hidden entries are shown"); - assert!(vfs.read_dir(Path::new(VIRTUAL_HOME), false).unwrap().iter().all(|e| !e.name.starts_with('.'))); - } -} diff --git a/apps/mpfiles/src/main.rs b/apps/mpfiles/src/main.rs deleted file mode 100644 index f6b4cdfb4..000000000 --- a/apps/mpfiles/src/main.rs +++ /dev/null @@ -1,5232 +0,0 @@ -//! mpfiles — the file browser of the mp* desktop. -//! -//! A GNOME-Files-shaped browser: tabs, a places-and-bookmarks sidebar, an -//! editable breadcrumb path bar, and four views over one folder (icons with -//! real thumbnails, a sortable DataGrid list with expandable folders, a -//! compact list, and a treemap of where the bytes actually are). Space quick- -//! looks the selection the way macOS does; inside mpwm the compositor hosts -//! that popup for us. -//! -//! Everything here is the shell — the entry model lives in `model`, the views -//! in `contents`, thumbnails in `thumbs`, file operations in `ops`, the -//! treemap's arithmetic in `treemap` and its widget in `treemap_view`. - -pub use makepad_widgets; - -use makepad_widgets::makepad_platform::thread::SignalToUI; -use makepad_widgets::*; - -use std::{ - path::{Path, PathBuf}, - process::Command, - sync::{ - atomic::{AtomicBool, Ordering}, - mpsc::{self, Receiver, Sender}, - Arc, - }, - thread, -}; - -mod bookmarks; -mod chat_agent; -mod chat_panel; -mod chat_tools; -mod contents; -mod demo; -mod menu; -mod model; -mod ops; -mod preview; -mod rename; -mod sizecache; -mod theme; -mod thumbs; -mod treemap; -mod treemap_view; -mod vfs; - -use crate::{ - bookmarks::Bookmarks, - chat_agent::{ChatAgent, ChatEvent}, - chat_panel::{ChatState, ChatVoice}, - chat_tools::{ToolJob, ToolRunner}, - contents::{FileContents, FileContentsAction, ViewMode, DEFAULT_ZOOM, ZOOM_LEVELS}, - model::{display_name, trash_dir, FileEntry}, - menu::{MenuAction, MenuRow}, - ops::{Journal, OpKind, OpRequest, OpUpdate, Ops, Undo}, - preview::{Preview, PreviewHost}, - rename::BatchMode, - theme::Palette, - treemap_view::MapProjection, - vfs::vfs, -}; - -app_main!(App); - -script_mod! { - use mod.prelude.widgets.* - use mod.widgets.* - - let ToolButton = View{ - width: 28 - height: 28 - flow: Overlay - align: Align{x: 0.5 y: 0.5} - cursor: MouseCursor.Hand - btn_sel := SolidView{ - visible: false - width: Fill - height: Fill - draw_bg +: {color: mod.mpf.sel} - } - } - - let SideItem = SolidView{ - width: Fill - height: 32 - flow: Right - spacing: 12 - padding: Inset{left: 18 right: 12} - align: Align{y: 0.5} - cursor: MouseCursor.Hand - draw_bg +: {color: mod.mpf.bg_dark} - side_icon := Icon{ - icon_walk: Walk{width: 16 height: 16} - draw_icon +: {color: mod.mpf.fg_dim} - } - side_title := Label{ - width: Fill - max_lines: 1 - text_overflow: TextOverflow.Ellipsis - draw_text +: { - color: mod.mpf.fg - text_style: theme.font_regular{font_size: 10.0} - } - } - } - - // A bookmark is a place with a remove button that appears under the - // pointer — the same shape GNOME's sidebar uses. - let BookmarkItem = SolidView{ - visible: false - width: Fill - height: 32 - flow: Right - spacing: 12 - padding: Inset{left: 18 right: 8} - align: Align{y: 0.5} - cursor: MouseCursor.Hand - draw_bg +: {color: mod.mpf.bg_dark} - bm_icon := Icon{ - icon_walk: Walk{width: 13 height: 13} - draw_icon +: { - svg: crate_resource("self://resources/icons/bookmark.svg") - color: mod.mpf.fg_dim - } - } - bm_title := Label{ - width: Fill - max_lines: 1 - text_overflow: TextOverflow.Ellipsis - draw_text +: { - color: mod.mpf.fg - text_style: theme.font_regular{font_size: 10.0} - } - } - bm_remove := View{ - visible: false - width: 18 - height: 18 - align: Align{x: 0.5 y: 0.5} - cursor: MouseCursor.Hand - Icon{ - icon_walk: Walk{width: 9 height: 9} - draw_icon +: { - svg: crate_resource("self://resources/icons/close.svg") - color: mod.mpf.fg_dim - } - } - } - } - - let SectionLabel = Label{ - height: 26 - padding: Inset{left: 18 top: 8} - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_bold{font_size: 8.0} - } - } - - let Divider = View{ - width: Fill - height: 13 - align: Align{y: 0.5} - SolidView{ - width: Fill - height: 1 - margin: Inset{left: 16 right: 16} - draw_bg +: {color: mod.mpf.muted} - } - } - - let MenuRow = View{ - width: Fill - height: 28 - flow: Right - spacing: 8 - padding: Inset{left: 14 right: 14} - align: Align{y: 0.5} - cursor: MouseCursor.Hand - // A tick is an icon like every other mark in this app: the UI font - // draws U+2713 as something closer to a radical sign. - menu_check := View{ - visible: false - width: 10 - height: 10 - align: Align{x: 0.5 y: 0.5} - Icon{ - icon_walk: Walk{width: 9 height: 9} - draw_icon +: { - svg: crate_resource("self://resources/icons/check.svg") - color: mod.mpf.accent - } - } - } - menu_gap := View{ - width: 10 - height: 1 - } - menu_label := Label{ - width: Fill - draw_text +: { - color: mod.mpf.fg - text_style: theme.font_regular{font_size: 10.0} - } - } - } - - /** One row of the filter popup's legend: swatch, kind, live bytes. - * Clicking it IS the filter toggle for that kind. */ - let LegendRow = SolidView{ - width: Fill - height: 22 - flow: Right - spacing: 8 - padding: Inset{left: 8 right: 8} - align: Align{y: 0.5} - cursor: MouseCursor.Hand - draw_bg +: {color: #00000000} - lg_swatch := SolidView{ - width: 10 - height: 10 - draw_bg +: {color: #x565f89} - } - lg_name := Label{ - width: Fill - draw_text +: { - color: mod.mpf.fg - text_style: theme.font_regular{font_size: 9.5} - } - } - lg_bytes := Label{ - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 9.5} - } - } - } - - /** One "modified within" choice. The box is the text plus the same - * padding on every side, so the highlight is centred by construction — - * a fixed height had the label riding low in it. */ - let AgeChip = SolidView{ - width: Fit - height: Fit - padding: Inset{left: 5 right: 5 top: 3 bottom: 3} - cursor: MouseCursor.Hand - draw_bg +: {color: #00000000} - chip_label := Label{ - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 9.0} - } - } - } - - let Crumb = View{ - width: Fit - height: 24 - padding: Inset{left: 8 right: 8} - align: Align{y: 0.5} - cursor: MouseCursor.Hand - crumb_title := Label{ - max_lines: 1 - draw_text +: { - color: mod.mpf.fg - text_style: theme.font_regular{font_size: 10.0} - } - } - } - - let CrumbSep = Label{ - text: "›" - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 10.0} - } - } - - // One tab. Square, flat, filled when active — the strip only appears once - // there is more than one of them. - let TabItem = SolidView{ - visible: false - width: 172 - height: Fill - flow: Right - spacing: 4 - padding: Inset{left: 12 right: 6} - align: Align{y: 0.5} - cursor: MouseCursor.Hand - draw_bg +: {color: mod.mpf.bg_dark} - tab_title := Label{ - width: Fill - max_lines: 1 - text_overflow: TextOverflow.Ellipsis - draw_text +: { - color: mod.mpf.fg - text_style: theme.font_regular{font_size: 9.5} - } - } - tab_close := View{ - width: 18 - height: 18 - align: Align{x: 0.5 y: 0.5} - cursor: MouseCursor.Hand - Icon{ - icon_walk: Walk{width: 9 height: 9} - draw_icon +: { - svg: crate_resource("self://resources/icons/close.svg") - color: mod.mpf.fg_dim - } - } - } - } - - // One line of the properties panel: a quiet key over its value. - let PropRow = View{ - width: Fill - height: Fit - flow: Down - spacing: 2 - padding: Inset{left: 16 right: 16 top: 7 bottom: 7} - prop_key := Label{ - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_bold{font_size: 8.0} - } - } - prop_value := Label{ - width: Fill - draw_text +: { - color: mod.mpf.fg_bright - text_style: theme.font_regular{font_size: 9.5} - } - } - } - - // The omarchy popup card: the theme background behind a 2px accent edge, - // hard corners, 28pt rows, and a hover that is the foreground at 8% with - // accent text. No fade — a menu that animates in is a menu you wait for. - let CtxRow = View{ - visible: false - width: Fill - height: Fit - flow: Down - ctx_line := SolidView{ - visible: false - width: Fill - height: 1 - margin: Inset{top: 4 bottom: 4} - draw_bg +: {color: mod.mpf.muted} - } - ctx_body := SolidView{ - width: Fill - height: 28 - flow: Right - spacing: 12 - padding: Inset{left: 14 right: 14} - align: Align{y: 0.5} - cursor: MouseCursor.Hand - draw_bg +: {color: mod.mpf.bg} - ctx_label := Label{ - width: Fill - max_lines: 1 - text_overflow: TextOverflow.Ellipsis - draw_text +: { - color: mod.mpf.fg - text_style: theme.font_regular{font_size: 9.5} - } - } - ctx_hint := Label{ - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 8.5} - } - } - } - } - - let CtxPanel = RectView{ - width: 268 - height: Fit - flow: Down - padding: Inset{top: 5 bottom: 5} - draw_bg +: { - color: mod.mpf.bg - border_color: mod.mpf.accent - border_size: 2.0 - } - } - - let DialogButton = RectView{ - width: Fit - height: 28 - padding: Inset{left: 16 right: 16} - align: Align{x: 0.5 y: 0.5} - cursor: MouseCursor.Hand - draw_bg +: { - color: mod.mpf.bg_light - border_color: mod.mpf.muted - border_size: 1.0 - } - dlg_label := Label{ - draw_text +: { - color: mod.mpf.fg_bright - text_style: theme.font_regular{font_size: 9.5} - } - } - } - - let DialogField = View{ - width: Fill - height: Fit - flow: Down - spacing: 4 - padding: Inset{top: 6 bottom: 6} - field_key := Label{ - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_bold{font_size: 8.0} - } - } - field_box := View{ - width: Fill - height: 26 - field_input := MpfInput{} - } - } - - mod.widgets.BreadcrumbsBase = #(Breadcrumbs::register_widget(vm)) - mod.widgets.Breadcrumbs = set_type_default() do mod.widgets.BreadcrumbsBase{ - width: Fit - height: Fill - flow: Right - spacing: 1 - align: Align{y: 0.5} - clip_x: true - c0 := Crumb{} - s0 := CrumbSep{} - c1 := Crumb{} - s1 := CrumbSep{} - c2 := Crumb{} - s2 := CrumbSep{} - c3 := Crumb{} - s3 := CrumbSep{} - c4 := Crumb{} - s4 := CrumbSep{} - c5 := Crumb{} - } - - startup() do #(App::script_component(vm)){ - ui: Root{ - main_window := Window{ - window.title: "Files" - window.inner_size: vec2(1240, 800) - pass.clear_color: mod.mpf.bg - body +: { - flow: Overlay - app_bg := SolidView{ - width: Fill - height: Fill - flow: Down - draw_bg +: {color: mod.mpf.bg} - - top_bar := SolidView{ - width: Fill - height: 38 - flow: Right - spacing: 4 - padding: Inset{right: 10} - align: Align{y: 0.5} - draw_bg +: {color: mod.mpf.bg_light} - - title_box := View{ - width: 208 - height: Fill - padding: Inset{left: 18} - align: Align{y: 0.5} - files_title := Label{ - text: "Files" - draw_text +: { - color: mod.mpf.fg_bright - text_style: theme.font_bold{font_size: 11.0} - } - } - } - - back_button := ToolButton{ - Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/back.svg") - color: mod.mpf.fg - } - } - } - forward_button := ToolButton{ - Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/forward.svg") - color: mod.mpf.fg - } - } - } - - path_box := RectView{ - width: Fill - height: 28 - flow: Right - margin: Inset{left: 4 right: 4} - padding: Inset{left: 5 right: 5} - align: Align{y: 0.5} - draw_bg +: { - color: mod.mpf.bg - border_color: mod.mpf.muted - border_size: 1.0 - } - // The crumb box fills the plate so a click in - // the empty space past the last crumb still - // lands somewhere — that is what opens the - // editable path. - crumb_box := View{ - width: Fill - height: Fill - align: Align{y: 0.5} - cursor: MouseCursor.Text - breadcrumbs := mod.widgets.Breadcrumbs{} - } - path_edit_box := View{ - visible: false - width: Fill - height: Fill - path_edit := MpfInput{ - empty_text: "Type a path" - draw_bg +: { - border_size: uniform(0.0) - } - } - } - search_box := View{ - visible: false - width: Fill - height: Fill - search_input := MpfInput{ - empty_text: "Search this folder" - draw_bg +: { - border_size: uniform(0.0) - } - } - } - } - - icons_button := ToolButton{ - Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/grid.svg") - color: mod.mpf.fg - } - } - } - list_button := ToolButton{ - Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/list.svg") - color: mod.mpf.fg - } - } - } - compact_button := ToolButton{ - Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/compact.svg") - color: mod.mpf.fg - } - } - } - treemap_button := ToolButton{ - Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/treemap.svg") - color: mod.mpf.fg - } - } - } - - View{width: 6 height: 1} - - newfolder_button := ToolButton{ - Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/newfolder.svg") - color: mod.mpf.fg - } - } - } - terminal_button := ToolButton{ - Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/terminal.svg") - color: mod.mpf.fg - } - } - } - props_button := ToolButton{ - Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/info.svg") - color: mod.mpf.fg - } - } - } - search_button := ToolButton{ - Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/search.svg") - color: mod.mpf.fg - } - } - } - preview_button := ToolButton{ - Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/eye.svg") - color: mod.mpf.fg - } - } - } - chat_button := ToolButton{ - Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/chat.svg") - color: mod.mpf.fg - } - } - } - menu_button := ToolButton{ - Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/menu-dots.svg") - color: mod.mpf.fg - } - } - } - } - - tab_strip := SolidView{ - visible: false - width: Fill - height: 26 - flow: Right - spacing: 1 - padding: Inset{left: 1} - draw_bg +: {color: mod.mpf.bg} - tab0 := TabItem{} - tab1 := TabItem{} - tab2 := TabItem{} - tab3 := TabItem{} - tab4 := TabItem{} - tab5 := TabItem{} - tab6 := TabItem{} - tab7 := TabItem{} - } - - body_row := View{ - width: Fill - height: Fill - flow: Right - - sidebar := SolidView{ - width: 208 - height: Fill - flow: Down - draw_bg +: {color: mod.mpf.bg_dark} - - side_scroll := ScrollYView{ - width: Fill - height: Fill - flow: Down - padding: Inset{top: 10 bottom: 10} - - home_item := SideItem{ - side_icon +: {draw_icon +: {svg: crate_resource("self://resources/icons/home.svg")}} - side_title +: {text: "Home"} - } - recent_item := SideItem{ - side_icon +: {draw_icon +: {svg: crate_resource("self://resources/icons/clock.svg")}} - side_title +: {text: "Recent"} - } - starred_item := SideItem{ - side_icon +: {draw_icon +: {svg: crate_resource("self://resources/icons/star.svg")}} - side_title +: {text: "Starred"} - } - network_item := SideItem{ - side_icon +: {draw_icon +: {svg: crate_resource("self://resources/icons/network.svg")}} - side_title +: {text: "Network"} - } - trash_item := SideItem{ - side_icon +: {draw_icon +: {svg: crate_resource("self://resources/icons/trash.svg")}} - side_title +: {text: "Trash"} - } - - Divider{} - SectionLabel{text: "PLACES"} - - desktop_item := SideItem{ - side_icon +: {draw_icon +: {svg: crate_resource("self://resources/icons/folder.svg")}} - side_title +: {text: "Desktop"} - } - documents_item := SideItem{ - side_icon +: {draw_icon +: {svg: crate_resource("self://resources/icons/folder.svg")}} - side_title +: {text: "Documents"} - } - downloads_item := SideItem{ - side_icon +: {draw_icon +: {svg: crate_resource("self://resources/icons/folder.svg")}} - side_title +: {text: "Downloads"} - } - music_item := SideItem{ - side_icon +: {draw_icon +: {svg: crate_resource("self://resources/icons/folder.svg")}} - side_title +: {text: "Music"} - } - pictures_item := SideItem{ - side_icon +: {draw_icon +: {svg: crate_resource("self://resources/icons/folder.svg")}} - side_title +: {text: "Pictures"} - } - videos_item := SideItem{ - side_icon +: {draw_icon +: {svg: crate_resource("self://resources/icons/folder.svg")}} - side_title +: {text: "Videos"} - } - - Divider{} - SectionLabel{text: "BOOKMARKS"} - bookmark_hint := Label{ - width: Fill - height: 34 - padding: Inset{left: 18 right: 12} - max_lines: 2 - text: "Cmd+D bookmarks this folder, or drag one here" - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 8.0} - } - } - bm0 := BookmarkItem{} - bm1 := BookmarkItem{} - bm2 := BookmarkItem{} - bm3 := BookmarkItem{} - bm4 := BookmarkItem{} - bm5 := BookmarkItem{} - bm6 := BookmarkItem{} - bm7 := BookmarkItem{} - bm8 := BookmarkItem{} - bm9 := BookmarkItem{} - bm10 := BookmarkItem{} - bm11 := BookmarkItem{} - } - hidden_hint := Label{ - width: Fill - height: 22 - padding: Inset{left: 18} - text: "Ctrl+H Show hidden files" - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 8.0} - } - } - } - - content_bg := SolidView{ - width: Fill - height: Fill - flow: Down - draw_bg +: {color: mod.mpf.bg} - - folder_header := View{ - width: Fill - height: 44 - flow: Right - padding: Inset{left: 20 right: 20} - align: Align{y: 0.5} - folder_title := Label{ - width: Fill - max_lines: 1 - text_overflow: TextOverflow.Ellipsis - text: "Home" - draw_text +: { - color: mod.mpf.fg_bright - text_style: theme.font_bold{font_size: 12.0} - } - } - item_count := Label{ - text: "Loading…" - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 9.0} - } - } - } - empty_label := Label{ - visible: false - width: Fill - height: 46 - padding: Inset{left: 20} - text: "This folder is empty" - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 10.0} - } - } - - // The map's own tool strip. It only exists in - // the Treemap view, and everything on it acts - // on the rectangle that is picked — which is - // what a right-click used to be for. - map_tools := SolidView{ - visible: false - width: Fill - height: 30 - flow: Right - spacing: 2 - padding: Inset{left: 16 right: 16} - align: Align{y: 0.5} - draw_bg +: {color: mod.mpf.bg_dark} - // The render-mode switch: one block view, - // three ways of looking at it. - proj_flat := ToolButton{ - Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/treemap.svg") - color: mod.mpf.fg - } - } - } - proj_ortho := ToolButton{ - Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/treemap25.svg") - color: mod.mpf.fg - } - } - } - proj_persp := ToolButton{ - Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/treemap3d.svg") - color: mod.mpf.fg - } - } - } - View{width: 10 height: 1} - map_rescan := ToolButton{ - map_rescan_icon := Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/reload.svg") - color: mod.mpf.fg - } - } - } - View{width: 10 height: 1} - map_trash := ToolButton{ - map_trash_icon := Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/trash.svg") - color: mod.mpf.muted - } - } - } - map_erase := ToolButton{ - map_erase_icon := Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/delete-forever.svg") - color: mod.mpf.muted - } - } - } - View{width: 10 height: 1} - map_filter := ToolButton{ - map_filter_icon := Icon{ - icon_walk: Walk{width: 15 height: 15} - draw_icon +: { - svg: crate_resource("self://resources/icons/filter.svg") - color: mod.mpf.fg - } - } - } - map_tools_hint := Label{ - width: Fill - max_lines: 1 - margin: Inset{left: 8} - text_overflow: TextOverflow.Ellipsis - text: "Click a rectangle to pick it" - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 8.5} - } - } - map_scan_all := CheckBox{ - text: "ignore system" - } - } - map_row := View{ - width: Fill - height: Fill - flow: Right - contents := mod.widgets.FileContents{} - // The filter, docked: everything in it - // applies live, and the map tweens right - // beside it while you fiddle. - map_side := SolidView{ - visible: false - width: 258 - height: Fill - draw_bg +: {color: mod.mpf.bg_dark} - ScrollYView{ - width: Fill - height: Fill - flow: Down - spacing: 6 - padding: Inset{left: 10 right: 10 top: 10 bottom: 10} - Label{ - text: "FILTER" - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_bold{font_size: 8.0} - } - } - filter_query := MpfInput{ - width: Fill - height: 26 - empty_text: "name, .ext, >100mb, <7d" - } - View{ - width: Fill - height: Fit - flow: Right - spacing: 8 - align: Align{y: 0.5} - filter_size_label := Label{ - width: 96 - text: "any size" - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 9.0} - } - } - filter_size := Slider{ - width: Fill - height: 18 - text: "" - } - } - filter_age_row := View{ - width: Fill - height: Fit - flow: Right - spacing: 2 - align: Align{y: 0.5} - filter_age_hint := Label{ - margin: Inset{right: 4} - text: "new:" - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 9.0} - } - } - filter_age0 := AgeChip{chip_label +: {text: "any"}} - filter_age1 := AgeChip{chip_label +: {text: "1d"}} - filter_age2 := AgeChip{chip_label +: {text: "3d"}} - filter_age3 := AgeChip{chip_label +: {text: "1w"}} - filter_age4 := AgeChip{chip_label +: {text: "1mo"}} - filter_age5 := AgeChip{chip_label +: {text: "1y"}} - } - Hr{} - filter_kind0 := LegendRow{} - filter_kind1 := LegendRow{} - filter_kind2 := LegendRow{} - filter_kind3 := LegendRow{} - filter_kind4 := LegendRow{} - filter_kind5 := LegendRow{} - filter_kind6 := LegendRow{} - filter_clear := View{ - width: Fill - height: 20 - align: Align{x: 1.0 y: 0.5} - cursor: MouseCursor.Hand - clear_label := Label{ - text: "clear all" - draw_text +: { - color: mod.mpf.accent - text_style: theme.font_regular{font_size: 9.0} - } - } - } - } - } - } - - progress_row := SolidView{ - visible: false - width: Fill - height: 30 - flow: Right - spacing: 12 - padding: Inset{left: 16 right: 12} - align: Align{y: 0.5} - draw_bg +: {color: mod.mpf.bg_light} - progress_track := SolidView{ - width: 150 - height: 6 - draw_bg +: {color: mod.mpf.muted} - progress_fill := SolidView{ - width: 0 - height: Fill - draw_bg +: {color: mod.mpf.accent} - } - } - progress_label := Label{ - width: Fill - max_lines: 1 - text_overflow: TextOverflow.Ellipsis - draw_text +: { - color: mod.mpf.fg - text_style: theme.font_regular{font_size: 8.5} - } - } - progress_cancel := View{ - width: Fit - height: 22 - padding: Inset{left: 10 right: 10} - align: Align{y: 0.5} - cursor: MouseCursor.Hand - Label{ - text: "Cancel" - draw_text +: { - color: mod.mpf.accent - text_style: theme.font_regular{font_size: 8.5} - } - } - } - } - - status_bar := SolidView{ - width: Fill - height: 26 - padding: Inset{left: 16 right: 16} - align: Align{y: 0.5} - draw_bg +: {color: mod.mpf.bg_dark} - status_label := Label{ - width: Fill - max_lines: 1 - text_overflow: TextOverflow.Ellipsis - text: "Loading…" - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 8.5} - } - } - } - } - - props_panel := SolidView{ - visible: false - width: 306 - height: Fill - flow: Down - draw_bg +: {color: mod.mpf.bg_dark} - props_header := SolidView{ - width: Fill - height: 36 - flow: Right - padding: Inset{left: 16 right: 10} - align: Align{y: 0.5} - draw_bg +: {color: mod.mpf.bg_light} - Label{ - width: Fill - text: "Properties" - draw_text +: { - color: mod.mpf.fg_bright - text_style: theme.font_bold{font_size: 10.0} - } - } - props_close := View{ - width: 20 - height: 20 - align: Align{x: 0.5 y: 0.5} - cursor: MouseCursor.Hand - Icon{ - icon_walk: Walk{width: 10 height: 10} - draw_icon +: { - svg: crate_resource("self://resources/icons/close.svg") - color: mod.mpf.fg_dim - } - } - } - } - props_scroll := ScrollYView{ - width: Fill - height: Fill - flow: Down - prop_name := PropRow{prop_key +: {text: "NAME"}} - prop_kind := PropRow{prop_key +: {text: "KIND"}} - prop_size := PropRow{ - prop_key +: {text: "SIZE"} - prop_spinner := LoadingSpinner{ - visible: false - width: 18 - height: 18 - } - } - prop_modified := PropRow{prop_key +: {text: "MODIFIED"}} - prop_created := PropRow{prop_key +: {text: "CREATED"}} - prop_permissions := PropRow{prop_key +: {text: "PERMISSIONS"}} - prop_path := PropRow{prop_key +: {text: "WHERE"}} - prop_opens := PropRow{prop_key +: {text: "OPEN WITH"}} - } - } - - chat_panel := mod.widgets.MpfChatPanel{} - } - } - - column_menu := View{ - visible: false - width: Fill - height: Fill - align: Align{x: 1.0 y: 0.0} - padding: Inset{top: 50 right: 10} - menu_panel := RectView{ - width: 210 - height: Fit - flow: Down - padding: Inset{top: 6 bottom: 6} - draw_bg +: { - color: mod.mpf.bg_dark - border_color: mod.mpf.muted - border_size: 1.0 - } - menu_title := Label{ - height: 26 - padding: Inset{left: 14} - text: "COLUMNS" - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_bold{font_size: 8.0} - } - } - menu_size := MenuRow{} - menu_kind := MenuRow{} - menu_modified := MenuRow{} - menu_created := MenuRow{} - menu_permissions := MenuRow{} - } - } - - context_menu := View{ - visible: false - width: Fill - height: Fill - align: Align{x: 0.0 y: 0.0} - padding: Inset{left: 0 top: 0} - ctx_panel := CtxPanel{ - ctx0 := CtxRow{} - ctx1 := CtxRow{} - ctx2 := CtxRow{} - ctx3 := CtxRow{} - ctx4 := CtxRow{} - ctx5 := CtxRow{} - ctx6 := CtxRow{} - ctx7 := CtxRow{} - ctx8 := CtxRow{} - ctx9 := CtxRow{} - ctx10 := CtxRow{} - ctx11 := CtxRow{} - ctx12 := CtxRow{} - ctx13 := CtxRow{} - } - } - - context_submenu := View{ - visible: false - width: Fill - height: Fill - align: Align{x: 0.0 y: 0.0} - padding: Inset{left: 0 top: 0} - ctx_sub_panel := CtxPanel{ - sub0 := CtxRow{} - sub1 := CtxRow{} - sub2 := CtxRow{} - sub3 := CtxRow{} - } - } - - batch_dialog := SolidView{ - visible: false - width: Fill - height: Fill - align: Align{x: 0.5 y: 0.5} - draw_bg +: {color: #x0b0b10cc} - batch_panel := RectView{ - width: 540 - height: Fit - flow: Down - padding: Inset{left: 20 right: 20 top: 16 bottom: 16} - draw_bg +: { - color: mod.mpf.bg_dark - border_color: mod.mpf.muted - border_size: 1.0 - } - batch_title := Label{ - text: "Rename 0 files" - draw_text +: { - color: mod.mpf.fg_bright - text_style: theme.font_bold{font_size: 11.0} - } - } - batch_find := DialogField{ - field_key +: {text: "FIND"} - field_box +: {field_input +: {empty_text: "text in the current names"}} - } - batch_replace := DialogField{ - field_key +: {text: "REPLACE WITH"} - field_box +: {field_input +: {empty_text: "what to put there instead"}} - } - batch_pattern := DialogField{ - field_key +: {text: "OR A PATTERN — {name} IS THE OLD NAME, ### THE NUMBER"} - field_box +: {field_input +: {empty_text: "shot-###"}} - } - batch_preview := Label{ - width: Fill - height: Fit - margin: Inset{top: 10 bottom: 10} - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_code{font_size: 8.5} - } - } - batch_buttons := View{ - width: Fill - height: Fit - flow: Right - spacing: 10 - align: Align{x: 1.0} - batch_cancel := DialogButton{dlg_label +: {text: "Cancel"}} - batch_apply := DialogButton{ - dlg_label +: {text: "Rename"} - draw_bg +: { - color: mod.mpf.sel - border_color: mod.mpf.accent - } - } - } - } - } - - quick_look := SolidView{ - visible: false - width: Fill - height: Fill - align: Align{x: 0.5 y: 0.5} - draw_bg +: {color: #x0b0b10cc} - ql_panel := RectView{ - width: 780 - height: 560 - flow: Down - draw_bg +: { - color: mod.mpf.bg_dark - border_color: mod.mpf.muted - border_size: 1.0 - } - ql_header := SolidView{ - width: Fill - height: 36 - flow: Right - padding: Inset{left: 14 right: 14} - align: Align{y: 0.5} - draw_bg +: {color: mod.mpf.bg_light} - ql_title := Label{ - width: Fill - max_lines: 1 - text_overflow: TextOverflow.Ellipsis - text: "Preview" - draw_text +: { - color: mod.mpf.fg_bright - text_style: theme.font_bold{font_size: 10.0} - } - } - ql_hint := Label{ - text: "Space or Esc to close" - draw_text +: { - color: mod.mpf.fg_dim - text_style: theme.font_regular{font_size: 8.5} - } - } - } - ql_scroll := ScrollYView{ - width: Fill - height: Fill - padding: Inset{left: 14 right: 14 top: 10 bottom: 10} - ql_text := Label{ - width: Fill - draw_text +: { - color: mod.mpf.fg - text_style: theme.font_code{font_size: 8.5} - } - } - } - } - } - } - } - } - } -} - -/// The 6 crumb slots of the path bar; deeper paths show their tail. -const CRUMB_IDS: [&[LiveId]; 6] = [ - ids!(c0), - ids!(c1), - ids!(c2), - ids!(c3), - ids!(c4), - ids!(c5), -]; -const CRUMB_TITLE_IDS: [&[LiveId]; 6] = [ - ids!(c0.crumb_title), - ids!(c1.crumb_title), - ids!(c2.crumb_title), - ids!(c3.crumb_title), - ids!(c4.crumb_title), - ids!(c5.crumb_title), -]; -const CRUMB_SEP_IDS: [&[LiveId]; 5] = - [ids!(s0), ids!(s1), ids!(s2), ids!(s3), ids!(s4)]; - -/// The path bar: the tail of the current path, each part clickable. -#[derive(Script, ScriptHook, Widget)] -pub struct Breadcrumbs { - #[deref] - view: View, - #[rust] - paths: Vec, -} - -impl Breadcrumbs { - fn set_path(&mut self, cx: &mut Cx, path: &Path) { - let mut paths: Vec = path.ancestors().map(Path::to_path_buf).collect(); - paths.reverse(); - if paths.len() > CRUMB_IDS.len() { - paths = paths.split_off(paths.len() - CRUMB_IDS.len()); - } - self.paths = paths; - for (index, crumb) in CRUMB_IDS.iter().enumerate() { - let visible = index < self.paths.len(); - self.view.view(cx, *crumb).set_visible(cx, visible); - if visible { - let title = display_name(&self.paths[index]); - self.view - .label(cx, CRUMB_TITLE_IDS[index]) - .set_text(cx, &title); - } - if let Some(sep) = CRUMB_SEP_IDS.get(index) { - self.view - .widget(cx, *sep) - .set_visible(cx, index + 1 < self.paths.len()); - } - } - self.view.redraw(cx); - } - - fn clicked_path(&self, cx: &mut Cx, actions: &Actions) -> Option { - for (index, crumb) in CRUMB_IDS.iter().enumerate() { - if self.view.view(cx, *crumb).finger_down(actions).is_some() { - return self.paths.get(index).cloned(); - } - } - None - } -} - -impl Widget for Breadcrumbs { - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - self.view.draw_walk(cx, scope, walk) - } - - fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - self.view.handle_event(cx, event, scope); - } -} - -/// One browser tab: its folder, its own history, and its own view mode. GNOME -/// Files keeps all three per tab, and anything less makes tabs a lie — -/// switching back would land you somewhere you never were. -#[derive(Clone, Debug)] -pub struct Tab { - dir: PathBuf, - back: Vec, - forward: Vec, - mode: ViewMode, -} - -impl Tab { - fn new(dir: PathBuf, mode: ViewMode) -> Self { - Self { - dir, - back: Vec::new(), - forward: Vec::new(), - mode, - } - } -} - -/// A finished directory read, matched back to the request that asked for it. -struct DirectoryResult { - /// The tab folder the read belongs to; a listing for a folder we already - /// left answers no question anybody is still asking. - dir: PathBuf, - request_id: u64, - /// `None` for the folder itself, `Some` for the children of a folder the - /// List tree expanded. - parent: Option, - result: Result, String>, -} - -/// Which field should take the keyboard once it has been drawn. A widget that -/// was hidden a moment ago has no area yet, and `take_key_focus` on an empty -/// area focuses nothing — so every reveal-then-focus goes through here and -/// lands one frame later. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum FocusTarget { - #[default] - None, - Path, - Search, - Batch, - Chat, - Filter, -} - -/// A finished recursive size measurement for the properties panel. -struct SizeResult { - path: PathBuf, - bytes: u64, -} - -/// The sidebar places, in order: (widget id, what it navigates to). -const PLACES: [(&[LiveId], &str); 11] = [ - (ids!(home_item), "home"), - (ids!(recent_item), "recent"), - (ids!(starred_item), "starred"), - (ids!(network_item), "network"), - (ids!(trash_item), "trash"), - (ids!(desktop_item), "Desktop"), - (ids!(documents_item), "Documents"), - (ids!(downloads_item), "Downloads"), - (ids!(music_item), "Music"), - (ids!(pictures_item), "Pictures"), - (ids!(videos_item), "Videos"), -]; - -/// The column picker's rows: (row id, the column it toggles). -/// Name is the row's identity and is not offered. -const COLUMN_ROWS: [(&[LiveId], model::SortKey); 5] = [ - (ids!(menu_size), model::SortKey::Size), - (ids!(menu_kind), model::SortKey::Kind), - (ids!(menu_modified), model::SortKey::Modified), - (ids!(menu_created), model::SortKey::Created), - (ids!(menu_permissions), model::SortKey::Permissions), -]; - -const MODE_BUTTONS: [(&[LiveId], ViewMode); 4] = [ - (ids!(icons_button), ViewMode::Icons), - (ids!(list_button), ViewMode::List), - (ids!(compact_button), ViewMode::Compact), - (ids!(treemap_button), ViewMode::Treemap), -]; - -/// The projection switch on the map's own strip: how the block view renders, -/// not which view is open. -const PROJ_BUTTONS: [(&[LiveId], MapProjection); 3] = [ - (ids!(proj_flat), MapProjection::Flat), - (ids!(proj_ortho), MapProjection::Ortho), - (ids!(proj_persp), MapProjection::Persp), -]; - -/// The tab strip's slots. More tabs than this and the strip would be a -/// horizontal scroll problem instead of a tab strip. -const TAB_IDS: [&[LiveId]; 8] = [ - ids!(tab0), - ids!(tab1), - ids!(tab2), - ids!(tab3), - ids!(tab4), - ids!(tab5), - ids!(tab6), - ids!(tab7), -]; - -/// The sidebar's bookmark slots — as many as [`bookmarks::MAX_BOOKMARKS`]. -const BOOKMARK_IDS: [&[LiveId]; bookmarks::MAX_BOOKMARKS] = [ - ids!(bm0), - ids!(bm1), - ids!(bm2), - ids!(bm3), - ids!(bm4), - ids!(bm5), - ids!(bm6), - ids!(bm7), - ids!(bm8), - ids!(bm9), - ids!(bm10), - ids!(bm11), -]; - -/// The context menu's row slots — as many as [`menu::MAX_ROWS`]. -const CTX_IDS: [&[LiveId]; menu::MAX_ROWS] = [ - ids!(ctx0), - ids!(ctx1), - ids!(ctx2), - ids!(ctx3), - ids!(ctx4), - ids!(ctx5), - ids!(ctx6), - ids!(ctx7), - ids!(ctx8), - ids!(ctx9), - ids!(ctx10), - ids!(ctx11), - ids!(ctx12), - ids!(ctx13), -]; - -/// The Open With submenu's slots. -const CTX_SUB_IDS: [&[LiveId]; menu::MAX_APPS] = - [ids!(sub0), ids!(sub1), ids!(sub2), ids!(sub3)]; - -/// One menu row's height, and the space a separator adds above it — the two -/// numbers the panel's own height is made of, so it can be kept on screen. -const CTX_ROW_H: f64 = 28.0; -const CTX_SEP_H: f64 = 9.0; -const CTX_PANEL_W: f64 = 268.0; - -/// The warm-pool dormancy state machine (see `mp_wm_api::warm_start` / -/// `WmEvent::Adopted`). mpwm pre-spawns hidden warm instances of this app -/// (`MPWM_WARM_START=1`); a cached file browser must not scan a directory or -/// decode thumbnails for a window nobody is looking at. A warm instance -/// starts `Dormant` — no initial directory scan — and wakes exactly once: -/// either mpwm adopts it into a real tile (`WmEvent::Adopted` on the studio -/// `Custom` channel), or, defensively, a human touches the window directly -/// (a key or pointer/touch event, in case an `Adopted` message is ever -/// lost). A non-warm instance is never dormant. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum Dormancy { - /// Not a warm-pool instance: the initial scan happens immediately. - #[default] - Active, - /// A warm-pool instance, still idling. - Dormant, - /// A warm-pool instance that has woken up. - Woken, -} - -impl Dormancy { - /// `warm` is `mp_wm_api::warm_start()`, read once at startup. - pub fn start(warm: bool) -> Self { - if warm { Dormancy::Dormant } else { Dormancy::Active } - } - - pub fn is_dormant(&self) -> bool { - *self == Dormancy::Dormant - } - - /// Transition `Dormant` -> `Woken`. Returns `true` the one time this - /// actually wakes it (the caller should run the deferred scan then); - /// `false` when it was already active or already woken, so `Adopted` - /// arriving after an input wake (or twice) never rescans. - pub fn wake(&mut self) -> bool { - if *self == Dormancy::Dormant { - *self = Dormancy::Woken; - true - } else { - false - } - } -} - -/// A raw input event a human — not the WM protocol — could only have sent: -/// the defensive wake path for a lost `WmEvent::Adopted`. -fn is_wake_input(event: &Event) -> bool { - matches!( - event, - Event::KeyDown(_) | Event::MouseDown(_) | Event::TouchUpdate(_) - ) -} - -#[derive(Script, ScriptHook)] -pub struct App { - #[live] - ui: WidgetRef, - #[rust] - tabs: Vec, - #[rust] - tab: usize, - #[rust] - home: PathBuf, - #[rust] - sender: Option>, - #[rust] - receiver: Option>, - #[rust] - size_sender: Option>, - #[rust] - size_receiver: Option>, - #[rust] - size_cancel: Option>, - #[rust] - request_id: u64, - #[rust] - show_hidden: bool, - #[rust] - search_visible: bool, - #[rust] - path_edit_open: bool, - #[rust] - preview: PreviewHost, - #[rust] - quick_look_open: bool, - #[rust] - column_menu_open: bool, - /// The filter sidebar, docked to the right of the map. The name kept its - /// popup days; the state is the same choice. - #[rust] - filter_popup_open: bool, - /// How the block view renders — flat, extruded, perspective. A property - /// of the view, not a view of its own; saved across launches. - #[rust] - projection: MapProjection, - /// The "modified within" choice: an index into [`AGE_MINUTES`]. - #[rust] - filter_age: usize, - /// Which legend kinds are toggled into the filter. - #[rust] - filter_kinds: [bool; 7], - /// Which kind class each legend row currently shows (rows are sorted by - /// bytes, so the mapping moves). - #[rust] - legend_rows: [usize; 7], - #[rust] - props_open: bool, - #[rust] - batch_open: bool, - #[rust] - bookmarks: Bookmarks, - #[rust] - hovered_bookmark: Option, - #[rust] - focus_next: NextFrame, - #[rust] - focus_target: FocusTarget, - #[rust] - focus_tries: usize, - /// Copy and cut share one clipboard; `clipboard_cut` says which it was. - #[rust] - clipboard: Vec, - #[rust] - clipboard_cut: bool, - #[rust] - ops: Option, - #[rust] - journal: Journal, - #[rust] - op_id: u64, - /// The job the progress row is showing, so Cancel knows what to stop. - #[rust] - active_op: Option, - /// What each running job will mean for the size map once it lands. The - /// map is expensive to build and cheap to correct, so every operation the - /// app performs itself is folded straight into it — a scan of a full home - /// directory is minutes, and moving one file to the Trash should not cost - /// them. - #[rust] - map_jobs: Vec, - /// A file to select, and maybe rename, once the folder is re-listed. - #[rust] - pending_select: Vec, - #[rust] - pending_rename: Option, - /// What an operation said it did. The re-listing that follows would - /// overwrite the status line with the folder's resting state, and a - /// cancelled copy or an undo the user never sees is one they cannot trust. - #[rust] - pending_status: Option, - /// The paths the batch dialog is about to rename. - #[rust] - batch_targets: Vec, - /// The context menu: what it offers, what it is about, and which row the - /// pointer is on. - #[rust] - menu_open: bool, - #[rust] - menu_rows: Vec, - #[rust] - menu_target: Option, - #[rust] - menu_hover: Option, - #[rust] - submenu_open: bool, - #[rust] - submenu_apps: Vec, - #[rust] - submenu_hover: Option, - /// A permanent delete that has been asked for once and is waiting for the - /// second press that means it. There is no undo behind this one, so it is - /// the only thing in the app that asks twice. - #[rust] - pending_delete: Vec, - /// Warm-pool dormancy — see `Dormancy`. - #[rust] - dormancy: Dormancy, - - // ---------------------------------------------------------------- chat - /// The ask-about-these-files panel. Everything below it stays `None` until - /// the panel is opened for the first time: a file browser must not load - /// nine billion parameters for a panel nobody asked for. - #[rust] - chat_open: bool, - #[rust] - chat: ChatState, - #[rust] - agent: Option, - #[rust] - tool_runner: Option, - /// True between sending a question and the answer being finished. - #[rust] - chat_busy: bool, - #[rust] - chat_ready: bool, - /// How many tool results the model is still owed for this turn, and the - /// ones that have come back so far — they go over in call order, together. - #[rust] - chat_awaiting_tools: usize, - #[rust] - chat_tool_replies: Vec, - /// Tool rounds spent on the current question, so a model that decides to - /// keep looking forever is stopped rather than left running. - #[rust] - chat_tool_rounds: usize, - /// The status line under the panel header. - #[rust] - chat_status: String, - /// The last "about:" chip and map-strip hint that were pushed into the UI, - /// so the per-signal refresh only touches a widget when something changed. - #[rust] - chat_about: String, - #[rust] - map_tools_note: String, -} - -/// One finished tool call, waiting for its turn-mates. -pub struct ToolReply { - text: String, - is_error: bool, -} - -/// One row of the Open With submenu: the app's id (empty = the desktop's own -/// opener) and the sentence the row shows. -#[derive(Clone, Debug, Default)] -pub struct AppChoice { - id: String, - label: String, -} - -impl App { - // ----------------------------------------------------------------- tabs - - fn current_dir(&self) -> PathBuf { - self.tabs - .get(self.tab) - .map(|t| t.dir.clone()) - .unwrap_or_default() - } - - fn tab_mut(&mut self) -> &mut Tab { - let index = self.tab.min(self.tabs.len().saturating_sub(1)); - &mut self.tabs[index] - } - - fn new_tab(&mut self, cx: &mut Cx) { - if self.tabs.len() >= TAB_IDS.len() { - self.status(cx, "That is as many tabs as the strip holds"); - return; - } - let dir = self.current_dir(); - let mode = self.tabs[self.tab].mode; - self.tabs.insert(self.tab + 1, Tab::new(dir, mode)); - self.tab += 1; - self.enter_tab(cx); - } - - /// Close the active tab. The last tab has nothing behind it, so closing - /// it closes the window — which is what Cmd+W means everywhere else. - fn close_tab(&mut self, cx: &mut Cx) { - if self.tabs.len() <= 1 { - cx.quit(); - return; - } - self.tabs.remove(self.tab); - self.tab = self.tab.min(self.tabs.len() - 1); - self.enter_tab(cx); - } - - fn switch_tab(&mut self, cx: &mut Cx, delta: isize) { - if self.tabs.len() < 2 { - return; - } - let count = self.tabs.len() as isize; - self.tab = ((self.tab as isize + delta).rem_euclid(count)) as usize; - self.enter_tab(cx); - } - - /// Make the active tab's state the window's state. - fn enter_tab(&mut self, cx: &mut Cx) { - let mode = self.tabs[self.tab].mode; - self.apply_mode(cx, mode); - self.refresh_tab_strip(cx); - self.request_directory(cx); - } - - fn refresh_tab_strip(&mut self, cx: &mut Cx) { - // One tab is not a tab strip: it is a window, and a strip over it is - // just a bar that says nothing. - let many = self.tabs.len() > 1; - self.ui.widget(cx, ids!(tab_strip)).set_visible(cx, many); - let palette = Palette::shared(); - let (on, off) = ( - Palette::vec4(&palette.bg_light), - Palette::vec4(&palette.bg_dark), - ); - for (index, id) in TAB_IDS.iter().enumerate() { - let shown = many && index < self.tabs.len(); - let mut item = self.ui.view(cx, *id); - item.set_visible(cx, shown); - if !shown { - continue; - } - let title = display_name(&self.tabs[index].dir); - item.label(cx, ids!(tab_title)).set_text(cx, &title); - let color = if index == self.tab { on } else { off }; - script_apply_eval!(cx, item, { - draw_bg +: {color: #(color)} - }); - } - } - - // --------------------------------------------------------------- status - - /// Give the keyboard to `target` on the next frame, once it has been drawn - /// and has an area to focus. - fn focus_soon(&mut self, cx: &mut Cx, target: FocusTarget) { - self.focus_target = target; - self.focus_tries = 0; - self.focus_next = cx.new_next_frame(); - } - - fn apply_focus(&mut self, cx: &mut Cx) { - let field = match self.focus_target { - FocusTarget::None => return, - FocusTarget::Path => self.ui.text_input(cx, ids!(path_edit)), - FocusTarget::Search => self.ui.text_input(cx, ids!(search_input)), - FocusTarget::Batch => self - .ui - .view(cx, ids!(batch_find)) - .text_input(cx, ids!(field_input)), - FocusTarget::Chat => self.ui.text_input(cx, ids!(chat_input)), - FocusTarget::Filter => self.ui.text_input(cx, ids!(filter_query)), - }; - // `take_key_focus` focuses the field's *area*, and a field that has - // not been drawn since it was revealed has none — focusing it would - // quietly focus nothing. Wait for the frame that gives it one. - let drawn = field.area().rect(cx).size.x >= 1.0; - if !drawn { - if self.focus_tries < 8 { - self.focus_tries += 1; - self.focus_next = cx.new_next_frame(); - } - drop(field); - return; - } - self.focus_target = FocusTarget::None; - self.focus_tries = 0; - field.take_key_focus(cx); - { - // The borrow has to end before `field` does. - if let Some(mut inner) = field.borrow_mut() { - inner.select_all(cx); - } - } - drop(field); - } - - fn status(&mut self, cx: &mut Cx, text: &str) { - self.ui.label(cx, ids!(status_label)).set_text(cx, text); - } - - /// True when a press at `at` landed on `child` *inside this row*. - /// - /// A clickable row that contains a clickable button is one press, not two: - /// the row is what reports it, and the button inside never sees a - /// `FingerDown` of its own. So which of the two was meant is decided by - /// geometry — where the finger actually went down. The lookup has to start - /// at the row, because every row in a list carries the same child id and a - /// search from the root would always answer with the first one. - fn pressed_on(&mut self, cx: &mut Cx, row: &[LiveId], child: &[LiveId], at: DVec2) -> bool { - let rect = self - .ui - .view(cx, row) - .widget(cx, child) - .area() - .rect(cx); - rect.size.x > 0.0 && rect.contains(at) - } - - fn with_contents( - &mut self, - cx: &mut Cx, - f: impl FnOnce(&mut FileContents, &mut Cx) -> R, - ) -> Option { - let widget = self.ui.widget(cx, ids!(contents)); - let mut contents = widget.borrow_mut::()?; - Some(f(&mut contents, cx)) - } - - // ---------------------------------------------------------- navigation - - fn navigate(&mut self, cx: &mut Cx, path: PathBuf, add_history: bool) { - let current = self.current_dir(); - if add_history && !current.as_os_str().is_empty() && current != path { - let tab = self.tab_mut(); - tab.back.push(current); - tab.forward.clear(); - } - self.tab_mut().dir = path; - self.request_directory(cx); - } - - fn request_directory(&mut self, cx: &mut Cx) { - let Some(sender) = self.sender.clone() else { - return; - }; - self.request_id = self.request_id.wrapping_add(1); - let request_id = self.request_id; - let path = self.current_dir(); - let show_hidden = self.show_hidden; - self.update_path_ui(cx); - self.refresh_tab_strip(cx); - self.ui.label(cx, ids!(item_count)).set_text(cx, "Loading…"); - let display = path.display().to_string(); - self.status(cx, &format!("Loading {}…", display)); - // The treemap is of a folder, so a new folder means a new map. - if self.tabs[self.tab].mode.is_treemap() { - let map = self.with_contents(cx, |contents, cx| contents.treemap(cx)); - if let Some(map) = map { - map.set_root(cx, &path); - } - } - let dir = path.clone(); - thread::spawn(move || { - let result = vfs().read_dir(&path, show_hidden); - let sent = sender.send(DirectoryResult { - dir, - request_id, - parent: None, - result, - }); - if sent.is_ok() { - SignalToUI::set_ui_signal(); - } - }); - } - - /// Read the children of a folder the List tree just expanded. - fn request_children(&mut self, cx: &mut Cx, folder: PathBuf) { - let Some(sender) = self.sender.clone() else { - return; - }; - let show_hidden = self.show_hidden; - let dir = self.current_dir(); - let request_id = self.request_id; - let _ = cx; - thread::spawn(move || { - let result = vfs().read_dir(&folder, show_hidden); - let sent = sender.send(DirectoryResult { - dir, - request_id, - parent: Some(folder), - result, - }); - if sent.is_ok() { - SignalToUI::set_ui_signal(); - } - }); - } - - fn drain_directory_results(&mut self, cx: &mut Cx) { - let results: Vec = self - .receiver - .as_ref() - .map(|receiver| receiver.try_iter().collect()) - .unwrap_or_default(); - let current = self.current_dir(); - for result in results { - if result.dir != current { - continue; - } - if let Some(parent) = result.parent { - // Children of an expanded folder: the tree keeps its shape - // even when the read failed, it just opens onto nothing. - let entries = result.result.unwrap_or_default(); - self.with_contents(cx, |contents, cx| { - contents.set_children(cx, &parent, entries) - }); - continue; - } - // A folder the user already left is not the answer to any question. - if result.request_id != self.request_id { - continue; - } - match result.result { - Ok(entries) => { - let count = entries.len(); - self.with_contents(cx, |contents, cx| contents.set_entries(cx, entries)); - self.ui - .widget(cx, ids!(empty_label)) - .set_visible(cx, count == 0); - self.ui - .label(cx, ids!(empty_label)) - .set_text(cx, "This folder is empty"); - self.ui.label(cx, ids!(item_count)).set_text( - cx, - &format!("{} item{}", count, if count == 1 { "" } else { "s" }), - ); - self.report(cx); - self.apply_pending(cx); - } - Err(error) => { - self.with_contents(cx, |contents, cx| contents.set_entries(cx, Vec::new())); - self.ui.widget(cx, ids!(empty_label)).set_visible(cx, true); - self.ui - .label(cx, ids!(empty_label)) - .set_text(cx, "Folder unavailable"); - self.ui.label(cx, ids!(item_count)).set_text(cx, "0 items"); - self.status(cx, &error); - } - } - } - } - - /// Select (and maybe start renaming) what an operation just created, now - /// that the folder has been re-listed and the file is on screen. - fn apply_pending(&mut self, cx: &mut Cx) { - let select = std::mem::take(&mut self.pending_select); - if !select.is_empty() { - self.with_contents(cx, |contents, cx| contents.select_paths(cx, &select)); - } - if let Some(path) = self.pending_rename.take() { - self.with_contents(cx, |contents, cx| contents.begin_rename(cx, &path)); - } - if let Some(message) = self.pending_status.take() { - self.status(cx, &message); - } - } - - /// The status line's resting state: where we are and how it is sorted. - fn report(&mut self, cx: &mut Cx) { - // Whatever changed, the chat's "about:" chip and the map strip are - // about the same selection this line is — so they follow it here. - self.refresh_chat(cx); - let mode = self.tabs[self.tab].mode; - if mode.is_treemap() { - let text = self - .with_contents(cx, |contents, cx| contents.treemap(cx).status()) - .unwrap_or_default(); - self.status(cx, &text); - return; - } - let dir = self.current_dir().display().to_string(); - let (sort, picked) = self - .with_contents(cx, |contents, _| (contents.sort(), contents.selection_count())) - .unwrap_or_default(); - let key = sort.key.label().to_lowercase(); - let selection = match picked { - 0 => String::new(), - 1 => " · 1 selected".to_string(), - n => format!(" · {n} selected"), - }; - let text = format!( - "{dir} — {} · sorted by {key} {}, folders first{selection}", - mode.label(), - if sort.ascending { "↑" } else { "↓" } - ); - self.status(cx, &text); - } - - fn update_path_ui(&mut self, cx: &mut Cx) { - let current = self.current_dir(); - let title = display_name(¤t); - self.ui.label(cx, ids!(folder_title)).set_text(cx, &title); - let widget = self.ui.widget(cx, ids!(breadcrumbs)); - if let Some(mut breadcrumbs) = widget.borrow_mut::() { - breadcrumbs.set_path(cx, ¤t); - } - // Light up the place the current folder belongs to. Recent and - // Starred are Home shortcuts, not places of their own, so they never - // claim the highlight. - let palette = Palette::shared(); - let (on, off) = (Palette::vec4(&palette.sel), Palette::vec4(&palette.bg_dark)); - for (id, name) in PLACES { - let lit = !matches!(name, "recent" | "starred") && self.place_path(name) == current; - let color = if lit { on } else { off }; - let mut item = self.ui.view(cx, id); - script_apply_eval!(cx, item, { - draw_bg +: {color: #(color)} - }); - } - self.refresh_bookmarks(cx); - } - - fn place_path(&self, name: &str) -> PathBuf { - match name { - "home" | "recent" | "starred" => self.home.clone(), - "network" => PathBuf::from("/"), - "trash" => trash_dir(&self.home), - folder => self.home.join(folder), - } - } - - fn go_back(&mut self, cx: &mut Cx) { - let current = self.current_dir(); - let tab = self.tab_mut(); - if let Some(path) = tab.back.pop() { - tab.forward.push(current); - tab.dir = path; - self.request_directory(cx); - } - } - - fn go_forward(&mut self, cx: &mut Cx) { - let current = self.current_dir(); - let tab = self.tab_mut(); - if let Some(path) = tab.forward.pop() { - tab.back.push(current); - tab.dir = path; - self.request_directory(cx); - } - } - - fn go_up(&mut self, cx: &mut Cx) { - if let Some(parent) = self.current_dir().parent() { - self.navigate(cx, parent.to_path_buf(), true); - } - } - - // ---------------------------------------------------------- view modes - - fn set_mode(&mut self, cx: &mut Cx, mode: ViewMode) { - self.tab_mut().mode = mode; - self.apply_mode(cx, mode); - self.report(cx); - self.ui.redraw(cx); - } - - /// Push a mode into the body and the toolbar without touching history. - fn apply_mode(&mut self, cx: &mut Cx, mode: ViewMode) { - let dir = self.current_dir(); - let projection = self.projection; - self.with_contents(cx, |contents, cx| { - contents.set_mode(cx, mode); - let map = contents.treemap(cx); - // Scanning a tree is expensive: it only runs while the map is the - // thing on screen. - if mode.is_treemap() { - if map.root() != dir { - map.set_root(cx, &dir); - } - map.set_projection(cx, projection); - } else { - map.stop(cx); - } - }); - for (id, button_mode) in MODE_BUTTONS { - self.ui - .widget(cx, id) - .widget(cx, ids!(btn_sel)) - .set_visible(cx, button_mode == mode); - } - self.style_projection_buttons(cx); - // The map's tool strip and the filter sidebar belong to the map. The - // pick it acts on lives in the treemap widget and survives this, so - // coming back to the map finds the same rectangle still ringed. - self.ui - .widget(cx, ids!(map_tools)) - .set_visible(cx, mode.is_treemap()); - self.ui - .widget(cx, ids!(map_side)) - .set_visible(cx, mode.is_treemap() && self.filter_popup_open); - if mode.is_treemap() && self.filter_popup_open { - // Entering the map with the sidebar already open (a pref, or a - // mode round-trip): the legend fills now, not on the next toggle. - self.refresh_filter_popup(cx); - } - self.map_tools_note.clear(); - self.refresh_chat(cx); - } - - /// Choose how the block view renders, remember it, and light the right - /// button. Never changes which view is open. - fn set_projection_choice(&mut self, cx: &mut Cx, projection: MapProjection) { - self.projection = projection; - model::pref_set( - "projection", - match projection { - MapProjection::Flat => "flat", - MapProjection::Ortho => "ortho", - MapProjection::Persp => "persp", - }, - ); - self.with_contents(cx, |contents, cx| { - contents.treemap(cx).set_projection(cx, projection); - }); - self.style_projection_buttons(cx); - self.report(cx); - self.ui.redraw(cx); - } - - fn style_projection_buttons(&mut self, cx: &mut Cx) { - for (id, projection) in PROJ_BUTTONS { - self.ui - .widget(cx, id) - .widget(cx, ids!(btn_sel)) - .set_visible(cx, projection == self.projection); - } - } - - fn zoom(&mut self, cx: &mut Cx, delta: isize) { - if self.tabs[self.tab].mode != ViewMode::Icons { - self.status(cx, "Icon sizes are for the Icons view — Cmd+1 switches to it"); - return; - } - let level = self - .with_contents(cx, |contents, _| contents.zoom()) - .unwrap_or(DEFAULT_ZOOM) as isize; - let next = (level + delta).clamp(0, ZOOM_LEVELS.len() as isize - 1) as usize; - let width = self - .with_contents(cx, |contents, cx| contents.set_zoom(cx, next)) - .unwrap_or_default(); - self.status( - cx, - &format!( - "Icon size {} of {} — {:.0}pt tiles", - next + 1, - ZOOM_LEVELS.len(), - width - ), - ); - } - - // -------------------------------------------------------------- search - - fn set_search(&mut self, cx: &mut Cx, visible: bool) { - if visible { - self.set_path_edit(cx, false); - } - self.search_visible = visible; - self.ui - .widget(cx, ids!(crumb_box)) - .set_visible(cx, !visible && !self.path_edit_open); - self.ui.widget(cx, ids!(search_box)).set_visible(cx, visible); - self.ui - .widget(cx, ids!(search_button)) - .widget(cx, ids!(btn_sel)) - .set_visible(cx, visible); - if visible { - self.focus_soon(cx, FocusTarget::Search); - } else { - self.ui.text_input(cx, ids!(search_input)).set_text(cx, ""); - self.with_contents(cx, |contents, cx| contents.set_filter(cx, String::new())); - cx.set_key_focus(Area::Empty); - } - self.ui.redraw(cx); - } - - // ----------------------------------------------------------- path bar - - /// Ctrl+L, or a click in the empty part of the path plate: the crumbs - /// become the path, editable. - fn set_path_edit(&mut self, cx: &mut Cx, open: bool) { - if open && self.search_visible { - self.set_search(cx, false); - } - self.path_edit_open = open; - self.ui - .widget(cx, ids!(crumb_box)) - .set_visible(cx, !open && !self.search_visible); - self.ui.widget(cx, ids!(path_edit_box)).set_visible(cx, open); - let field = self.ui.text_input(cx, ids!(path_edit)); - if open { - let text = self.current_dir().display().to_string(); - field.set_text(cx, &text); - self.focus_soon(cx, FocusTarget::Path); - self.status(cx, "Type a path and press Enter — Esc puts the crumbs back"); - } else { - cx.set_key_focus(Area::Empty); - self.report(cx); - } - self.ui.redraw(cx); - } - - /// `~` and `~/x` mean the home directory, the way every shell and every - /// file manager's path box does. - fn expand_path(&self, text: &str) -> PathBuf { - let text = text.trim(); - if text == "~" { - return self.home.clone(); - } - if let Some(tail) = text.strip_prefix("~/") { - return self.home.join(tail); - } - PathBuf::from(text) - } - - fn commit_path_edit(&mut self, cx: &mut Cx, text: &str) { - let path = self.expand_path(text); - if vfs().is_dir(&path) { - self.set_path_edit(cx, false); - self.navigate(cx, path, true); - return; - } - // A file in the box is a reasonable thing to type: open it, and stay - // where we are. - if vfs().exists(&path) { - self.set_path_edit(cx, false); - let message = preview::open_file(cx, &path); - self.status(cx, &message); - return; - } - self.status(cx, &format!("{} does not exist", path.display())); - } - - // ------------------------------------------------------------ bookmarks - - fn refresh_bookmarks(&mut self, cx: &mut Cx) { - let list: Vec = self.bookmarks.list().to_vec(); - let current = self.current_dir(); - self.ui - .widget(cx, ids!(bookmark_hint)) - .set_visible(cx, list.is_empty()); - let palette = Palette::shared(); - let (on, off) = (Palette::vec4(&palette.sel), Palette::vec4(&palette.bg_dark)); - for (index, id) in BOOKMARK_IDS.iter().enumerate() { - let mut item = self.ui.view(cx, *id); - let Some(path) = list.get(index) else { - item.set_visible(cx, false); - continue; - }; - item.set_visible(cx, true); - let title = display_name(path); - item.label(cx, ids!(bm_title)).set_text(cx, &title); - item.widget(cx, ids!(bm_remove)) - .set_visible(cx, self.hovered_bookmark == Some(index)); - let color = if *path == current { on } else { off }; - script_apply_eval!(cx, item, { - draw_bg +: {color: #(color)} - }); - } - } - - fn bookmark_current(&mut self, cx: &mut Cx) { - let path = self.current_dir(); - self.bookmark(cx, path); - } - - fn bookmark(&mut self, cx: &mut Cx, path: PathBuf) { - if !vfs().is_dir(&path) { - self.status(cx, "Only folders can be bookmarked"); - return; - } - let name = display_name(&path); - let message = if self.bookmarks.add(&path) { - format!("Bookmarked {name}") - } else if self.bookmarks.contains(&path) { - format!("{name} is already bookmarked") - } else { - "The sidebar has no room for another bookmark".to_string() - }; - self.refresh_bookmarks(cx); - self.status(cx, &message); - } - - // ------------------------------------------------------------- preview - - /// Space: show the selection, or put away what Space last showed. - fn toggle_preview(&mut self, cx: &mut Cx) { - if self.quick_look_open { - self.close_preview(cx); - return; - } - // Whether a panel is open is never this app's own belief. Hosted, the - // WM's last PreviewShown/PreviewHidden is the answer; standalone, the - // answer is whether the child we spawned is still alive — and nothing - // told us when it exited, so ask now rather than trust what was true. - if !mp_wm_api::hosted(cx) { - self.preview.poll(); - } - let open = self.preview.showing().is_some() || self.preview.hosted_showing().is_some(); - if open { - self.preview.close(cx); - self.set_preview_button(cx, false); - self.status(cx, "Preview closed"); - return; - } - let Some(entry) = self - .with_contents(cx, |contents, _| contents.selected_entry()) - .flatten() - else { - self.status(cx, "Select a file first — Space previews it"); - return; - }; - if entry.is_dir { - self.status(cx, &format!("{} is a folder — Enter opens it", entry.name)); - return; - } - match self.preview.open(cx, &entry.path) { - Preview::Shown(message) => { - // Hosted, the button lights when `PreviewShown` arrives, not - // because we asked — see `handle_wm_event`. - self.set_preview_button(cx, self.preview.showing().is_some()); - self.status(cx, &message); - } - // No viewer binary to show it: text and code still have a panel - // here, which is better than nothing happening on Space. - Preview::NoViewer(message) => { - if entry.kind.is_textual() { - self.open_quick_look(cx, &entry); - } else { - self.status(cx, &message); - } - } - } - } - - /// The in-app quick look: the head of a text file, monospaced. - fn open_quick_look(&mut self, cx: &mut Cx, entry: &FileEntry) { - let text = match model::read_head(&entry.path, 200, 512 * 1024) { - Ok(text) => text, - Err(error) => format!("Could not read {}:\n{}", entry.name, error), - }; - self.ui.label(cx, ids!(ql_title)).set_text(cx, &entry.name); - self.ui.label(cx, ids!(ql_text)).set_text(cx, &text); - self.ui.widget(cx, ids!(quick_look)).set_visible(cx, true); - self.quick_look_open = true; - self.set_preview_button(cx, true); - self.status( - cx, - &format!("Previewing {} — Space or Esc to close", entry.name), - ); - self.ui.redraw(cx); - } - - fn close_preview(&mut self, cx: &mut Cx) { - self.preview.close(cx); - if self.quick_look_open { - self.quick_look_open = false; - self.ui.widget(cx, ids!(quick_look)).set_visible(cx, false); - self.ui.redraw(cx); - } - self.set_preview_button(cx, false); - self.report(cx); - } - - fn set_preview_button(&mut self, cx: &mut Cx, on: bool) { - self.ui - .widget(cx, ids!(preview_button)) - .widget(cx, ids!(btn_sel)) - .set_visible(cx, on); - } - - /// The column picker: which columns the list view shows. The grid does - /// not deliver right-clicks, so the menu button carries it. - fn set_column_menu(&mut self, cx: &mut Cx, open: bool) { - self.column_menu_open = open; - self.ui.widget(cx, ids!(column_menu)).set_visible(cx, open); - if open { - self.refresh_column_menu(cx); - } - self.ui.redraw(cx); - } - - fn refresh_column_menu(&mut self, cx: &mut Cx) { - let shown = self - .with_contents(cx, |contents, _| contents.columns()) - .unwrap_or_default(); - for (id, column) in COLUMN_ROWS { - let on = shown.contains(&column); - let mut row = self.ui.view(cx, id); - row.widget(cx, ids!(menu_check)).set_visible(cx, on); - row.widget(cx, ids!(menu_gap)).set_visible(cx, !on); - let text = column.label().to_string(); - let color = if on { - Palette::vec4(&Palette::shared().sel) - } else { - Palette::vec4(&Palette::shared().bg_dark) - }; - script_apply_eval!(cx, row, { - draw_bg +: {color: #(color)} - }); - row.label(cx, ids!(menu_label)).set_text(cx, &text); - } - } - - /// The window manager's side of the conversation. - fn handle_wm_event(&mut self, cx: &mut Cx, event: &mp_wm_api::WmEvent) { - if matches!(event, mp_wm_api::WmEvent::Adopted) { - self.wake(cx); - } - if !self.preview.on_wm_event(event) { - return; - } - let showing = self.preview.hosted_showing().is_some(); - self.set_preview_button(cx, showing); - if !showing { - self.report(cx); - } - } - - - // ------------------------------------------------------- context menu - - /// Open the menu at `at`. `entry` is what the press landed on; `None` - /// means the empty space, which is a menu about the folder itself. - fn open_menu(&mut self, cx: &mut Cx, at: DVec2, entry: Option) { - let rows = match &entry { - Some(entry) => { - let count = self - .with_contents(cx, |contents, _| contents.selection_count()) - .unwrap_or(1) - .max(1); - menu::entry_menu(count, entry.is_dir) - } - None => { - let mode = self.tabs[self.tab].mode; - menu::empty_menu(mode, self.clipboard.len(), self.show_hidden) - } - }; - self.menu_target = entry; - self.menu_rows = rows; - self.menu_hover = None; - self.menu_open = true; - self.close_submenu(cx); - self.fill_menu(cx); - self.place_menu(cx, ids!(context_menu), at, self.menu_rows.len(), &self.menu_rows.clone()); - self.ui.widget(cx, ids!(context_menu)).set_visible(cx, true); - self.ui.redraw(cx); - } - - fn close_menu(&mut self, cx: &mut Cx) { - if !self.menu_open { - return; - } - self.menu_open = false; - self.menu_rows.clear(); - self.menu_target = None; - self.menu_hover = None; - self.ui.widget(cx, ids!(context_menu)).set_visible(cx, false); - self.close_submenu(cx); - self.ui.redraw(cx); - } - - fn close_submenu(&mut self, cx: &mut Cx) { - self.submenu_open = false; - self.submenu_hover = None; - self.ui - .widget(cx, ids!(context_submenu)) - .set_visible(cx, false); - } - - /// Push a card's top-left corner to `at`, kept inside the window. The - /// overlay fills the window and its padding is the position — which is - /// how a card gets placed without a custom layout pass. - fn place_menu(&mut self, cx: &mut Cx, overlay: &[LiveId], at: DVec2, count: usize, rows: &[MenuRow]) { - // The padding is measured from the overlay's own corner, and the - // overlay starts under the window's caption bar — a press is in window - // coordinates, so the difference has to come off. The measurement is - // taken from the app's background, which shares the overlay's origin - // and, unlike the overlay, has always been drawn. - let host = self.ui.view(cx, ids!(app_bg)).area().rect(cx); - let separators = rows.iter().take(count).filter(|r| r.separator).count() as f64; - let height = count as f64 * CTX_ROW_H + separators * CTX_SEP_H + 10.0; - let left = (at.x - host.pos.x) - .min(host.size.x - CTX_PANEL_W - 6.0) - .max(0.0); - let top = (at.y - host.pos.y).min(host.size.y - height - 6.0).max(0.0); - // The overlay fills the window and its padding is the card's corner. - // Set component by component: `Inset` is a name the widget prelude - // brings in, and a runtime `script_apply_eval!` has no prelude. - let mut panel = self.ui.view(cx, overlay); - script_apply_eval!(cx, panel, { - padding.left: #(left) - padding.top: #(top) - }); - } - - /// Paint the rows the menu is currently offering into the slots. - fn fill_menu(&mut self, cx: &mut Cx) { - let rows = self.menu_rows.clone(); - let palette = Palette::shared(); - for (index, id) in CTX_IDS.iter().enumerate() { - let slot = self.ui.view(cx, *id); - let Some(row) = rows.get(index) else { - slot.set_visible(cx, false); - continue; - }; - slot.set_visible(cx, true); - slot.widget(cx, ids!(ctx_line)) - .set_visible(cx, row.separator); - let label = if row.submenu { - format!("{} ›", row.label) - } else { - row.label.clone() - }; - slot.label(cx, ids!(ctx_label)).set_text(cx, &label); - slot.label(cx, ids!(ctx_hint)).set_text(cx, row.hint); - let hovered = self.menu_hover == Some(index); - let bg = if hovered { - Palette::vec4(&palette.hover_soft) - } else { - Palette::vec4(&palette.bg) - }; - let text = if row.danger { - Palette::vec4(&palette.danger) - } else if hovered { - Palette::vec4(&palette.accent) - } else { - Palette::vec4(&palette.fg) - }; - let mut body = slot.view(cx, ids!(ctx_body)); - script_apply_eval!(cx, body, { - draw_bg +: {color: #(bg)} - }); - let mut label_widget = slot.label(cx, ids!(ctx_label)); - script_apply_eval!(cx, label_widget, { - draw_text +: {color: #(text)} - }); - } - } - - fn fill_submenu(&mut self, cx: &mut Cx) { - let apps = self.submenu_apps.clone(); - let palette = Palette::shared(); - for (index, id) in CTX_SUB_IDS.iter().enumerate() { - let slot = self.ui.view(cx, *id); - let Some(app) = apps.get(index) else { - slot.set_visible(cx, false); - continue; - }; - slot.set_visible(cx, true); - slot.widget(cx, ids!(ctx_line)).set_visible(cx, false); - slot.label(cx, ids!(ctx_label)).set_text(cx, &app.label); - slot.label(cx, ids!(ctx_hint)).set_text(cx, ""); - let hovered = self.submenu_hover == Some(index); - let bg = if hovered { - Palette::vec4(&palette.hover_soft) - } else { - Palette::vec4(&palette.bg) - }; - let text = if hovered { - Palette::vec4(&palette.accent) - } else { - Palette::vec4(&palette.fg) - }; - let mut body = slot.view(cx, ids!(ctx_body)); - script_apply_eval!(cx, body, { - draw_bg +: {color: #(bg)} - }); - let mut label_widget = slot.label(cx, ids!(ctx_label)); - script_apply_eval!(cx, label_widget, { - draw_text +: {color: #(text)} - }); - } - } - - /// Open the Open With list beside its row. - fn open_submenu(&mut self, cx: &mut Cx, row: usize) { - let Some(entry) = self.menu_target.clone() else { - return; - }; - let available = |app: &str| preview::app_available(cx, app); - self.submenu_apps = menu::open_with_apps(&entry.path, &available) - .into_iter() - .map(|(id, label)| AppChoice { id, label }) - .collect(); - if self.submenu_apps.is_empty() { - return; - } - self.submenu_open = true; - self.submenu_hover = None; - self.fill_submenu(cx); - // Beside the row it belongs to, so the eye does not have to look for - // it: the card's own left edge plus its width, at the row's height. - let card = self.ui.view(cx, ids!(ctx_panel)).area().rect(cx); - let separators = self.menu_rows[..row].iter().filter(|r| r.separator).count() as f64; - let at = dvec2( - card.pos.x + CTX_PANEL_W - 8.0, - card.pos.y + 5.0 + row as f64 * CTX_ROW_H + separators * CTX_SEP_H, - ); - let rows: Vec = Vec::new(); - self.place_menu(cx, ids!(context_submenu), at, self.submenu_apps.len(), &rows); - self.ui - .widget(cx, ids!(context_submenu)) - .set_visible(cx, true); - self.ui.redraw(cx); - } - - /// Do what a row says. Every arm is a thing this app already does — a row - /// with nothing behind it would be a lie, so there are none. - fn fire_menu(&mut self, cx: &mut Cx, action: MenuAction) { - let target = self.menu_target.clone(); - self.close_menu(cx); - match action { - MenuAction::Open => { - if let Some(entry) = target { - self.open_entry(cx, entry); - } - } - MenuAction::OpenWith => {} - MenuAction::Preview => self.toggle_preview(cx), - MenuAction::NewFolder => self.new_folder(cx), - MenuAction::Rename => self.begin_rename(cx), - MenuAction::Duplicate => self.duplicate(cx), - MenuAction::Copy => self.copy_selection(cx, false), - MenuAction::Cut => self.copy_selection(cx, true), - MenuAction::Paste => self.paste(cx), - MenuAction::SelectAll => { - self.with_contents(cx, |contents, cx| contents.select_all(cx)); - self.report(cx); - } - MenuAction::Trash => self.trash_selection(cx), - MenuAction::DeleteForever => self.delete_forever(cx), - MenuAction::RevealInTreemap => self.reveal_in_treemap(cx, target), - MenuAction::Properties => self.set_props(cx, true), - MenuAction::OpenInTerminal => self.open_terminal(cx), - MenuAction::ShowHidden => self.toggle_hidden(cx), - MenuAction::SetMode(mode) => self.set_mode(cx, mode), - MenuAction::OpenWithApp(index) => { - let (Some(entry), Some(app)) = (target, self.submenu_apps.get(index).cloned()) - else { - return; - }; - let message = preview::open_file_with(cx, &entry.path, &app.id); - self.status(cx, &message); - } - } - } - - /// Copy the selection into the folder it is already in — which the - /// collision rule turns into "name (2)". - fn duplicate(&mut self, cx: &mut Cx) { - let paths = self.target_paths(cx); - if paths.is_empty() { - self.status(cx, "Nothing selected to duplicate"); - return; - } - self.submit(cx, OpKind::Copy, paths, None); - } - - /// Measure the folder again and replace the map that was read back from - /// the cache. The one thing that makes a remembered map safe: it is never - /// more than a keystroke from being made true. - fn rescan_map(&mut self, cx: &mut Cx) { - if !self.tabs[self.tab].mode.is_treemap() { - self.status(cx, "Rescanning is for the map — Cmd+4 shows it"); - return; - } - self.with_contents(cx, |contents, cx| contents.treemap(cx).rescan(cx)); - self.report(cx); - } - - /// Show the entry on the map. The map is of the folder we are in, so this - /// is a view change plus a highlight — not a search. - fn reveal_in_treemap(&mut self, cx: &mut Cx, entry: Option) { - if !self.tabs[self.tab].mode.is_treemap() { - self.set_mode(cx, ViewMode::Treemap); - } - let Some(entry) = entry else { - return; - }; - let name = entry.name.clone(); - let map = self.with_contents(cx, |contents, cx| contents.treemap(cx)); - if let Some(map) = map { - map.set_selected(cx, Some(entry.path)); - } - self.status(cx, &format!("{name} is highlighted on the map")); - } - - /// Erase, with nothing behind it. Asked once in the status bar and done on - /// the second press: there is no undo for this, so a single slip must not - /// be enough. - fn delete_forever(&mut self, cx: &mut Cx) { - let paths = self.target_paths(cx); - if paths.is_empty() { - self.status(cx, "Nothing selected to delete"); - return; - } - if self.pending_delete != paths { - let count = paths.len(); - self.pending_delete = paths; - self.status( - cx, - &format!( - "Delete {count} item{} permanently? This cannot be undone — press Shift+Delete again to confirm, Esc to cancel", - if count == 1 { "" } else { "s" } - ), - ); - return; - } - self.pending_delete.clear(); - self.submit(cx, OpKind::Delete, paths, None); - } - - fn toggle_hidden(&mut self, cx: &mut Cx) { - self.show_hidden = !self.show_hidden; - self.ui.label(cx, ids!(hidden_hint)).set_text( - cx, - if self.show_hidden { - "Ctrl+H Hide hidden files" - } else { - "Ctrl+H Show hidden files" - }, - ); - self.request_directory(cx); - } - - // ---------------------------------------------------------- properties - - fn set_props(&mut self, cx: &mut Cx, open: bool) { - self.props_open = open; - self.ui.widget(cx, ids!(props_panel)).set_visible(cx, open); - self.ui - .widget(cx, ids!(props_button)) - .widget(cx, ids!(btn_sel)) - .set_visible(cx, open); - if open { - self.refresh_props(cx); - } else if let Some(cancel) = self.size_cancel.take() { - cancel.store(true, Ordering::Relaxed); - } - self.ui.redraw(cx); - } - - fn set_prop(&mut self, cx: &mut Cx, id: &[LiveId], value: &str) { - let row = self.ui.view(cx, id); - row.label(cx, ids!(prop_value)).set_text(cx, value); - } - - fn refresh_props(&mut self, cx: &mut Cx) { - if !self.props_open { - return; - } - let picked = self - .with_contents(cx, |contents, _| contents.selected_entries()) - .unwrap_or_default(); - // More than one thing selected has no single name or kind, so the - // panel describes the set instead of pretending. - if picked.len() > 1 { - let bytes: u64 = picked.iter().map(|e| e.size).sum(); - self.set_prop(cx, ids!(prop_name), &format!("{} items", picked.len())); - self.set_prop(cx, ids!(prop_kind), "Multiple selection"); - self.set_prop( - cx, - ids!(prop_size), - &format!("{} of files (folders not counted)", model::format_size(bytes, false)), - ); - for id in [ - ids!(prop_modified), - ids!(prop_created), - ids!(prop_permissions), - ids!(prop_opens), - ] { - self.set_prop(cx, id, "—"); - } - let dir = self.current_dir().display().to_string(); - self.set_prop(cx, ids!(prop_path), &dir); - self.ui.widget(cx, ids!(prop_spinner)).set_visible(cx, false); - return; - } - // Nothing selected describes the folder itself, which is what Cmd+I - // on an empty selection means in Files. - let entry = picked.into_iter().next(); - let (path, name, kind, modified, created, permissions, is_dir, size) = match &entry { - Some(e) => ( - e.path.clone(), - e.name.clone(), - e.kind_text(), - e.modified_text(), - e.created_text(), - e.permissions.clone(), - e.is_dir, - e.size, - ), - None => { - // Nothing selected describes the folder itself, and the honest - // way to describe it is to ask its own parent for its entry — - // the same listing every other row here comes from. - let dir = self.current_dir(); - let entry = dir.parent().and_then(|parent| { - vfs() - .read_dir(parent, true) - .ok()? - .into_iter() - .find(|e| e.path == dir) - }); - match entry { - Some(e) => ( - dir, - e.name.clone(), - e.kind_text(), - e.modified_text(), - e.created_text(), - e.permissions.clone(), - true, - 0, - ), - None => ( - dir.clone(), - display_name(&dir), - "Folder".to_string(), - "—".to_string(), - "—".to_string(), - "—".to_string(), - true, - 0, - ), - } - } - }; - self.set_prop(cx, ids!(prop_name), &name); - self.set_prop(cx, ids!(prop_kind), &kind); - // The date says when; the age says whether that is recent, which is - // the question anyone actually has about a file. - let now = model::now_secs(); - let age = entry - .as_ref() - .filter(|e| e.modified_secs > 0) - .map(|e| format!(" {}", model::format_age(e.modified_secs, now))) - .unwrap_or_default(); - self.set_prop(cx, ids!(prop_modified), &format!("{modified}{age}")); - self.set_prop(cx, ids!(prop_created), &created); - self.set_prop( - cx, - ids!(prop_permissions), - &format!("{} {}", octal_mode(&path), permissions), - ); - let where_text = path - .parent() - .map(|p| p.display().to_string()) - .unwrap_or_else(|| path.display().to_string()); - self.set_prop(cx, ids!(prop_path), &where_text); - self.set_prop( - cx, - ids!(prop_opens), - if is_dir { - "mpfiles" - } else { - mp_wm_api::viewer_for(&path) - }, - ); - if !is_dir { - self.ui.widget(cx, ids!(prop_spinner)).set_visible(cx, false); - let text = format!("{} ({} bytes)", model::format_size(size, false), size); - self.set_prop(cx, ids!(prop_size), &text); - return; - } - // A folder's size is a whole recursive walk: it goes on a thread, and - // the panel spins until it lands. - self.set_prop(cx, ids!(prop_size), "Measuring…"); - self.ui.widget(cx, ids!(prop_spinner)).set_visible(cx, true); - self.measure_folder(cx, path); - } - - fn measure_folder(&mut self, cx: &mut Cx, path: PathBuf) { - if let Some(cancel) = self.size_cancel.take() { - cancel.store(true, Ordering::Relaxed); - } - let Some(sender) = self.size_sender.clone() else { - return; - }; - let cancel = Arc::new(AtomicBool::new(false)); - self.size_cancel = Some(cancel.clone()); - let _ = cx; - thread::spawn(move || { - let bytes = vfs().total_bytes(&path, &cancel); - if cancel.load(Ordering::Relaxed) { - return; - } - if sender.send(SizeResult { path, bytes }).is_ok() { - SignalToUI::set_ui_signal(); - } - }); - } - - fn drain_sizes(&mut self, cx: &mut Cx) { - let results: Vec = self - .size_receiver - .as_ref() - .map(|r| r.try_iter().collect()) - .unwrap_or_default(); - for result in results { - // The panel may have moved on to another folder while the walk - // ran; a stale total is worse than no total. - let showing = self - .with_contents(cx, |contents, _| contents.selected_entry()) - .flatten() - .map(|e| e.path) - .unwrap_or_else(|| self.current_dir()); - if !self.props_open || showing != result.path { - continue; - } - self.ui.widget(cx, ids!(prop_spinner)).set_visible(cx, false); - let text = format!( - "{} ({} bytes)", - model::format_size(result.bytes, false), - result.bytes - ); - self.set_prop(cx, ids!(prop_size), &text); - } - } - - // ------------------------------------------------------ file operations - - fn next_op_id(&mut self) -> u64 { - self.op_id = self.op_id.wrapping_add(1); - self.op_id - } - - fn submit(&mut self, cx: &mut Cx, kind: OpKind, sources: Vec, new_name: Option) { - let id = self.next_op_id(); - // Remembered now, applied when the job reports back: the size map can - // be corrected by arithmetic instead of another walk of the disk, but - // only if it knows what went where, and `OpUpdate::Done` says only - // where things landed. - self.remember_for_map(id, MapEffect::of(kind), sources.clone()); - let request = OpRequest { - id, - kind, - sources, - dest_dir: self.current_dir(), - new_name, - home: self.home.clone(), - }; - // An in-memory tree changes at once: sending it to a worker would only - // buy a progress bar for work that is already finished. - if vfs().is_instant() { - let update = match vfs().perform(&request) { - Ok(outcome) => OpUpdate::Done { - id, - kind, - message: outcome.message, - undo: outcome.undo, - touched: outcome.touched, - }, - Err(message) => OpUpdate::Failed { id, kind, message }, - }; - self.apply_op_update(cx, update); - return; - } - let Some(engine) = self.ops.as_ref() else { - return; - }; - engine.submit(request); - self.active_op = Some(id); - self.show_progress(cx, true, 0.0, &format!("{}…", kind.verb())); - } - - fn show_progress(&mut self, cx: &mut Cx, on: bool, fraction: f64, text: &str) { - self.ui.widget(cx, ids!(progress_row)).set_visible(cx, on); - if on { - self.ui.label(cx, ids!(progress_label)).set_text(cx, text); - let width = (fraction.clamp(0.0, 1.0) * 150.0).round(); - let mut fill = self.ui.view(cx, ids!(progress_fill)); - script_apply_eval!(cx, fill, { - width: #(width) - }); - } - self.ui.redraw(cx); - } - - /// Put the progress row away once the engine has nothing left to do. A - /// batch rename is many jobs; hiding the row on the first `Done` would - /// make the rest of them invisible. - fn finish_op(&mut self, cx: &mut Cx) { - let busy = self.ops.as_ref().map(|engine| engine.busy()).unwrap_or(false); - if !busy { - self.show_progress(cx, false, 0.0, ""); - } - } - - fn drain_ops(&mut self, cx: &mut Cx) { - let updates = self - .ops - .as_ref() - .map(|engine| engine.drain()) - .unwrap_or_default(); - for update in updates { - self.apply_op_update(cx, update); - } - } - - /// One finished (or progressing) operation, whichever filesystem did it. - fn apply_op_update(&mut self, cx: &mut Cx, update: OpUpdate) { - { - match update { - OpUpdate::Progress { - id, - kind, - done, - total, - current, - } => { - if self.active_op != Some(id) { - return; - } - let fraction = if total == 0 { - 0.0 - } else { - done as f64 / total as f64 - }; - let text = format!( - "{} {} — {} of {}", - kind.verb(), - current, - model::format_size(done, false), - model::format_size(total, false) - ); - self.show_progress(cx, true, fraction, &text); - } - OpUpdate::Done { - id, - kind, - message, - undo, - touched, - } => { - if let Some(undo) = undo { - self.journal.push(undo); - } - if self.active_op == Some(id) { - self.active_op = None; - } - self.finish_op(cx); - self.map_absorb(cx, id, &touched); - // What a job left behind is worth selecting only when it - // landed *here*: a trashed file's `touched` path is inside - // the Trash, and selecting it would select nothing. - if kind != OpKind::Trash { - self.pending_select = touched; - } - self.status(cx, &message); - self.pending_status = Some(message); - self.request_directory(cx); - } - OpUpdate::Failed { id, kind, message } => { - // Nothing happened, so the map is still right. - self.map_jobs.retain(|job| job.id != id); - if self.active_op == Some(id) { - self.active_op = None; - } - self.finish_op(cx); - let text = format!("{} failed — {message}", kind.verb()); - self.status(cx, &text); - self.pending_status = Some(text); - self.request_directory(cx); - } - } - } - } - - fn copy_selection(&mut self, cx: &mut Cx, cut: bool) { - let paths = self.target_paths(cx); - if paths.is_empty() { - self.status(cx, "Nothing selected to copy"); - return; - } - let count = paths.len(); - self.clipboard = paths; - self.clipboard_cut = cut; - self.status( - cx, - &format!( - "{} {} item{} — Cmd+V pastes {} here or in another tab", - if cut { "Cut" } else { "Copied" }, - count, - if count == 1 { "" } else { "s" }, - if count == 1 { "it" } else { "them" } - ), - ); - } - - fn paste(&mut self, cx: &mut Cx) { - if self.clipboard.is_empty() { - self.status(cx, "The clipboard is empty"); - return; - } - let sources = self.clipboard.clone(); - let kind = if self.clipboard_cut { - OpKind::Move - } else { - OpKind::Copy - }; - // A cut is spent once it is pasted; a copy stays on the clipboard the - // way it does everywhere else. - if self.clipboard_cut { - self.clipboard.clear(); - } - self.submit(cx, kind, sources, None); - } - - /// The paths a file operation starts from. - /// - /// Normally that is the listing's selection. On the treemap it usually - /// cannot be: nearly everything the map draws lives below the folder being - /// listed, so the map's own pick is the answer instead. Telling somebody - /// mid-cleanup that nothing is selected while a 6 GB rectangle sits - /// outlined in front of them would be a lie. - fn target_paths(&mut self, cx: &mut Cx) -> Vec { - let paths: Vec = self - .with_contents(cx, |contents, _| contents.selected_entries()) - .unwrap_or_default() - .into_iter() - .map(|e| e.path) - .collect(); - if !paths.is_empty() || !self.tabs[self.tab].mode.is_treemap() { - return paths; - } - self.with_contents(cx, |contents, cx| contents.treemap(cx).selection()) - .flatten() - .map(|path| vec![path]) - .unwrap_or_default() - } - - fn trash_selection(&mut self, cx: &mut Cx) { - let paths = self.target_paths(cx); - if paths.is_empty() { - self.status(cx, "Nothing selected to move to the Trash"); - return; - } - self.submit(cx, OpKind::Trash, paths, None); - } - - fn new_folder(&mut self, cx: &mut Cx) { - let name = ops::unique_path(&self.current_dir(), "untitled folder"); - let name = display_name(&name); - // The new folder arrives selected with its name up for editing, which - // is the only moment anyone ever wants to type a folder name. - self.pending_rename = Some(self.current_dir().join(&name)); - self.submit(cx, OpKind::NewFolder, Vec::new(), Some(name)); - } - - fn undo(&mut self, cx: &mut Cx) { - let Some(undo) = self.journal.pop() else { - self.status(cx, "Nothing to undo"); - return; - }; - let id = self.next_op_id(); - // An undo is a move backwards or a removal, and both sides of it are - // already known — so the map follows it without a rescan too. - match &undo { - Undo::Moved { pairs } => { - let sources: Vec = pairs.iter().map(|(_, to)| to.clone()).collect(); - self.remember_for_map(id, MapEffect::Move, sources); - } - Undo::Created { paths } => { - self.remember_for_map(id, MapEffect::Remove, paths.clone()); - } - } - let home = self.home.clone(); - let description = undo.describe(); - if vfs().is_instant() { - let update = match vfs().perform_undo(&undo) { - Ok(outcome) => OpUpdate::Done { - id, - kind: OpKind::Move, - message: outcome.message, - undo: None, - touched: outcome.touched, - }, - Err(message) => OpUpdate::Failed { - id, - kind: OpKind::Move, - message, - }, - }; - self.apply_op_update(cx, update); - self.status(cx, &description); - return; - } - let Some(engine) = self.ops.as_ref() else { - return; - }; - engine.submit_undo(id, undo, home); - self.active_op = Some(id); - self.show_progress(cx, true, 0.0, &description); - let left = self.journal.len(); - self.status( - cx, - &format!( - "{description} — {left} step{} left to undo", - if left == 1 { "" } else { "s" } - ), - ); - } - - // -------------------------------------------------------------- rename - - fn begin_rename(&mut self, cx: &mut Cx) { - let picked = self - .with_contents(cx, |contents, _| contents.selected_entries()) - .unwrap_or_default(); - match picked.len() { - 0 => self.status(cx, "Select a file first — F2 renames it"), - 1 => { - let path = picked[0].path.clone(); - if self.tabs[self.tab].mode.is_treemap() { - self.status(cx, "Renaming needs a list view — Cmd+1, 2 or 3"); - return; - } - self.with_contents(cx, |contents, cx| contents.begin_rename(cx, &path)); - self.status(cx, "Type the new name, Enter to rename, Esc to leave it"); - } - _ => self.open_batch(cx, picked.into_iter().map(|e| e.path).collect()), - } - } - - fn commit_rename(&mut self, cx: &mut Cx, path: PathBuf, name: String) { - if let Some(problem) = rename::name_error(&name) { - self.status(cx, problem); - return; - } - if name == display_name(&path) { - self.report(cx); - return; - } - self.pending_select = vec![path.with_file_name(&name)]; - self.submit(cx, OpKind::Rename, vec![path], Some(name)); - } - - // -------------------------------------------------------- batch rename - - fn open_batch(&mut self, cx: &mut Cx, targets: Vec) { - self.batch_targets = targets; - self.batch_open = true; - let count = self.batch_targets.len(); - self.ui.label(cx, ids!(batch_title)).set_text( - cx, - &format!("Rename {} item{}", count, if count == 1 { "" } else { "s" }), - ); - for id in [ids!(batch_find), ids!(batch_replace), ids!(batch_pattern)] { - let row = self.ui.view(cx, id); - row.text_input(cx, ids!(field_input)).set_text(cx, ""); - } - self.ui.widget(cx, ids!(batch_dialog)).set_visible(cx, true); - self.refresh_batch_preview(cx); - self.focus_soon(cx, FocusTarget::Batch); - self.ui.redraw(cx); - } - - fn close_batch(&mut self, cx: &mut Cx) { - self.batch_open = false; - self.batch_targets.clear(); - self.ui.widget(cx, ids!(batch_dialog)).set_visible(cx, false); - cx.set_key_focus(Area::Empty); - self.report(cx); - self.ui.redraw(cx); - } - - fn batch_field(&mut self, cx: &mut Cx, id: &[LiveId]) -> String { - self.ui.view(cx, id).text_input(cx, ids!(field_input)).text() - } - - /// What the dialog's fields would do, computed by the same function that - /// will do it — so the preview cannot drift from the result. - fn batch_plan(&mut self, cx: &mut Cx) -> Vec<(PathBuf, String)> { - let find = self.batch_field(cx, ids!(batch_find)); - let replace = self.batch_field(cx, ids!(batch_replace)); - let pattern = self.batch_field(cx, ids!(batch_pattern)); - let mode = BatchMode::from_fields(&find, &replace, &pattern); - let names: Vec = self - .batch_targets - .iter() - .map(|p| display_name(p)) - .collect(); - let renamed = rename::batch_rename(&names, &mode, 1); - if rename::is_noop(&names, &renamed) { - return Vec::new(); - } - self.batch_targets - .iter() - .cloned() - .zip(renamed) - .filter(|(path, name)| &display_name(path) != name) - .collect() - } - - fn refresh_batch_preview(&mut self, cx: &mut Cx) { - let plan = self.batch_plan(cx); - let text = if plan.is_empty() { - "Nothing would change yet.".to_string() - } else { - let mut lines: Vec = plan - .iter() - .take(6) - .map(|(path, name)| format!("{} → {}", display_name(path), name)) - .collect(); - if plan.len() > lines.len() { - lines.push(format!("…and {} more", plan.len() - lines.len())); - } - lines.join("\n") - }; - self.ui.label(cx, ids!(batch_preview)).set_text(cx, &text); - } - - fn apply_batch(&mut self, cx: &mut Cx) { - let plan = self.batch_plan(cx); - if plan.is_empty() { - self.status(cx, "That pattern would not change any name"); - return; - } - let count = plan.len(); - self.close_batch(cx); - // One rename job per file: the engine's Rename takes a single source, - // and a failure on one name must not abandon the rest. - let mut selected = Vec::with_capacity(count); - for (path, name) in plan { - selected.push(path.with_file_name(&name)); - self.submit(cx, OpKind::Rename, vec![path], Some(name)); - } - self.pending_select = selected; - self.status(cx, &format!("Renaming {count} items")); - } - - // ------------------------------------------------------------ terminal - - fn open_terminal(&mut self, cx: &mut Cx) { - let dir = self.current_dir(); - let request = mp_wm_api::WmRequest::Launch { - app: "terminal".to_string(), - args: vec!["--cwd".to_string(), dir.display().to_string()], - }; - if mp_wm_api::send(cx, &request) { - self.status(cx, &format!("Opening a terminal in {}", dir.display())); - return; - } - let Some(bin) = preview::sibling_bin("mpterm") else { - self.status(cx, "mpterm is not built — nothing to open a terminal with"); - return; - }; - match Command::new(&bin).arg("--cwd").arg(&dir).spawn() { - Ok(_) => self.status(cx, &format!("Opening a terminal in {}", dir.display())), - Err(error) => self.status(cx, &format!("Could not start mpterm: {error}")), - } - } - - // ---------------------------------------------------------------- open - - fn open_entry(&mut self, cx: &mut Cx, entry: FileEntry) { - if entry.is_dir { - self.navigate(cx, entry.path, true); - return; - } - let message = preview::open_file(cx, &entry.path); - self.status(cx, &message); - } - - fn describe(&mut self, cx: &mut Cx, entry: &FileEntry) { - // The listing's selection is what "this" means outside the map, so the - // ask panel's chip follows it here the same way it follows the pick. - self.refresh_chat(cx); - let picked = self - .with_contents(cx, |contents, _| contents.selection_count()) - .unwrap_or(1); - if picked > 1 { - self.report(cx); - self.refresh_props(cx); - return; - } - // An open Quick Look panel follows the selection, the way Finder's - // does: the same viewer is retargeted, so arrow keys dial through - // previews without this window ever losing the keyboard. - self.preview.retarget(cx, &entry.path); - let text = if entry.is_dir { - format!("{} — folder", entry.name) - } else { - format!( - "{} — {} — {} — {} — opens with {}", - entry.name, - entry.kind.label(), - model::format_size(entry.size, false), - entry.modified_text(), - mp_wm_api::viewer_for(&entry.path), - ) - }; - self.status(cx, &text); - self.refresh_props(cx); - } - - // ------------------------------------------------------------ keyboard - - fn handle_key(&mut self, cx: &mut Cx, event: &KeyEvent) { - let command = event.modifiers.control || event.modifiers.logo; - let shift = event.modifiers.shift; - - // Escape unwinds whatever is on top, innermost first. - if event.key_code == KeyCode::Escape { - // The caret in the filter's query field first: Escape clears what - // is typed there, and only an already-empty field lets Escape - // mean anything bigger. The sidebar itself is the funnel's to - // close, never Escape's — a surprise-closing panel loses work. - if self.filter_popup_open && self.filter_is_typing(cx) { - let field = self.ui.text_input(cx, ids!(filter_query)); - if !field.text().is_empty() { - field.set_text(cx, ""); - self.rebuild_filter(cx); - return; - } - } - if self.menu_open { - return self.close_menu(cx); - } - // Only while the caret is actually in the ask field: Escape on the - // map still means "zoom back out", panel or no panel. - if self.chat_open && self.chat_is_typing(cx) { - return self.toggle_chat(cx); - } - // A zoomed treemap is one of the things Escape is on top of: it - // steps back out one folder before Escape means anything else. - if self.tabs[self.tab].mode.is_treemap() - && self - .with_contents(cx, |contents, cx| contents.treemap(cx).zoom_out(cx)) - .unwrap_or(false) - { - return self.report(cx); - } - if !self.pending_delete.is_empty() { - self.pending_delete.clear(); - self.status(cx, "Nothing was deleted"); - return; - } - if self.batch_open { - return self.close_batch(cx); - } - if self.column_menu_open { - return self.set_column_menu(cx, false); - } - if self.path_edit_open { - return self.set_path_edit(cx, false); - } - if self - .with_contents(cx, |contents, _| contents.is_renaming()) - .unwrap_or(false) - { - self.with_contents(cx, |contents, cx| contents.cancel_rename(cx)); - return self.report(cx); - } - if self.quick_look_open || self.preview.showing().is_some() { - self.close_preview(cx); - } else if self.search_visible { - self.set_search(cx, false); - } else { - self.with_contents(cx, |contents, cx| contents.clear_selection(cx)); - self.refresh_props(cx); - self.report(cx); - } - return; - } - - // Every text field in this window keeps key focus while it is hidden, - // so what is *open* decides whether a key is text or navigation. The - // chat's field is the exception: the panel stays open while the user - // reads, so it is the keyboard that says whether they are typing in it. - // The filter's query field counts too: without it, typing a word into - // the filter let Backspace fall through to "go up" — which navigated - // away and started a whole new scan mid-keystroke — and q/e spun the - // camera under the caret. - let editing = self.batch_open - || self.path_edit_open - || self.search_visible - || self.chat_is_typing(cx) - || self.filter_is_typing(cx) - || self - .with_contents(cx, |contents, _| contents.is_renaming()) - .unwrap_or(false); - - if command { - match event.key_code { - KeyCode::KeyK => return self.toggle_chat(cx), - KeyCode::KeyT if !shift => return self.new_tab(cx), - KeyCode::KeyW => return self.close_tab(cx), - KeyCode::LBracket if shift => return self.switch_tab(cx, -1), - KeyCode::RBracket if shift => return self.switch_tab(cx, 1), - KeyCode::KeyL => return self.set_path_edit(cx, !self.path_edit_open), - KeyCode::KeyD if !editing => return self.bookmark_current(cx), - KeyCode::KeyN if shift => return self.new_folder(cx), - KeyCode::KeyI if !editing => { - let open = !self.props_open; - return self.set_props(cx, open); - } - KeyCode::KeyZ if !editing => return self.undo(cx), - KeyCode::KeyC if !editing => return self.copy_selection(cx, false), - KeyCode::KeyX if !editing => return self.copy_selection(cx, true), - KeyCode::KeyV if !editing => return self.paste(cx), - KeyCode::KeyA if !editing => { - self.with_contents(cx, |contents, cx| contents.select_all(cx)); - self.report(cx); - return; - } - KeyCode::Backspace if !editing => return self.trash_selection(cx), - KeyCode::Equals | KeyCode::NumpadAdd if !editing => return self.zoom(cx, 1), - KeyCode::Minus | KeyCode::NumpadSubtract if !editing => return self.zoom(cx, -1), - KeyCode::KeyH => return self.toggle_hidden(cx), - KeyCode::KeyF => { - self.set_search(cx, !self.search_visible); - return; - } - KeyCode::KeyR if !editing => return self.rescan_map(cx), - KeyCode::Key1 => return self.set_mode(cx, ViewMode::Icons), - KeyCode::Key2 => return self.set_mode(cx, ViewMode::List), - KeyCode::Key3 => return self.set_mode(cx, ViewMode::Compact), - // The block view and its three renderings: Cmd+4 the flat - // map, Cmd+5 the extrusion, Cmd+6 the perspective — each - // enters the view if it is not already open. - KeyCode::Key4 => { - self.set_projection_choice(cx, MapProjection::Flat); - return self.set_mode(cx, ViewMode::Treemap); - } - KeyCode::Key5 => { - self.set_projection_choice(cx, MapProjection::Ortho); - return self.set_mode(cx, ViewMode::Treemap); - } - KeyCode::Key6 => { - self.set_projection_choice(cx, MapProjection::Persp); - return self.set_mode(cx, ViewMode::Treemap); - } - _ => {} - } - } - // Ctrl+Tab cycles tabs even on macOS, where Cmd+Tab belongs to the OS. - if event.key_code == KeyCode::Tab && event.modifiers.control { - return self.switch_tab(cx, if shift { -1 } else { 1 }); - } - if event.key_code == KeyCode::F2 && !editing { - return self.begin_rename(cx); - } - if event.key_code == KeyCode::F5 && !editing { - return self.rescan_map(cx); - } - if event.key_code == KeyCode::Delete && !editing { - if shift { - return self.delete_forever(cx); - } - return self.trash_selection(cx); - } - // The macOS keyboard's Delete key is Backspace, so the same pair holds - // there: with Cmd it trashes, with Cmd+Shift it erases. - if event.key_code == KeyCode::Backspace && command && shift && !editing { - return self.delete_forever(cx); - } - if editing { - return; - } - // On the map, Enter zooms into the picked folder and Backspace steps - // back out of one — the same pair the list view uses for open and go - // up, meaning the same two things one level in. - if self.tabs[self.tab].mode.is_treemap() { - match event.key_code { - KeyCode::ReturnKey | KeyCode::NumpadEnter => { - if self - .with_contents(cx, |contents, cx| { - contents.treemap(cx).zoom_into_selection(cx) - }) - .unwrap_or(false) - { - return self.report(cx); - } - } - KeyCode::Backspace => { - if self - .with_contents(cx, |contents, cx| contents.treemap(cx).zoom_out(cx)) - .unwrap_or(false) - { - return self.report(cx); - } - } - // Q and E step the orbit, the keyboard's version of the - // left-drag. A no-op on the flat map. - KeyCode::KeyQ => { - self.with_contents(cx, |contents, cx| { - contents.treemap(cx).orbit_by(cx, -0.26, 0.0); - }); - return; - } - KeyCode::KeyE => { - self.with_contents(cx, |contents, cx| { - contents.treemap(cx).orbit_by(cx, 0.26, 0.0); - }); - return; - } - _ => {} - } - } - match event.key_code { - KeyCode::Space => self.toggle_preview(cx), - KeyCode::Backspace => self.go_up(cx), - // Cmd/Ctrl+Up is the desktop's "open parent folder". - KeyCode::ArrowUp if command => self.go_up(cx), - KeyCode::ReturnKey | KeyCode::NumpadEnter => { - if let Some(entry) = self - .with_contents(cx, |contents, _| contents.selected_entry()) - .flatten() - { - self.open_entry(cx, entry); - } - } - KeyCode::ArrowLeft | KeyCode::ArrowRight | KeyCode::ArrowUp | KeyCode::ArrowDown => { - let stride = self - .with_contents(cx, |contents, _| contents.row_stride()) - .unwrap_or(1); - let amount = match event.key_code { - KeyCode::ArrowLeft => -1, - KeyCode::ArrowRight => 1, - KeyCode::ArrowUp => -stride, - _ => stride, - }; - let selected = self - .with_contents(cx, |contents, cx| contents.move_selection(cx, amount, shift)) - .flatten(); - if let Some(entry) = selected { - self.describe(cx, &entry); - } - } - _ => {} - } - } - - // -------------------------------------------------------------- events - - /// A drag ended at `at`: over the sidebar it means "bookmark this". - fn handle_drop(&mut self, cx: &mut Cx, paths: Vec, at: DVec2) { - let rect = self.ui.view(cx, ids!(sidebar)).area().rect(cx); - if !rect.contains(at) { - return; - } - let folders: Vec = paths.into_iter().filter(|p| vfs().is_dir(p)).collect(); - if folders.is_empty() { - self.status(cx, "Only folders can be bookmarked"); - return; - } - for folder in folders { - self.bookmark(cx, folder); - } - } - - /// Note what a job will do to the size map, so its completion can be - /// folded in rather than triggering a rescan. Bounded: a job that never - /// reports back must not leave a record here forever. - fn remember_for_map(&mut self, id: u64, effect: MapEffect, sources: Vec) { - if matches!(effect, MapEffect::Nothing) || sources.is_empty() { - return; - } - if self.map_jobs.len() >= 32 { - self.map_jobs.remove(0); - } - self.map_jobs.push(MapJob { - id, - effect, - sources, - }); - } - - /// Correct the size map for a job that just finished. `touched` is where - /// things ended up, in the same order as the sources that produced them. - fn map_absorb(&mut self, cx: &mut Cx, id: u64, touched: &[PathBuf]) { - let Some(index) = self.map_jobs.iter().position(|job| job.id == id) else { - return; - }; - let job = self.map_jobs.remove(index); - let map = self.with_contents(cx, |contents, cx| contents.treemap(cx)); - let Some(map) = map else { return }; - match job.effect { - MapEffect::Nothing => {} - MapEffect::Remove => { - let moves: Vec<(PathBuf, Option)> = - job.sources.into_iter().map(|from| (from, None)).collect(); - map.absorb_moves(cx, &moves); - } - MapEffect::Move => { - // A job that reported fewer destinations than sources did not - // move all of them; the ones it cannot account for are treated - // as gone from where they were, which is the one thing that is - // certainly true. - let moves: Vec<(PathBuf, Option)> = job - .sources - .into_iter() - .enumerate() - .map(|(i, from)| (from, touched.get(i).cloned())) - .collect(); - map.absorb_moves(cx, &moves); - } - MapEffect::Copy => { - let copies: Vec<(PathBuf, PathBuf)> = job - .sources - .into_iter() - .zip(touched.iter().cloned()) - .collect(); - map.absorb_copies(cx, &copies); - } - } - } - - fn handle_contents_action(&mut self, cx: &mut Cx, action: FileContentsAction) { - match action { - FileContentsAction::Open(entry) => self.open_entry(cx, entry), - // On the map the status line belongs to the map: it says what is - // on screen and what was picked, which is more than one entry's - // description and never goes stale behind it. - FileContentsAction::Selected(entry) => { - if self.tabs[self.tab].mode.is_treemap() { - self.report(cx) - } else { - self.describe(cx, &entry) - } - } - FileContentsAction::Sorted | FileContentsAction::Restated => self.report(cx), - FileContentsAction::MapFilterCleared => self.reset_filter_controls(cx), - FileContentsAction::Renamed(path, name) => self.commit_rename(cx, path, name), - FileContentsAction::RenameCancelled => self.report(cx), - FileContentsAction::Dropped(paths, at) => self.handle_drop(cx, paths, at), - FileContentsAction::NeedChildren(folder) => self.request_children(cx, folder), - FileContentsAction::Context { at, entry } => self.open_menu(cx, at, entry), - } - } -} - -/// What a finished operation does to the size map. -#[derive(Clone, Copy, PartialEq)] -enum MapEffect { - /// The sources stop existing anywhere the map can see. - Remove, - /// The sources end up somewhere else, which may or may not be on the map. - Move, - /// The sources stay and are duplicated. - Copy, - /// Nothing worth correcting: a new empty folder is no bytes. - Nothing, -} - -impl MapEffect { - fn of(kind: OpKind) -> MapEffect { - match kind { - OpKind::Delete => MapEffect::Remove, - OpKind::Trash | OpKind::Move | OpKind::Rename => MapEffect::Move, - OpKind::Copy => MapEffect::Copy, - OpKind::NewFolder => MapEffect::Nothing, - } - } -} - -/// One submitted job, remembered until it reports back. -struct MapJob { - id: u64, - effect: MapEffect, - sources: Vec, -} - -/// The mode as `755`, next to the `rwx` letters the listing already shows. -fn octal_mode(path: &Path) -> String { - // A virtual file has no inode to ask, and inventing one would be a number - // that means nothing. - if vfs::is_demo() { - return "—".to_string(); - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - match std::fs::metadata(path) { - Ok(meta) => format!("{:o}", meta.permissions().mode() & 0o7777), - Err(_) => "—".to_string(), - } - } - #[cfg(not(unix))] - { - let _ = path; - "—".to_string() - } -} - -impl App { - /// The menu's own pointer handling. Returns true when the event was the - /// menu's, so nothing behind it also acts on the same click. - fn handle_menu_actions(&mut self, cx: &mut Cx, actions: &Actions) -> bool { - // The submenu is on top, so it is asked first. - if self.submenu_open { - for index in 0..self.submenu_apps.len() { - let row = self.ui.view(cx, CTX_SUB_IDS[index]).view(cx, ids!(ctx_body)); - if row.finger_hover_in(actions).is_some() && self.submenu_hover != Some(index) { - self.submenu_hover = Some(index); - self.fill_submenu(cx); - self.ui.redraw(cx); - } - if row.finger_down(actions).is_some() { - self.fire_menu(cx, MenuAction::OpenWithApp(index)); - return true; - } - } - } - let rows = self.menu_rows.clone(); - for (index, row) in rows.iter().enumerate() { - let body = self.ui.view(cx, CTX_IDS[index]).view(cx, ids!(ctx_body)); - if body.finger_hover_in(actions).is_some() && self.menu_hover != Some(index) { - self.menu_hover = Some(index); - self.fill_menu(cx); - // Moving onto another row puts away a submenu that belonged to - // the one before it. - if row.action != MenuAction::OpenWith { - self.close_submenu(cx); - } else { - self.open_submenu(cx, index); - } - self.ui.redraw(cx); - } - if body.finger_down(actions).is_some() { - if row.action == MenuAction::OpenWith { - self.open_submenu(cx, index); - } else { - self.fire_menu(cx, row.action); - } - return true; - } - } - // Anywhere else closes it, which is what clicking outside a menu means. - if self - .ui - .view(cx, ids!(context_menu)) - .finger_down(actions) - .is_some() - { - self.close_menu(cx); - return true; - } - false - } - - /// Wakes a dormant warm instance: `WmEvent::Adopted`, or defensively the - /// first real key/pointer input in case that message was lost. A no-op - /// past the first call (`Dormancy::wake` only fires once), so input - /// arriving after `Adopted` already woke it never rescans. - fn wake(&mut self, cx: &mut Cx) { - if self.dormancy.wake() { - log!("mpfiles: warm instance woken, scanning now"); - self.enter_tab(cx); - } - } - - // ------------------------------------------------------------------ chat - - /// Open or close the ask panel. Opening it for the first time is what - /// starts the model loading — until then this app has no idea a language - /// model exists, which is the only way a file browser is allowed to have - /// one. Cmd+K, or the speech-bubble button in the toolbar. - fn toggle_chat(&mut self, cx: &mut Cx) { - let open = !self.chat_open; - self.chat_open = open; - self.ui.widget(cx, ids!(chat_panel)).set_visible(cx, open); - if !open { - return; - } - if self.agent.is_none() { - self.start_chat(cx); - } - self.refresh_chat(cx); - self.focus_soon(cx, FocusTarget::Chat); - } - - /// Load the model, once. A machine without the weights on it says so and - /// carries on being a file browser. - fn start_chat(&mut self, cx: &mut Cx) { - let Some(model) = chat_agent::model_path() else { - self.chat.push( - ChatVoice::Info, - format!( - "No local model on this machine. Put a Qwen GGUF at {} (or point {} at one) and reopen this panel.", - chat_agent::MODEL_FILE, - chat_agent::MODEL_ENV, - ), - ); - self.set_chat_status(cx, "no model — the rest of the app is unaffected"); - self.redraw_chat(cx); - return; - }; - self.agent = Some(ChatAgent::start( - model.clone(), - CHAT_SYSTEM_PROMPT.to_string(), - chat_tools::tools(), - )); - self.tool_runner = Some(ToolRunner::new()); - self.chat.push( - ChatVoice::Info, - format!("Loading {}…", display_name(&model)), - ); - self.set_chat_status(cx, "loading the model…"); - self.redraw_chat(cx); - } - - fn set_chat_status(&mut self, cx: &mut Cx, text: &str) { - if self.chat_status == text { - return; - } - self.chat_status = text.to_string(); - self.ui.label(cx, ids!(chat_status)).set_text(cx, text); - } - - fn redraw_chat(&mut self, cx: &mut Cx) { - let list = self.ui.portal_list(cx, ids!(chat_list)); - list.set_tail_range(true); - list.redraw(cx); - } - - /// Where the user is, as the model reads it: the folder, the view, and - /// what is picked. This rides in front of every question and never appears - /// in the transcript — "what is this?" is the whole of what was asked. - fn chat_where(&mut self, cx: &mut Cx) -> String { - let mode = self.tabs[self.tab].mode; - let dir = self.current_dir(); - let mut out = format!( - "[where the user is]\nhome: {}\nfolder: {}\nview: {}\n", - self.home.display(), - dir.display(), - mode.label(), - ); - if mode.is_treemap() { - let map = self.with_contents(cx, |contents, cx| { - let map = contents.treemap(cx); - (map.selection(), map.status()) - }); - let (picked, status) = map.unwrap_or_default(); - out.push_str(&format!("map: {status}\n")); - match picked { - Some(path) => out.push_str(&format!("selected: {}\n", describe_path(&path))), - None => out.push_str("selected: nothing on the map is picked\n"), - } - return out; - } - let selected = self - .with_contents(cx, |contents, _| contents.selected_entries()) - .unwrap_or_default(); - if selected.is_empty() { - out.push_str("selected: nothing — the question is about the folder itself\n"); - return out; - } - out.push_str(&format!("selected: {} item(s)\n", selected.len())); - for entry in selected.iter().take(12) { - out.push_str(&format!( - " {} — {}, {}\n", - entry.path.display(), - entry.kind_text(), - entry.size_text(), - )); - } - if selected.len() > 12 { - out.push_str(&format!(" …and {} more\n", selected.len() - 12)); - } - out - } - - /// The one-line "about:" chip over the input, and the map strip's hint and - /// button states. Called from `report`, so it follows every selection - /// change — and only touches a widget when its text actually changed. - fn refresh_chat(&mut self, cx: &mut Cx) { - let mode = self.tabs[self.tab].mode; - let picked = self.chat_subject(cx); - if self.chat_open { - let about = match &picked { - Some(path) => format!("about: {}", describe_path(path)), - None => format!("about: {} (this folder)", self.current_dir().display()), - }; - if about != self.chat_about { - self.chat_about = about.clone(); - self.ui - .label(cx, ids!(chat_about_label)) - .set_text(cx, &about); - } - } - if !mode.is_treemap() { - return; - } - let note = match &picked { - Some(path) => format!("Rescan · act on {}", display_name(path)), - None => "Rescan · click a rectangle to pick what to delete".to_string(), - }; - if note == self.map_tools_note { - return; - } - self.map_tools_note = note.clone(); - self.ui - .label(cx, ids!(map_tools_hint)) - .set_text(cx, ¬e); - // The two delete buttons go out when there is nothing under them: a - // button that looks live and does nothing is worse than a dim one. - let palette = Palette::shared(); - let live = Palette::vec4(&palette.fg); - let danger = Palette::vec4(&palette.danger); - let dead = Palette::vec4(&palette.muted); - let has_pick = picked.is_some(); - for (id, lit) in [ - (ids!(map_trash_icon), if has_pick { live } else { dead }), - (ids!(map_erase_icon), if has_pick { danger } else { dead }), - ] { - let mut icon = self.ui.widget(cx, id); - script_apply_eval!(cx, icon, { - draw_icon +: {color: #(lit)} - }); - } - } - - /// Is the caret in the ask field? The panel stays open while its answer is - /// read, so "open" cannot be what decides whether a key is text. - fn chat_is_typing(&mut self, cx: &mut Cx) -> bool { - if !self.chat_open { - return false; - } - let area = self.ui.text_input(cx, ids!(chat_input)).area(); - !area.is_empty() && cx.has_key_focus(area) - } - - /// Whether the caret is in the filter sidebar's query field. - fn filter_is_typing(&mut self, cx: &mut Cx) -> bool { - let area = self.ui.text_input(cx, ids!(filter_query)).area(); - !area.is_empty() && cx.has_key_focus(area) - } - - /// What "this" means right now: the map's pick on the map, the listing's - /// selection anywhere else. - fn chat_subject(&mut self, cx: &mut Cx) -> Option { - if self.tabs[self.tab].mode.is_treemap() { - return self - .with_contents(cx, |contents, cx| contents.treemap(cx).selection()) - .flatten(); - } - self.with_contents(cx, |contents, _| contents.selected_entry()) - .flatten() - .map(|entry| entry.path) - } - - fn send_chat(&mut self, cx: &mut Cx) { - let field = self.ui.text_input(cx, ids!(chat_input)); - let text = field.text().trim().to_string(); - drop(field); - if text.is_empty() { - return; - } - if self.agent.is_none() { - self.start_chat(cx); - if self.agent.is_none() { - return; - } - } - if !self.chat_ready { - self.chat - .push(ChatVoice::Info, "The model is still loading — one moment."); - self.redraw_chat(cx); - return; - } - if self.chat_busy { - // A second question while the first is running is an override, not - // a queue: stop the old one and ask the new one. - self.stop_chat(cx); - } - self.ui.text_input(cx, ids!(chat_input)).set_text(cx, ""); - self.chat.push(ChatVoice::User, text.clone()); - let prompt = format!("{}\n[question]\n{text}", self.chat_where(cx)); - if let Some(agent) = &self.agent { - agent.send_user_turn(prompt); - } - self.chat_busy = true; - self.chat_tool_rounds = 0; - self.chat_awaiting_tools = 0; - self.chat_tool_replies.clear(); - self.set_chat_status(cx, "thinking…"); - self.set_chat_running(cx, true); - self.redraw_chat(cx); - } - - fn stop_chat(&mut self, cx: &mut Cx) { - if !self.chat_busy { - return; - } - if let Some(agent) = &self.agent { - agent.cancel(); - } - self.chat.commit_pending(); - self.chat.push(ChatVoice::Info, "stopped"); - self.chat_busy = false; - self.chat_awaiting_tools = 0; - self.chat_tool_replies.clear(); - self.set_chat_status(cx, "ready"); - self.set_chat_running(cx, false); - self.redraw_chat(cx); - } - - /// Swap the Ask button for Stop while a turn is running. - fn set_chat_running(&mut self, cx: &mut Cx, running: bool) { - self.ui - .widget(cx, ids!(chat_send)) - .set_visible(cx, !running); - self.ui.widget(cx, ids!(chat_stop)).set_visible(cx, running); - } - - /// Everything the model and the tool worker have said since the last frame. - fn drain_chat(&mut self, cx: &mut Cx) { - let events = match &self.agent { - Some(agent) => agent.poll(), - None => Vec::new(), - }; - for event in events { - self.on_chat_event(cx, event); - } - let replies = match &self.tool_runner { - Some(runner) => runner.drain(), - None => Vec::new(), - }; - for reply in replies { - self.chat.push( - ChatVoice::Tool, - if reply.is_error { - format!("⚠ {}", reply.note) - } else { - reply.note.clone() - }, - ); - self.chat_tool_replies.push(ToolReply { - text: reply.text, - is_error: reply.is_error, - }); - if self.chat_tool_replies.len() >= self.chat_awaiting_tools.max(1) { - let results: Vec<(String, bool)> = self - .chat_tool_replies - .drain(..) - .map(|reply| (reply.text, reply.is_error)) - .collect(); - self.chat_awaiting_tools = 0; - if let Some(agent) = &self.agent { - agent.send_tool_results(results); - } - self.set_chat_status(cx, "reading…"); - } - self.redraw_chat(cx); - } - } - - fn on_chat_event(&mut self, cx: &mut Cx, event: ChatEvent) { - match event { - ChatEvent::Loading { phase, fraction } => { - let text = format!("loading — {phase} {:.0}%", fraction * 100.0); - self.set_chat_status(cx, &text); - } - ChatEvent::Ready { - prefill_tokens, - secs, - } => { - self.chat_ready = true; - self.chat.push( - ChatVoice::Info, - format!("Ready — {prefill_tokens} tokens of prompt in {secs:.1}s."), - ); - self.set_chat_status(cx, "ready — ask about the folder or the selection"); - self.redraw_chat(cx); - } - ChatEvent::Failed(error) => { - self.chat_ready = false; - self.chat_busy = false; - self.agent = None; - self.chat.push(ChatVoice::Info, format!("⚠ {error}")); - self.set_chat_status(cx, "the model could not be loaded"); - self.set_chat_running(cx, false); - self.redraw_chat(cx); - } - ChatEvent::Delta(text) => { - self.chat.pending.push_str(&text); - self.redraw_chat(cx); - } - ChatEvent::ToolCall { name, args } => { - self.chat.commit_pending(); - self.chat_awaiting_tools += 1; - let job = ToolJob { - name, - args, - cwd: self.current_dir(), - home: self.home.clone(), - }; - match (&self.tool_runner, self.chat_tool_rounds < MAX_TOOL_ROUNDS) { - (Some(runner), true) => runner.submit(job), - // Enough. The turn ends with the truth rather than with - // another lap of the same three folders. - _ => { - self.chat_awaiting_tools = self.chat_awaiting_tools.saturating_sub(1); - if let Some(agent) = &self.agent { - agent.send_tool_results(vec![( - "that is enough looking around — answer from what you already have" - .to_string(), - true, - )]); - } - } - } - self.redraw_chat(cx); - } - ChatEvent::TurnDone { - tool_calls, - tokens, - secs, - context_used, - context_max, - } => { - if tool_calls > 0 { - // The tools drive the next round; the turn is not over. - self.chat_tool_rounds += 1; - self.set_chat_status(cx, "looking…"); - return; - } - self.chat.commit_pending(); - self.chat_busy = false; - self.set_chat_running(cx, false); - let rate = tokens as f64 / secs.max(0.001); - self.set_chat_status( - cx, - &format!( - "{tokens} tokens in {secs:.1}s ({rate:.1} tok/s) · context {context_used}/{context_max}" - ), - ); - self.redraw_chat(cx); - } - ChatEvent::ContextFull => { - self.chat.commit_pending(); - self.chat_busy = false; - self.set_chat_running(cx, false); - self.chat.push( - ChatVoice::Info, - "⚠ this conversation has filled the model's context — reopen the app to start a fresh one", - ); - self.set_chat_status(cx, "context full"); - self.redraw_chat(cx); - } - } - } - - // ------------------------------------------------------- the map's tools - - /// The map strip's buttons. They act on the picked rectangle through - /// exactly the paths the keyboard and the context menu already use — the - /// permanent delete included, which still asks once and acts on the second - /// press. - fn handle_map_tool_actions(&mut self, cx: &mut Cx, actions: &Actions) { - if !self.tabs[self.tab].mode.is_treemap() { - return; - } - for (id, projection) in PROJ_BUTTONS { - if self.ui.view(cx, id).finger_down(actions).is_some() { - return self.set_projection_choice(cx, projection); - } - } - if self.ui.view(cx, ids!(map_rescan)).finger_down(actions).is_some() { - return self.rescan_map(cx); - } - if self.ui.view(cx, ids!(map_filter)).finger_down(actions).is_some() { - let open = !self.filter_popup_open; - return self.set_filter_popup(cx, open); - } - if let Some(ignore) = self.ui.check_box(cx, ids!(map_scan_all)).changed(actions) { - // Checked means today's behaviour: leave the system folders out. - crate::model::set_scan_all(!ignore); - self.status( - cx, - if ignore { - "System folders excluded again — rescanning" - } else { - "Measuring system folders too — macOS will ask permission per folder" - }, - ); - self.with_contents(cx, |contents, cx| contents.treemap(cx).remap(cx)); - return; - } - let trash = self.ui.view(cx, ids!(map_trash)).finger_down(actions).is_some(); - let erase = self.ui.view(cx, ids!(map_erase)).finger_down(actions).is_some(); - if !trash && !erase { - return; - } - if self.chat_subject(cx).is_none() { - self.status(cx, "Click a rectangle on the map first"); - return; - } - if trash { - self.trash_selection(cx); - } else { - self.delete_forever(cx); - } - } - - // ------------------------------------------------------- the map filter - - fn set_filter_popup(&mut self, cx: &mut Cx, open: bool) { - self.filter_popup_open = open; - model::pref_set("filter_side", if open { "1" } else { "0" }); - self.ui.widget(cx, ids!(map_side)).set_visible( - cx, - open && self.tabs[self.tab].mode.is_treemap(), - ); - if open { - self.refresh_filter_popup(cx); - // The field has no area until the sidebar's first frame; focus - // lands on the frame that gives it one. - self.focus_soon(cx, FocusTarget::Filter); - } - self.ui.redraw(cx); - } - - /// The legend half of the popup: swatches in the map's own hues, live - /// byte totals per kind, heaviest first, zero kinds dimmed but present — - /// it doubles as the map's colour key. - fn refresh_filter_popup(&mut self, cx: &mut Cx) { - let totals = self - .with_contents(cx, |contents, cx| contents.treemap(cx).kind_totals(cx)) - .unwrap_or([0; 16]); - let mut classes: Vec<(usize, u64)> = (0..7) - .map(|class| { - let bytes = class_kind_values(class) - .iter() - .map(|&kind| totals[kind as usize]) - .sum(); - (class, bytes) - }) - .collect(); - classes.sort_by(|a, b| b.1.cmp(&a.1)); - let palette = Palette::shared(); - for (row, &(class, bytes)) in classes.iter().enumerate() { - self.legend_rows[row] = class; - let mut widget = self.ui.widget(cx, FILTER_KIND_IDS[row]); - let selected = self.filter_kinds[class]; - let swatch = palette.kind_color(class); - let row_bg = if selected { - let mut tint = Palette::vec4(&palette.accent); - tint.w = 0.22; - tint - } else { - Vec4f::default() - }; - let ink = if bytes == 0 && !selected { - Palette::vec4(&palette.fg_dim) - } else { - Palette::vec4(&palette.fg) - }; - script_apply_eval!(cx, widget, { - draw_bg +: { color: #(row_bg) } - }); - let mut swatch_view = widget.widget(cx, ids!(lg_swatch)); - script_apply_eval!(cx, swatch_view, { - draw_bg +: { color: #(swatch) } - }); - let mut name = widget.label(cx, ids!(lg_name)); - name.set_text(cx, CLASS_NAMES[class]); - script_apply_eval!(cx, name, { - draw_text +: { color: #(ink) } - }); - widget - .label(cx, ids!(lg_bytes)) - .set_text(cx, &treemap::format_bytes(bytes)); - } - self.style_filter_age(cx); - } - - fn style_filter_age(&mut self, cx: &mut Cx) { - let palette = Palette::shared(); - for (index, id) in FILTER_AGE_IDS.iter().enumerate() { - let mut widget = self.ui.widget(cx, id); - let on = index == self.filter_age; - let bg = if on { - let mut tint = Palette::vec4(&palette.accent); - tint.w = 0.22; - tint - } else { - Vec4f::default() - }; - let ink = if on { - Palette::vec4(&palette.fg_bright) - } else { - Palette::vec4(&palette.fg_dim) - }; - script_apply_eval!(cx, widget, { - draw_bg +: { color: #(bg) } - }); - let mut label = widget.label(cx, ids!(chip_label)); - script_apply_eval!(cx, label, { - draw_text +: { color: #(ink) } - }); - } - } - - /// Everything the popup says, folded into one query and applied live. - fn rebuild_filter(&mut self, cx: &mut Cx) { - let text = self.ui.text_input(cx, ids!(filter_query)).text(); - let now_min = now_minutes(); - let mut query = treemap::Query::parse(&text, now_min); - let slid = self.ui.slider(cx, ids!(filter_size)).value().unwrap_or(0.0); - match slider_bytes(slid) { - Some(bytes) => { - query.min_size = Some(query.min_size.map_or(bytes, |q| q.max(bytes))); - self.ui.label(cx, ids!(filter_size_label)).set_text( - cx, - &format!("bigger than {}", treemap::format_bytes(bytes)), - ); - } - None => { - self.ui - .label(cx, ids!(filter_size_label)) - .set_text(cx, "any size"); - } - } - if self.filter_age > 0 { - let cutoff = now_min.saturating_sub(AGE_MINUTES[self.filter_age]); - query.newer_than = Some(query.newer_than.map_or(cutoff, |q| q.max(cutoff))); - } - if self.filter_kinds.iter().any(|&on| on) { - let mask = (0..7) - .filter(|&class| self.filter_kinds[class]) - .fold(0u16, |mask, class| mask | class_kinds_mask(class)); - query.kinds = Some(mask); - } - self.with_contents(cx, |contents, cx| { - contents.treemap(cx).set_filter(cx, Some(query)); - }); - } - - /// Show every control cleared — the map itself is already unfiltered. - fn reset_filter_controls(&mut self, cx: &mut Cx) { - self.filter_age = 0; - self.filter_kinds = [false; 7]; - self.ui.text_input(cx, ids!(filter_query)).set_text(cx, ""); - self.ui.slider(cx, ids!(filter_size)).set_value(cx, 0.0); - self.ui - .label(cx, ids!(filter_size_label)) - .set_text(cx, "any size"); - if self.filter_popup_open { - self.refresh_filter_popup(cx); - } - } - - fn handle_filter_actions(&mut self, cx: &mut Cx, actions: &Actions) { - if !self.filter_popup_open { - return; - } - let mut dirty = false; - if self - .ui - .text_input(cx, ids!(filter_query)) - .changed(actions) - .is_some() - { - dirty = true; - } - if self.ui.slider(cx, ids!(filter_size)).slided(actions).is_some() { - dirty = true; - } - for (index, id) in FILTER_AGE_IDS.iter().enumerate() { - if self.ui.view(cx, id).finger_down(actions).is_some() { - self.filter_age = index; - self.style_filter_age(cx); - dirty = true; - } - } - for row in 0..FILTER_KIND_IDS.len() { - if self - .ui - .view(cx, FILTER_KIND_IDS[row]) - .finger_down(actions) - .is_some() - { - let class = self.legend_rows[row]; - self.filter_kinds[class] = !self.filter_kinds[class]; - self.refresh_filter_popup(cx); - dirty = true; - } - } - if self.ui.view(cx, ids!(filter_clear)).finger_down(actions).is_some() { - self.reset_filter_controls(cx); - self.with_contents(cx, |contents, cx| { - contents.treemap(cx).set_filter(cx, None); - }); - return; - } - if dirty { - self.rebuild_filter(cx); - } - } -} - -/// The filter popup's row slots. -const FILTER_AGE_IDS: [&[LiveId]; 6] = [ - ids!(filter_age0), - ids!(filter_age1), - ids!(filter_age2), - ids!(filter_age3), - ids!(filter_age4), - ids!(filter_age5), -]; -const FILTER_KIND_IDS: [&[LiveId]; 7] = [ - ids!(filter_kind0), - ids!(filter_kind1), - ids!(filter_kind2), - ids!(filter_kind3), - ids!(filter_kind4), - ids!(filter_kind5), - ids!(filter_kind6), -]; -/// "modified within", in minutes; index 0 is "any age". -const AGE_MINUTES: [u32; 6] = [0, 1_440, 4_320, 10_080, 43_200, 525_600]; -const CLASS_NAMES: [&str; 7] = - ["Video", "Images", "Audio", "Code", "Docs", "Archives", "Other"]; - -/// The `FileKind`s behind one legend class — the exact inverse of -/// `treemap_view::kind_class`, asserted so in a test below. -fn class_kind_values(class: usize) -> &'static [crate::model::FileKind] { - use crate::model::FileKind::*; - match class { - 0 => &[Video], - 1 => &[Image], - 2 => &[Audio], - 3 => &[Code], - 4 => &[Text, Pdf], - 5 => &[Archive], - _ => &[Generic, Folder], - } -} - -fn class_kinds_mask(class: usize) -> u16 { - class_kind_values(class) - .iter() - .fold(0u16, |mask, &kind| mask | 1 << (kind as u16)) -} - -/// The size slider's sweep: off at the left edge, then a logarithmic run -/// from 1 KB to 10 GB — the range disk questions actually live in. -fn slider_bytes(value: f64) -> Option { - if value <= 0.02 { - return None; - } - let t = ((value - 0.02) / 0.98).clamp(0.0, 1.0); - Some((1_000.0 * 10f64.powf(7.0 * t)) as u64) -} - -/// Now, in whole minutes since the epoch — the clock the age filter runs on. -fn now_minutes() -> u32 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| (d.as_secs() / 60).min(u32::MAX as u64) as u32) - .unwrap_or(0) -} - -/// How many times the model may go round the look-then-think loop for one -/// question before it has to answer with what it has. -const MAX_TOOL_ROUNDS: usize = 6; - -/// One path, as a sentence: what it is and how big. Reads off the disk, so it -/// is the truth at the moment it is asked rather than whatever a listing -/// remembered. -fn describe_path(path: &Path) -> String { - match model::entry_at(path) { - Some(entry) => format!( - "{} — {}, {}", - path.display(), - entry.kind_text(), - entry.size_text() - ), - None => path.display().to_string(), - } -} - -/// What the model is told it is, once, in front of everything else. -const CHAT_SYSTEM_PROMPT: &str = "\ -You are the assistant inside mpfiles, a file browser. You answer questions \ -about the files the person is looking at right now. - -Every question arrives behind a [where the user is] block: the folder they \ -have open and what they have selected. \"this\", \"it\", \"here\" and \"that\" \ -mean whatever is selected — and the folder itself when nothing is. - -You can only look. list_dir, read_file, stat and treemap_summary are all you \ -have. There is nothing that writes, moves, renames or deletes, so if you are \ -asked to change something, say plainly that you cannot and tell them what to \ -click instead. - -Look before you answer. Never guess what a folder holds or how big it is: \ -call a tool and say what it said. One or two calls is usually enough, and \ -treemap_summary is the one that answers \"what is taking up the space\". - -Answer in a couple of short sentences, or a short list. Sizes in human units. \ -No markdown headings and no preamble — say the thing."; - -impl MatchEvent for App { - fn handle_startup(&mut self, cx: &mut Cx) { - // Checked once: a warm-pool instance stays dormant until - // `WmEvent::Adopted` or a real input wakes it (see `Dormancy`). - self.dormancy = Dormancy::start(mp_wm_api::warm_start()); - // The scan-scope checkbox shows the saved choice from the first - // frame; checked means the system folders stay out. - self.ui - .check_box(cx, ids!(map_scan_all)) - .set_active(cx, !crate::model::scan_all(), Animate::No); - // The block view's saved rendering and whether its filter sidebar - // was left open — both come back exactly as they were left. - self.projection = match model::pref_get("projection").as_deref() { - Some("ortho") => MapProjection::Ortho, - Some("persp") => MapProjection::Persp, - _ => MapProjection::Flat, - }; - self.filter_popup_open = model::pref_get("filter_side").as_deref() == Some("1"); - self.style_projection_buttons(cx); - // `--demo` browses a home that does not exist, so a screen recording - // can show every feature of this app without showing anybody's disk. - // It is chosen before anything reads a path, and never afterwards. - if vfs::demo_requested() { - vfs::install(Arc::new(demo::DemoVfs::new())); - } - let (sender, receiver) = mpsc::channel(); - self.sender = Some(sender); - self.receiver = Some(receiver); - let (size_sender, size_receiver) = mpsc::channel(); - self.size_sender = Some(size_sender); - self.size_receiver = Some(size_receiver); - self.ops = Some(Ops::new(Box::new(SignalToUI::set_ui_signal))); - self.home = vfs().home(); - // The demo must not write to the real home, so its bookmarks live and - // die with the window. - self.bookmarks = if vfs::is_demo() { - Bookmarks::in_memory(Vec::new()) - } else { - Bookmarks::load(&self.home) - }; - if vfs::is_demo() { - // Say so where it cannot be missed: a recording of the demo must - // never be mistaken for a recording of somebody's files. - self.ui.label(cx, ids!(files_title)).set_text(cx, "Files · Demo"); - } - let palette = Palette::shared(); - let colors = contents::Colors { - dim: Palette::vec4(&palette.fg_dim), - selection: Palette::vec4(&palette.sel), - }; - self.with_contents(cx, |contents, cx| { - contents.set_colors(cx, colors); - contents.set_zoom(cx, DEFAULT_ZOOM); - }); - // An explicit folder argument wins over Home; mpwm passes none. - let start = std::env::args() - .skip(1) - .find(|a| !a.starts_with('-')) - .map(PathBuf::from) - .filter(|p| vfs().is_dir(p)) - .unwrap_or_else(|| self.home.clone()); - self.tabs = vec![Tab::new(start, ViewMode::Icons)]; - self.tab = 0; - // Warm and still dormant: no disk scan and no thumbnails until - // `wake` runs it — see `Dormancy`. - if self.dormancy.is_dormant() { - log!("mpfiles: warm-start dormant, deferring the initial scan"); - } else { - self.enter_tab(cx); - } - } - - fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { - if self.menu_open && self.handle_menu_actions(cx, actions) { - return; - } - if self.ui.view(cx, ids!(back_button)).finger_down(actions).is_some() { - self.go_back(cx); - } - if self - .ui - .view(cx, ids!(forward_button)) - .finger_down(actions) - .is_some() - { - self.go_forward(cx); - } - if self.ui.view(cx, ids!(search_button)).finger_down(actions).is_some() { - self.set_search(cx, !self.search_visible); - } - if self - .ui - .view(cx, ids!(preview_button)) - .finger_down(actions) - .is_some() - { - self.toggle_preview(cx); - } - if self - .ui - .view(cx, ids!(terminal_button)) - .finger_down(actions) - .is_some() - { - self.open_terminal(cx); - } - if self - .ui - .view(cx, ids!(newfolder_button)) - .finger_down(actions) - .is_some() - { - self.new_folder(cx); - } - if self.ui.view(cx, ids!(props_button)).finger_down(actions).is_some() { - let open = !self.props_open; - self.set_props(cx, open); - } - if self.ui.view(cx, ids!(props_close)).finger_down(actions).is_some() { - self.set_props(cx, false); - } - - // ---- the ask panel - if self.ui.view(cx, ids!(chat_button)).finger_down(actions).is_some() { - self.toggle_chat(cx); - } - if self.chat_open { - if self.ui.view(cx, ids!(chat_close)).finger_down(actions).is_some() { - self.toggle_chat(cx); - return; - } - if self.ui.view(cx, ids!(chat_stop)).finger_down(actions).is_some() { - self.stop_chat(cx); - return; - } - let field = self.ui.text_input(cx, ids!(chat_input)); - let returned = field.returned(actions).is_some(); - drop(field); - if returned || self.ui.view(cx, ids!(chat_send)).finger_down(actions).is_some() { - self.send_chat(cx); - return; - } - } - self.handle_map_tool_actions(cx, actions); - self.handle_filter_actions(cx, actions); - for (id, mode) in MODE_BUTTONS { - if self.ui.view(cx, id).finger_down(actions).is_some() { - self.set_mode(cx, mode); - } - } - if self.ui.view(cx, ids!(menu_button)).finger_down(actions).is_some() { - let open = !self.column_menu_open; - self.set_column_menu(cx, open); - let thumbs = self - .with_contents(cx, |contents, _| contents.thumbs_resident()) - .unwrap_or(0); - let undo = self - .journal - .peek() - .map(|u| format!("Cmd+Z {}", u.describe().to_lowercase())) - .unwrap_or_else(|| "Cmd+Z undo".to_string()); - self.status( - cx, - &format!( - "Cmd+1/2/3/4 views · Cmd+T tab · Ctrl+L path · Cmd+D bookmark · F2 rename · Cmd+C/X/V · Cmd+Delete trash · {undo} · Cmd+I info · {thumbs} thumbnails cached" - ), - ); - } - if self.column_menu_open { - for (id, column) in COLUMN_ROWS { - if self.ui.view(cx, id).finger_down(actions).is_some() { - self.with_contents(cx, |contents, cx| contents.toggle_column(cx, column)); - self.refresh_column_menu(cx); - self.report(cx); - return; - } - } - if self.ui.view(cx, ids!(column_menu)).finger_down(actions).is_some() { - self.set_column_menu(cx, false); - } - } - if self.ui.view(cx, ids!(quick_look)).finger_down(actions).is_some() { - self.close_preview(cx); - } - - // ---- tabs - for (index, id) in TAB_IDS.iter().enumerate() { - if index >= self.tabs.len() { - continue; - } - let item = self.ui.view(cx, *id); - let on_button = item.view(cx, ids!(tab_close)).finger_down(actions).is_some(); - let row_press = item.finger_down(actions); - if !on_button && row_press.is_none() { - continue; - } - let on_close = on_button - || row_press - .map(|press| self.pressed_on(cx, *id, ids!(tab_close), press.abs)) - .unwrap_or(false); - self.tab = index; - if on_close { - self.close_tab(cx); - } else { - self.enter_tab(cx); - } - return; - } - - // ---- progress row - if self - .ui - .view(cx, ids!(progress_cancel)) - .finger_down(actions) - .is_some() - { - if let (Some(engine), Some(id)) = (self.ops.as_ref(), self.active_op) { - engine.cancel(id); - self.status(cx, "Stopping…"); - } - } - - // ---- batch dialog - if self.batch_open { - if self.ui.view(cx, ids!(batch_cancel)).finger_down(actions).is_some() { - return self.close_batch(cx); - } - if self.ui.view(cx, ids!(batch_apply)).finger_down(actions).is_some() { - return self.apply_batch(cx); - } - let mut changed = false; - for id in [ids!(batch_find), ids!(batch_replace), ids!(batch_pattern)] { - let field = self.ui.view(cx, id).text_input(cx, ids!(field_input)); - if field.changed(actions).is_some() { - changed = true; - } - if field.returned(actions).is_some() { - return self.apply_batch(cx); - } - } - if changed { - self.refresh_batch_preview(cx); - } - } - - // ---- sidebar places - for (id, name) in PLACES { - if self.ui.view(cx, id).finger_down(actions).is_some() { - let path = self.place_path(name); - self.navigate(cx, path, true); - break; - } - } - - // ---- sidebar bookmarks - let marks: Vec = self.bookmarks.list().to_vec(); - for (index, id) in BOOKMARK_IDS.iter().enumerate() { - let Some(path) = marks.get(index) else { - break; - }; - let item = self.ui.view(cx, *id); - if item.finger_hover_in(actions).is_some() && self.hovered_bookmark != Some(index) { - self.hovered_bookmark = Some(index); - self.refresh_bookmarks(cx); - } - if let Some(left) = item.finger_hover_out(actions) { - // Moving onto the remove button *is* a hover-out of the row — - // hover belongs to one area at a time. Clearing on that would - // hide the button the moment it was aimed at, so the row keeps - // its hover until the pointer leaves the row itself. - let row = self.ui.view(cx, *id).area().rect(cx); - if !row.contains(left.abs) && self.hovered_bookmark == Some(index) { - self.hovered_bookmark = None; - self.refresh_bookmarks(cx); - } - } - // Whichever of the two saw the press: the button when it is - // visible and takes the capture, the row otherwise — and then its - // position says which was meant. - let on_button = item.view(cx, ids!(bm_remove)).finger_down(actions).is_some(); - let row_press = item.finger_down(actions); - if !on_button && row_press.is_none() { - continue; - } - let path = path.clone(); - let on_remove = on_button - || (self.hovered_bookmark == Some(index) - && row_press - .map(|press| self.pressed_on(cx, *id, ids!(bm_remove), press.abs)) - .unwrap_or(false)); - if on_remove { - let name = display_name(&path); - self.bookmarks.remove(&path); - self.hovered_bookmark = None; - self.refresh_bookmarks(cx); - self.status(cx, &format!("Removed the {name} bookmark")); - } else { - self.navigate(cx, path, true); - } - return; - } - - // ---- path bar: a crumb navigates, the empty space opens the editor - let widget = self.ui.widget(cx, ids!(breadcrumbs)); - let crumb = widget - .borrow::() - .and_then(|breadcrumbs| breadcrumbs.clicked_path(cx, actions)); - if let Some(path) = crumb { - self.navigate(cx, path, true); - } else if !self.search_visible - && self.ui.view(cx, ids!(crumb_box)).finger_down(actions).is_some() - { - self.set_path_edit(cx, true); - } - let path_field = self.ui.text_input(cx, ids!(path_edit)); - if let Some((text, _)) = path_field.returned(actions) { - self.commit_path_edit(cx, &text); - } - if path_field.escaped(actions) && self.path_edit_open { - self.set_path_edit(cx, false); - } - - if let Some(filter) = self.ui.text_input(cx, ids!(search_input)).changed(actions) { - self.with_contents(cx, |contents, cx| contents.set_filter(cx, filter)); - let shown = self.with_contents(cx, |contents, _| (contents.len(), contents.total())); - if let Some((shown, total)) = shown { - self.ui - .label(cx, ids!(item_count)) - .set_text(cx, &format!("{} of {} items", shown, total)); - } - } - - let body = self - .with_contents(cx, |contents, cx| contents.handle_actions(cx, actions)) - .unwrap_or_default(); - for action in body { - self.handle_contents_action(cx, action); - } - } -} - -impl AppMain for App { - fn script_mod(vm: &mut ScriptVm) -> ScriptValue { - crate::makepad_widgets::script_mod(vm); - // The WM's theme, first into the stock widgets and then into `mod.mpf` - // for our own chrome — both before anything reads a color. - mp_theme::apply(vm); - Palette::shared().publish(vm); - crate::theme::script_mod(vm); - crate::thumbs::script_mod(vm); - crate::treemap_view::script_mod(vm); - crate::contents::script_mod(vm); - crate::chat_panel::script_mod(vm); - self::script_mod(vm) - } - - fn handle_event(&mut self, cx: &mut Cx, event: &Event) { - self.match_event(cx, event); - // Defensive fallback: a lost `Adopted` message must not leave a - // visibly-adopted, actually-being-used instance dark and unscanned. - if self.dormancy.is_dormant() && is_wake_input(event) { - self.wake(cx); - } - // A press anywhere outside the context menu's cards closes it. The - // raw event, on purpose: the widgets underneath swallow presses - // differently in every view, and the full-window overlay the menu - // sits in has no background of its own, so it never hits — waiting - // for a bubbled press is how the menu got stuck open. - if let Event::MouseDown(press) = event { - if self.menu_open { - let card = self.ui.view(cx, ids!(ctx_panel)).area().rect(cx); - let sub = self.ui.view(cx, ids!(ctx_sub_panel)).area().rect(cx); - let inside = card.contains(press.abs) - || (self.submenu_open && sub.contains(press.abs)); - if !inside { - self.close_menu(cx); - } - } - } - if let Event::Signal = event { - self.drain_directory_results(cx); - self.drain_ops(cx); - self.drain_sizes(cx); - let map_moved = self - .with_contents(cx, |contents, cx| { - contents.drain_thumbs(cx); - contents.treemap(cx).drain(cx) - }) - .unwrap_or(false); - if self.tabs.get(self.tab).is_some_and(|t| t.mode.is_treemap()) { - self.report(cx); - // The legend's byte totals follow the scan in — this is also - // what fills a sidebar that came back open from the prefs, - // which otherwise sat as bare swatches until the first toggle. - if map_moved && self.filter_popup_open { - self.refresh_filter_popup(cx); - } - } - self.drain_chat(cx); - self.preview.poll(); - } - if let Event::Custom(json) = event { - if let Some(wm) = mp_wm_api::WmEvent::parse(json) { - self.handle_wm_event(cx, &wm); - } - } - if self.focus_next.is_event(event).is_some() { - self.apply_focus(cx); - } - if let Event::KeyDown(key) = event { - self.handle_key(cx, key); - } - // The transcript draws from the chat state, so it rides down the tree - // as the scope — every other widget in this window ignores it. - self.ui - .handle_event(cx, event, &mut Scope::with_data(&mut self.chat)); - } -} - -#[cfg(test)] -mod dormancy_tests { - use super::*; - - #[test] - fn non_warm_starts_active() { - let dormancy = Dormancy::start(false); - assert_eq!(dormancy, Dormancy::Active); - assert!(!dormancy.is_dormant()); - } - - #[test] - fn warm_starts_dormant_and_adopted_wakes_exactly_once() { - let mut dormancy = Dormancy::start(true); - assert!(dormancy.is_dormant()); - // Adopted wakes it... - assert!(dormancy.wake()); - assert!(!dormancy.is_dormant()); - assert_eq!(dormancy, Dormancy::Woken); - // ...and a second Adopted (or a stray input) never fires again. - assert!(!dormancy.wake()); - assert_eq!(dormancy, Dormancy::Woken); - } - - #[test] - fn waking_an_already_active_instance_is_a_no_op() { - let mut dormancy = Dormancy::start(false); - assert!(!dormancy.wake()); - assert_eq!(dormancy, Dormancy::Active); - } - - #[test] - fn key_and_pointer_events_are_wake_input() { - assert!(is_wake_input(&Event::KeyDown(KeyEvent::default()))); - assert!(is_wake_input(&Event::MouseDown(MouseDownEvent { - abs: dvec2(0.0, 0.0), - button: MouseButton::PRIMARY, - window_id: WindowId(0, 0), - modifiers: KeyModifiers::default(), - handled: std::cell::Cell::new(Area::default()), - time: 0.0, - }))); - // Touch ("finger") input wakes it too — same match arm as the mouse - // and keyboard cases above; `TouchUpdateEvent` is not part of the - // widgets crate's public re-export surface so it is not - // constructible from an app crate's test. - // A timer tick or a signal drain is not a human touching the app. - assert!(!is_wake_input(&Event::Signal)); - } - - #[test] - fn input_wakes_a_dormant_instance_the_same_as_adopted() { - let mut dormancy = Dormancy::start(true); - assert!(is_wake_input(&Event::KeyDown(KeyEvent::default()))); - assert!(dormancy.wake()); - assert!(!dormancy.is_dormant()); - } - - // The legend's classes and the map's kind_class must be exact inverses, - // or a chip would tint tiles it cannot filter. - #[test] - fn every_kind_belongs_to_the_class_that_claims_it() { - use crate::model::FileKind; - for kind in [ - FileKind::Folder, - FileKind::Image, - FileKind::Text, - FileKind::Code, - FileKind::Audio, - FileKind::Video, - FileKind::Archive, - FileKind::Pdf, - FileKind::Generic, - ] { - let class = crate::treemap_view::kind_class(kind) as usize; - assert!( - class_kind_values(class).contains(&kind), - "{kind:?} paints as class {class} but the legend chip for it filters {:?}", - class_kind_values(class), - ); - assert!(class_kinds_mask(class) & (1 << (kind as u16)) != 0); - } - } -} diff --git a/apps/mpfiles/src/menu.rs b/apps/mpfiles/src/menu.rs deleted file mode 100644 index 158b3171e..000000000 --- a/apps/mpfiles/src/menu.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! The context menu's contents. -//! -//! The menu is data, not layout: this module says which rows exist for a given -//! situation and what each one does, and the shell draws exactly that. Keeping -//! it here is what lets the rule "every row does a real thing" be a test rather -//! than a promise — a row can only exist if it names a [`MenuAction`], and -//! every action is dispatched in one `match` the compiler checks. - -use std::path::Path; - -use crate::contents::ViewMode; - -/// Everything the context menu can ask for. There is nothing here that the -/// shell does not do. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum MenuAction { - Open, - /// Opens the submenu of apps rather than doing something itself. - OpenWith, - Preview, - NewFolder, - Rename, - Duplicate, - Copy, - Cut, - Paste, - SelectAll, - Trash, - DeleteForever, - RevealInTreemap, - Properties, - OpenInTerminal, - ShowHidden, - SetMode(ViewMode), - /// One app from the Open With submenu, by its index in - /// [`open_with_apps`]'s answer. - OpenWithApp(usize), -} - -/// One row of the menu. -#[derive(Clone, Debug, PartialEq)] -pub struct MenuRow { - pub action: MenuAction, - pub label: String, - /// The keyboard shortcut that does the same thing, shown greyed on the - /// right. Empty when there is none. - pub hint: &'static str, - /// Drawn in the theme's warning color, because it cannot be undone. - pub danger: bool, - /// This row opens a submenu instead of acting. - pub submenu: bool, - /// A hairline above this row. - pub separator: bool, -} - -impl MenuRow { - fn new(action: MenuAction, label: &str, hint: &'static str) -> Self { - Self { - action, - label: label.to_string(), - hint, - danger: false, - submenu: false, - separator: false, - } - } - - fn sep(mut self) -> Self { - self.separator = true; - self - } - - fn danger(mut self) -> Self { - self.danger = true; - self - } - - fn submenu(mut self) -> Self { - self.submenu = true; - self - } -} - -/// The most rows any menu here has. The shell carries this many slots. -pub const MAX_ROWS: usize = 14; -/// The most apps the Open With submenu offers. -pub const MAX_APPS: usize = 4; - -/// The menu for a selection. `count` is how many things are selected and -/// `folder` whether the one under the pointer is a directory. -pub fn entry_menu(count: usize, folder: bool) -> Vec { - let many = count > 1; - let mut rows = vec![ - MenuRow::new( - MenuAction::Open, - if folder { "Open Folder" } else { "Open" }, - "Enter", - ), - ]; - // Only one file at a time can be handed to a chosen app, and only a file - // has a viewer to choose. - if !many && !folder { - rows.push(MenuRow::new(MenuAction::OpenWith, "Open With", "").submenu()); - rows.push(MenuRow::new(MenuAction::Preview, "Preview", "Space")); - } - rows.push(MenuRow::new(MenuAction::NewFolder, "New Folder", "⇧⌘N").sep()); - rows.push(MenuRow::new( - MenuAction::Rename, - if many { "Rename…" } else { "Rename" }, - "F2", - )); - rows.push(MenuRow::new(MenuAction::Duplicate, "Duplicate", "")); - rows.push(MenuRow::new(MenuAction::Copy, "Copy", "⌘C")); - rows.push(MenuRow::new(MenuAction::Cut, "Cut", "⌘X")); - rows.push(MenuRow::new(MenuAction::Trash, "Move to Trash", "⌘Del").sep()); - rows.push( - MenuRow::new(MenuAction::DeleteForever, "Delete Permanently", "⇧Del") - .danger(), - ); - rows.push(MenuRow::new(MenuAction::RevealInTreemap, "Reveal in Treemap", "").sep()); - rows.push(MenuRow::new(MenuAction::Properties, "Properties", "⌘I")); - rows.push(MenuRow::new( - MenuAction::OpenInTerminal, - "Open in Terminal", - "", - )); - rows -} - -/// The menu for the empty space of a folder. -pub fn empty_menu(mode: ViewMode, clipboard: usize, show_hidden: bool) -> Vec { - let mut rows = vec![MenuRow::new(MenuAction::NewFolder, "New Folder", "⇧⌘N")]; - // Paste is only a row when there is something to paste: a row that does - // nothing is worse than no row. - if clipboard > 0 { - rows.push(MenuRow::new( - MenuAction::Paste, - &format!( - "Paste {} item{}", - clipboard, - if clipboard == 1 { "" } else { "s" } - ), - "⌘V", - )); - } - rows.push(MenuRow::new(MenuAction::SelectAll, "Select All", "⌘A")); - for (index, view) in [ - ViewMode::Icons, - ViewMode::List, - ViewMode::Compact, - ViewMode::Treemap, - ] - .into_iter() - .enumerate() - { - let hint = ["⌘1", "⌘2", "⌘3", "⌘4"][index]; - let mark = if view == mode { "• " } else { " " }; - let mut row = MenuRow::new( - MenuAction::SetMode(view), - &format!("{mark}{}", view.label()), - hint, - ); - if index == 0 { - row = row.sep(); - } - rows.push(row); - } - rows.push( - MenuRow::new( - MenuAction::ShowHidden, - if show_hidden { - "Hide Hidden Files" - } else { - "Show Hidden Files" - }, - "⌃H", - ) - .sep(), - ); - rows -} - -/// The apps offered for one file, in the order the submenu lists them: the -/// association first, then the terminal's pager, then the desktop's own -/// opener. `available` decides whether a sibling binary is actually there — -/// an app that cannot run is not offered. -pub fn open_with_apps(path: &Path, available: &dyn Fn(&str) -> bool) -> Vec<(String, String)> { - let mut out: Vec<(String, String)> = Vec::new(); - let primary = mp_wm_api::viewer_for(path); - if available(primary) { - out.push((primary.to_string(), format!("Open with {primary}"))); - } - if primary != "mpterm" && available("mpterm") { - out.push(("mpterm".to_string(), "Open in the terminal pager".to_string())); - } - // The desktop's own opener always exists; it is the honest last resort. - out.push(( - String::new(), - "Open with the desktop default".to_string(), - )); - out.truncate(MAX_APPS); - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn every_menu_fits_the_slots_the_shell_carries() { - assert!(entry_menu(1, false).len() <= MAX_ROWS); - assert!(entry_menu(1, true).len() <= MAX_ROWS); - assert!(entry_menu(9, false).len() <= MAX_ROWS); - assert!(empty_menu(ViewMode::Icons, 3, false).len() <= MAX_ROWS); - } - - #[test] - fn a_folder_is_never_offered_a_viewer() { - let rows = entry_menu(1, true); - assert!(!rows.iter().any(|r| r.action == MenuAction::OpenWith)); - assert!(!rows.iter().any(|r| r.action == MenuAction::Preview)); - assert_eq!(rows[0].label, "Open Folder"); - } - - #[test] - fn a_multiple_selection_drops_the_single_file_rows() { - let rows = entry_menu(4, false); - assert!(!rows.iter().any(|r| r.action == MenuAction::OpenWith)); - // …but keeps everything that works on a set. - for action in [ - MenuAction::Copy, - MenuAction::Cut, - MenuAction::Trash, - MenuAction::DeleteForever, - MenuAction::Duplicate, - ] { - assert!(rows.iter().any(|r| r.action == action), "{action:?}"); - } - assert_eq!( - rows.iter().find(|r| r.action == MenuAction::Rename).unwrap().label, - "Rename…" - ); - } - - #[test] - fn only_the_permanent_delete_is_dangerous() { - let dangerous: Vec = entry_menu(1, false) - .into_iter() - .filter(|r| r.danger) - .map(|r| r.action) - .collect(); - assert_eq!(dangerous, [MenuAction::DeleteForever]); - } - - #[test] - fn paste_appears_only_when_there_is_something_to_paste() { - let empty = empty_menu(ViewMode::Icons, 0, false); - assert!(!empty.iter().any(|r| r.action == MenuAction::Paste)); - let full = empty_menu(ViewMode::Icons, 2, false); - let paste = full.iter().find(|r| r.action == MenuAction::Paste).unwrap(); - assert_eq!(paste.label, "Paste 2 items"); - } - - #[test] - fn the_empty_menu_marks_the_view_it_is_in() { - let rows = empty_menu(ViewMode::Treemap, 0, false); - let marked: Vec<&str> = rows - .iter() - .filter(|r| r.label.starts_with('•')) - .map(|r| r.label.as_str()) - .collect(); - assert_eq!(marked, ["• Treemap"]); - // And the hidden-files row says what pressing it will do. - assert!(rows.iter().any(|r| r.label == "Show Hidden Files")); - assert!(empty_menu(ViewMode::Icons, 0, true) - .iter() - .any(|r| r.label == "Hide Hidden Files")); - } - - #[test] - fn open_with_offers_only_apps_that_exist() { - let none = |_: &str| false; - let all = |_: &str| true; - let picture = Path::new("/a/x.png"); - let offered = open_with_apps(picture, &all); - assert_eq!(offered[0].0, "mpimage"); - assert_eq!(offered[1].0, "mpterm"); - // The desktop opener is the last resort and has no binary of its own. - assert!(offered.last().unwrap().0.is_empty()); - // With nothing built, only the desktop opener is left. - assert_eq!(open_with_apps(picture, &none).len(), 1); - // A text file's association *is* the pager, so it is not listed twice. - let ids: Vec = open_with_apps(Path::new("/a/n.txt"), &all) - .into_iter() - .map(|(id, _)| id) - .collect(); - assert_eq!(ids, ["mpterm", ""]); - assert!(offered.len() <= MAX_APPS); - } - - #[test] - fn separators_never_start_a_menu() { - for rows in [ - entry_menu(1, false), - entry_menu(1, true), - empty_menu(ViewMode::List, 1, false), - ] { - assert!(!rows[0].separator); - // …and no row is a separator with nothing to separate it from. - assert!(rows.iter().skip(1).any(|r| r.separator)); - } - } -} diff --git a/apps/mpfiles/src/model.rs b/apps/mpfiles/src/model.rs deleted file mode 100644 index 46d22d974..000000000 --- a/apps/mpfiles/src/model.rs +++ /dev/null @@ -1,1036 +0,0 @@ -//! The entry model every view shares: what a directory holds, how it sorts, -//! how a file's kind is decided, and which app owns it. -//! -//! Nothing here touches the UI, so all of it is unit-testable. - -use std::{ - fs, - path::{Path, PathBuf}, - time::{Duration, SystemTime}, -}; - -/// What a file *is*, as far as the browser is concerned: it picks the icon, -/// fills the Kind column, and decides whether Space can preview it. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub enum FileKind { - Folder, - Image, - Text, - Code, - Audio, - Video, - Archive, - Pdf, - #[default] - Generic, -} - -impl FileKind { - /// The SVG basename under `resources/icons/`. - pub fn icon_name(self) -> &'static str { - match self { - FileKind::Folder => "folder", - FileKind::Image => "image", - FileKind::Text => "text", - FileKind::Code => "code", - FileKind::Audio => "audio", - FileKind::Video => "video", - FileKind::Archive => "archive", - FileKind::Pdf => "pdf", - FileKind::Generic => "file", - } - } - - /// The word shown in the Kind column. - pub fn label(self) -> &'static str { - match self { - FileKind::Folder => "Folder", - FileKind::Image => "Image", - FileKind::Text => "Text", - FileKind::Code => "Code", - FileKind::Audio => "Audio", - FileKind::Video => "Video", - FileKind::Archive => "Archive", - FileKind::Pdf => "PDF", - FileKind::Generic => "File", - } - } - - /// Kinds the in-app quick look can render as text. - pub fn is_textual(self) -> bool { - matches!(self, FileKind::Text | FileKind::Code) - } -} - -/// Extensions the makepad image cache can decode (`detect_image_format` in -/// `draw/src/image_cache.rs`) — exactly the set that gets a real thumbnail. -pub const IMAGE_EXTS: &[&str] = &[ - "png", "jpg", "jpeg", "webp", "gif", "bmp", "qoi", "ico", -]; - -const CODE_EXTS: &[&str] = &[ - "rs", "c", "h", "cpp", "hpp", "cc", "js", "ts", "tsx", "jsx", "py", "go", "rb", "java", "kt", - "swift", "sh", "zsh", "bash", "fish", "lua", "vim", "toml", "yaml", "yml", "json", "xml", - "html", "css", "scss", "sql", "splash", "glsl", "wgsl", "metal", "m", "mm", "cs", "php", - "pl", "r", "jl", "zig", "nim", "hs", "ml", "ex", "exs", "gradle", "cmake", "mk", -]; - -const TEXT_EXTS: &[&str] = &[ - "txt", "md", "markdown", "log", "csv", "tsv", "ini", "cfg", "conf", "rst", "org", "tex", - "gitignore", "lock", "license", "readme", -]; - -const AUDIO_EXTS: &[&str] = &[ - "mp3", "wav", "flac", "ogg", "oga", "m4a", "aac", "aiff", "aif", "opus", "wma", "mid", - "midi", -]; - -const VIDEO_EXTS: &[&str] = &[ - "mp4", "mov", "mkv", "webm", "avi", "m4v", "wmv", "flv", "mpg", "mpeg", "ts", -]; - -/// The videos the platform decoder demuxes, i.e. the ones that can get a real -/// first-frame thumbnail and an `mpvideo` association. The rest of -/// [`VIDEO_EXTS`] still reads as a video, it just gets the film-strip icon and -/// the desktop's own opener. -pub const PLAYABLE_VIDEO_EXTS: &[&str] = &["mp4", "mov", "m4v", "webm", "mkv", "avi"]; - -const ARCHIVE_EXTS: &[&str] = &[ - "zip", "tar", "gz", "tgz", "bz2", "xz", "zst", "7z", "rar", "dmg", "pkg", "iso", "jar", - "whl", "deb", "rpm", -]; - -// There is no association table here: `mp_wm_api::viewer_for` is the one the -// window manager and the browser share. - -/// Lowercased extension of `path`, or "" when it has none. -pub fn extension_of(path: &Path) -> String { - path.extension() - .map(|e| e.to_string_lossy().to_lowercase()) - .unwrap_or_default() -} - -/// True when the makepad image cache can decode this file. -pub fn is_image_file(path: &Path) -> bool { - IMAGE_EXTS.contains(&extension_of(path).as_str()) -} - -/// True when the platform video decoder can pull a first frame out of it. -pub fn is_playable_video(path: &Path) -> bool { - PLAYABLE_VIDEO_EXTS.contains(&extension_of(path).as_str()) -} - -/// True when this file can get a real thumbnail instead of a type icon. -pub fn is_thumbnailable(path: &Path) -> bool { - is_image_file(path) || is_playable_video(path) -} - -/// Classify a directory entry. -pub fn kind_for(path: &Path, is_dir: bool) -> FileKind { - if is_dir { - return FileKind::Folder; - } - let ext = extension_of(path); - if ext == "pdf" { - return FileKind::Pdf; - } - if IMAGE_EXTS.contains(&ext.as_str()) { - return FileKind::Image; - } - if CODE_EXTS.contains(&ext.as_str()) { - return FileKind::Code; - } - if TEXT_EXTS.contains(&ext.as_str()) { - return FileKind::Text; - } - if AUDIO_EXTS.contains(&ext.as_str()) { - return FileKind::Audio; - } - if VIDEO_EXTS.contains(&ext.as_str()) { - return FileKind::Video; - } - if ARCHIVE_EXTS.contains(&ext.as_str()) { - return FileKind::Archive; - } - // Dotfiles with no extension (.zshrc, .gitconfig) read as text. - if ext.is_empty() && path.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) { - return FileKind::Text; - } - FileKind::Generic -} - -/// One row of a directory listing. -#[derive(Clone, Debug)] -pub struct FileEntry { - pub path: PathBuf, - pub name: String, - pub is_dir: bool, - pub size: u64, - /// Seconds since the epoch; 0 when unknown. The sort key for Modified. - pub modified_secs: u64, - /// Creation (birth) time where the filesystem reports one, else 0. - pub created_secs: u64, - /// The mode as `rwxr-xr-x`, or a read-only/read-write word off unix. - pub permissions: String, - /// Entries inside a folder; `None` when it could not be read (and for - /// files). Counting is bounded — see [`FOLDER_COUNT_CAP`]. - pub child_count: Option, - pub kind: FileKind, -} - -impl FileEntry { - /// The Modified column: an absolute local timestamp, year always. - pub fn modified_text(&self) -> String { - format_stamp(self.modified_secs) - } - - /// The Created column. - pub fn created_text(&self) -> String { - format_stamp(self.created_secs) - } - - /// The Size column: bytes for a file, the item count for a folder. - pub fn size_text(&self) -> String { - match (self.is_dir, self.child_count) { - (true, Some(1)) => "1 item".to_string(), - (true, Some(n)) => format!("{} items", n), - (true, None) => "—".to_string(), - _ => format_size(self.size, false), - } - } - - /// The Kind column: the descriptive name of the file type. - pub fn kind_text(&self) -> String { - kind_label(&self.path, self.is_dir, self.kind) - } -} - -/// Never walk more than this many entries to count a folder — a listing must -/// not turn into a filesystem crawl. -pub const FOLDER_COUNT_CAP: u32 = 50_000; - -/// The descriptive type name for the Kind column: the extension in words when -/// we know it, else the broad kind. -pub fn kind_label(path: &Path, is_dir: bool, kind: FileKind) -> String { - if is_dir { - return "Folder".to_string(); - } - let ext = extension_of(path); - let named = match ext.as_str() { - "png" => "PNG image", - "jpg" | "jpeg" => "JPEG image", - "gif" => "GIF image", - "webp" => "WebP image", - "bmp" => "Bitmap image", - "qoi" => "QOI image", - "ico" => "Icon", - "svg" => "SVG drawing", - "mp4" | "m4v" => "MPEG-4 video", - "mov" => "QuickTime video", - "mkv" => "Matroska video", - "webm" => "WebM video", - "avi" => "AVI video", - "mp3" => "MP3 audio", - "wav" => "WAV audio", - "flac" => "FLAC audio", - "ogg" | "oga" | "opus" => "Ogg audio", - "m4a" | "aac" => "AAC audio", - "pdf" => "PDF document", - "md" | "markdown" => "Markdown text", - "txt" => "Plain text", - "csv" => "CSV table", - "tsv" => "TSV table", - "json" => "JSON data", - "toml" => "TOML data", - "yaml" | "yml" => "YAML data", - "xml" => "XML data", - "html" | "htm" => "HTML page", - "rs" => "Rust source", - "splash" => "Splash source", - "zip" => "ZIP archive", - "tar" => "Tar archive", - "gz" | "tgz" => "Gzip archive", - "dmg" => "Disk image", - "" => return kind.label().to_string(), - _ => "", - }; - if !named.is_empty() { - return named.to_string(); - } - // Unknown extension: name it after itself, with the broad kind behind it. - match kind { - FileKind::Generic => format!("{} file", ext.to_uppercase()), - _ => format!("{} {}", ext.to_uppercase(), kind.label().to_lowercase()), - } -} - -/// Which column the listing is ordered by. Folders come first regardless — -/// that is what a file manager means by "sorted". -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum SortKey { - #[default] - Name, - Size, - Kind, - Modified, - Created, - Permissions, -} - -impl SortKey { - /// Every column the list view can show, in their natural order. Which of - /// them are on screen is the view's business; this is the whole set. - pub const ALL: [SortKey; 6] = [ - SortKey::Name, - SortKey::Size, - SortKey::Kind, - SortKey::Modified, - SortKey::Created, - SortKey::Permissions, - ]; - - /// The column header. - pub fn label(self) -> &'static str { - match self { - SortKey::Name => "Name", - SortKey::Size => "Size", - SortKey::Kind => "Kind", - SortKey::Modified => "Modified", - SortKey::Created => "Created", - SortKey::Permissions => "Permissions", - } - } - - /// A sensible starting width for the column, in points. - pub fn default_width(self) -> f64 { - match self { - SortKey::Name => 320.0, - SortKey::Size => 110.0, - SortKey::Kind => 130.0, - SortKey::Modified | SortKey::Created => 168.0, - SortKey::Permissions => 116.0, - } - } - - /// Numbers and dates read right-aligned; words read left-aligned. - pub fn align(self) -> f64 { - match self { - SortKey::Size => 1.0, - _ => 0.0, - } - } - - /// This column's text for an entry. - pub fn text(self, entry: &FileEntry) -> String { - match self { - SortKey::Name => entry.name.clone(), - SortKey::Size => entry.size_text(), - SortKey::Kind => entry.kind_text(), - SortKey::Modified => entry.modified_text(), - SortKey::Created => entry.created_text(), - SortKey::Permissions => entry.permissions.clone(), - } - } -} - -/// The active ordering. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SortSpec { - pub key: SortKey, - pub ascending: bool, -} - -impl Default for SortSpec { - fn default() -> Self { - Self { - key: SortKey::Name, - ascending: true, - } - } -} - -/// Order `order` (indices into `entries`) by `sort`, folders always first. -pub fn sort_indices(entries: &[FileEntry], order: &mut [usize], sort: SortSpec) { - order.sort_by(|a, b| { - let (l, r) = (&entries[*a], &entries[*b]); - // Folders first, always: a listing that scatters them is unreadable. - let dirs = r.is_dir.cmp(&l.is_dir); - if dirs != std::cmp::Ordering::Equal { - return dirs; - } - let by_key = match sort.key { - SortKey::Name => std::cmp::Ordering::Equal, - // Folders sort by how much they hold, files by their bytes. - SortKey::Size => l - .child_count - .cmp(&r.child_count) - .then_with(|| l.size.cmp(&r.size)), - SortKey::Kind => l.kind_text().cmp(&r.kind_text()), - SortKey::Modified => l.modified_secs.cmp(&r.modified_secs), - SortKey::Created => l.created_secs.cmp(&r.created_secs), - SortKey::Permissions => l.permissions.cmp(&r.permissions), - }; - let by_key = if sort.ascending { - by_key - } else { - by_key.reverse() - }; - if by_key != std::cmp::Ordering::Equal { - return by_key; - } - // Name is the tiebreaker for every key, and reverses with the sort so - // a descending listing is the exact mirror of the ascending one. - let by_name = l - .name - .to_lowercase() - .cmp(&r.name.to_lowercase()) - .then_with(|| l.name.cmp(&r.name)); - if sort.ascending || sort.key != SortKey::Name { - by_name - } else { - by_name.reverse() - } - }); -} - -/// Folder names directly under the user's home that the size map never -/// enters. -/// -/// This is not a taste decision, it is what makes the map usable on macOS at -/// all. `~/Library` is Apple's, not the user's: it is where Containers, Group -/// Containers, Mail, Messages, Safari, CloudStorage and Mobile Documents live, -/// and every one of them is behind a separate TCC grant — walking it means a -/// permission dialog per protected folder, over and over, for bytes the user -/// cannot delete by hand anyway. `~/.Trash` is not the user's files either; -/// it is what they already threw away, and counting it would double every -/// number the moment they trashed something. -/// -/// `MPFILES_SCAN_ALL=1` turns the whole rule off for anyone who wants the -/// literal truth about their home directory and does not mind the dialogs. -const HOME_SKIP: [&str; 2] = ["Library", ".Trash"]; - -/// Whether the size map measures the system folders too. Off by default — -/// the map skips ~/Library and ~/.Trash so macOS never storms the user with -/// permission dialogs — and flipped by the "ignore system" checkbox on the -/// map's tool strip. `MPFILES_SCAN_ALL=1` or a saved preference turns it on -/// at startup; every change is written back so the choice survives launches. -pub fn scan_all() -> bool { - *scan_all_flag().lock().unwrap_or_else(|e| e.into_inner()) -} - -/// Change the scope and remember it. The caller owns triggering the rescan. -pub fn set_scan_all(on: bool) { - *scan_all_flag().lock().unwrap_or_else(|e| e.into_inner()) = on; - pref_set("scan_all", if on { "1" } else { "0" }); -} - -fn scan_all_flag() -> &'static std::sync::Mutex { - static FLAG: std::sync::OnceLock> = std::sync::OnceLock::new(); - FLAG.get_or_init(|| { - if std::env::var_os("MPFILES_SCAN_ALL").is_some_and(|v| v != "0") { - return std::sync::Mutex::new(true); - } - std::sync::Mutex::new(pref_get("scan_all").as_deref() == Some("1")) - }) -} - -/// Where the little `key=value` preference file lives. -fn prefs_path() -> PathBuf { - home_dir().join(".config").join("mpfiles").join("prefs") -} - -/// One saved preference, by key. The file is `key=value` lines, nothing -/// more; a missing file is simply no preferences. -pub fn pref_get(key: &str) -> Option { - let text = std::fs::read_to_string(prefs_path()).ok()?; - pref_find(&text, key) -} - -/// Save one preference, leaving every other key exactly as it was — the -/// file is shared by whatever small choices the app remembers. -pub fn pref_set(key: &str, value: &str) { - let path = prefs_path(); - if let Some(dir) = path.parent() { - let _ = std::fs::create_dir_all(dir); - } - let old = std::fs::read_to_string(&path).unwrap_or_default(); - let _ = std::fs::write(&path, pref_replace(&old, key, value)); -} - -fn pref_find(text: &str, key: &str) -> Option { - text.lines().find_map(|line| { - let (k, v) = line.trim().split_once('=')?; - (k == key).then(|| v.to_string()) - }) -} - -fn pref_replace(old: &str, key: &str, value: &str) -> String { - let mut out = String::new(); - let mut written = false; - for line in old.lines() { - match line.trim().split_once('=') { - Some((k, _)) if k == key => { - if !written { - out.push_str(&format!("{key}={value}\n")); - written = true; - } - } - _ if !line.trim().is_empty() => { - out.push_str(line); - out.push('\n'); - } - _ => {} - } - } - if !written { - out.push_str(&format!("{key}={value}\n")); - } - out -} - -/// True for a folder the size map must not enter. -/// -/// Only ever consulted for directories, and only for the ones directly under -/// the user's home — a `Library` folder inside a project is a project's -/// library and gets measured like anything else. -pub fn skip_for_scan(path: &Path) -> bool { - if scan_all() { - return false; - } - let home = home_dir(); - let Some(parent) = path.parent() else { - return false; - }; - if parent != home { - return false; - } - path.file_name() - .and_then(|n| n.to_str()) - .is_some_and(|name| HOME_SKIP.contains(&name)) -} - -/// What scope the map's numbers were measured under — always said, in both -/// states, so nobody misreads a total. -pub fn scan_exclusions() -> Option { - if scan_all() { - return Some("including system folders".to_string()); - } - Some("excluding Library and Trash".to_string()) -} - -/// One entry, read straight off the disk by path rather than found in a -/// listing. The treemap needs this: nearly everything it draws lives below the -/// folder the browser is listing, and a context menu on a file three folders -/// down has to describe that file, not fail to find a row for it. -/// -/// `None` when there is nothing there, or when the browser is on the demo -/// filesystem — a virtual path has no `std::fs` entry to read, and inventing -/// one would let an operation run against a file that does not exist. -pub fn entry_at(path: &Path) -> Option { - if crate::vfs::is_demo() { - return None; - } - let metadata = fs::metadata(path).ok()?; - let is_dir = metadata.is_dir(); - Some(FileEntry { - name: display_name(path), - kind: kind_for(path, is_dir), - is_dir, - size: if is_dir { 0 } else { metadata.len() }, - modified_secs: epoch_secs(metadata.modified().ok()), - created_secs: epoch_secs(metadata.created().ok()), - permissions: permissions_text(&metadata), - child_count: is_dir.then(|| count_children(path)).flatten(), - path: path.to_path_buf(), - }) -} - -/// Read one directory. Runs on a worker thread — never the UI thread. -pub fn read_directory(path: &Path, show_hidden: bool) -> Result, String> { - let read_dir = fs::read_dir(path) - .map_err(|error| format!("Could not read {}: {}", path.display(), error))?; - let mut entries = Vec::new(); - for item in read_dir.flatten() { - let name = item.file_name().to_string_lossy().into_owned(); - if !show_hidden && name.starts_with('.') { - continue; - } - // `metadata` follows symlinks so a link to a folder browses like one; - // a broken link has no metadata and is skipped. - let Ok(metadata) = item.metadata() else { - continue; - }; - let path = item.path(); - let is_dir = metadata.is_dir(); - entries.push(FileEntry { - kind: kind_for(&path, is_dir), - name, - is_dir, - size: if is_dir { 0 } else { metadata.len() }, - modified_secs: epoch_secs(metadata.modified().ok()), - created_secs: epoch_secs(metadata.created().ok()), - permissions: permissions_text(&metadata), - // One extra `read_dir` per folder, on this worker thread — never - // on the UI thread, and never past the cap. - child_count: is_dir.then(|| count_children(&path)).flatten(), - path, - }); - } - let mut order: Vec = (0..entries.len()).collect(); - sort_indices(&entries, &mut order, SortSpec::default()); - Ok(order.into_iter().map(|i| entries[i].clone()).collect()) -} - -fn epoch_secs(time: Option) -> u64 { - time.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) - .unwrap_or(0) -} - -/// The mode as `rwxr-xr-x` on unix; the writability elsewhere. -fn permissions_text(metadata: &fs::Metadata) -> String { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = metadata.permissions().mode(); - let bit = |shift: u32, chars: [char; 2]| { - if mode >> shift & 1 == 1 { - chars[0] - } else { - chars[1] - } - }; - (0..9) - .rev() - .map(|i| match i % 3 { - 2 => bit(i, ['r', '-']), - 1 => bit(i, ['w', '-']), - _ => bit(i, ['x', '-']), - }) - .collect() - } - #[cfg(not(unix))] - { - if metadata.permissions().readonly() { - "read-only".to_string() - } else { - "read-write".to_string() - } - } -} - -/// How many entries a folder holds, up to [`FOLDER_COUNT_CAP`]; `None` when -/// it cannot be read (permissions, a vanished directory). -fn count_children(path: &Path) -> Option { - let read_dir = fs::read_dir(path).ok()?; - Some(read_dir.take(FOLDER_COUNT_CAP as usize).count() as u32) -} - -/// "1.5 KB" / "—" for folders. -pub fn format_size(bytes: u64, is_dir: bool) -> String { - if is_dir { - return "—".to_string(); - } - const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; - let mut value = bytes as f64; - let mut unit = 0; - while value >= 1000.0 && unit < UNITS.len() - 1 { - value /= 1000.0; - unit += 1; - } - if unit == 0 { - format!("{} {}", bytes, UNITS[unit]) - } else if value >= 10.0 { - format!("{:.0} {}", value, UNITS[unit]) - } else { - format!("{:.1} {}", value, UNITS[unit]) - } -} - -pub fn now_secs() -> u64 { - SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or(Duration::ZERO) - .as_secs() -} - -/// The machine's UTC offset in seconds, read once. The platform has no -/// timezone database, so we ask the system's own `date` — which knows about -/// DST — instead of guessing. -pub fn local_utc_offset_secs() -> i64 { - static OFFSET: std::sync::OnceLock = std::sync::OnceLock::new(); - *OFFSET.get_or_init(|| { - #[cfg(any(target_os = "macos", target_os = "linux"))] - { - let Ok(out) = std::process::Command::new("date").arg("+%z").output() else { - return 0; - }; - return parse_utc_offset(String::from_utf8_lossy(&out.stdout).trim()); - } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - 0 - }) -} - -/// `+0200` / `-0730` -> seconds east of UTC. -fn parse_utc_offset(text: &str) -> i64 { - let bytes = text.as_bytes(); - if bytes.len() < 5 || (bytes[0] != b'+' && bytes[0] != b'-') { - return 0; - } - let Ok(hours) = text[1..3].parse::() else { - return 0; - }; - let Ok(minutes) = text[3..5].parse::() else { - return 0; - }; - let magnitude = hours * 3600 + minutes * 60; - if bytes[0] == b'-' { - -magnitude - } else { - magnitude - } -} - -/// Days since the epoch to (year, month, day) — Howard Hinnant's -/// civil_from_days. -fn civil_from_days(z: i64) -> (i64, u32, u32) { - let z = z + 719468; - let era = if z >= 0 { z } else { z - 146096 } / 146097; - let doe = (z - era * 146097) as u64; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - (if m <= 2 { y + 1 } else { y }, m, d) -} - -const MONTHS: [&str; 12] = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", -]; - -/// A file timestamp as the file manager shows it: local time, the year -/// always, to the minute — "Aug 27, 2026 21:54". -pub fn format_stamp(secs: u64) -> String { - format_stamp_at(secs, local_utc_offset_secs()) -} - -/// [`format_stamp`] with an explicit offset, so it can be tested. -pub fn format_stamp_at(secs: u64, offset_secs: i64) -> String { - if secs == 0 { - return "—".to_string(); - } - let local = secs as i64 + offset_secs; - let days = local.div_euclid(86_400); - let time = local.rem_euclid(86_400); - let (year, month, day) = civil_from_days(days); - format!( - "{} {}, {} {:02}:{:02}", - MONTHS[(month as usize - 1).min(11)], - day, - year, - time / 3600, - (time % 3600) / 60 - ) -} - -/// "3 hr ago" for a timestamp `secs`, relative to `now`. -pub fn format_age(secs: u64, now: u64) -> String { - if secs == 0 { - return "Unknown".to_string(); - } - let age = now.saturating_sub(secs); - match age { - a if a < 60 => "Just now".to_string(), - a if a < 3600 => format!("{} min ago", a / 60), - a if a < 86_400 => format!("{} hr ago", a / 3600), - a if a < 604_800 => format!("{} days ago", a / 86_400), - a if a < 31_536_000 => format!("{} weeks ago", a / 604_800), - a => format!("{} years ago", a / 31_536_000), - } -} - -/// The first `lines` lines of a text file, for the in-app quick look. -pub fn read_head(path: &Path, lines: usize, max_bytes: usize) -> Result { - let data = fs::read(path).map_err(|e| format!("{}", e))?; - let cut = data.len().min(max_bytes); - // Never split a UTF-8 sequence: back off to the last boundary in the cut. - let text = match std::str::from_utf8(&data[..cut]) { - Ok(text) => text.to_string(), - Err(e) => String::from_utf8_lossy(&data[..e.valid_up_to()]).into_owned(), - }; - let mut out = String::new(); - let mut truncated = false; - for (i, line) in text.lines().enumerate() { - if i >= lines { - truncated = true; - break; - } - if i > 0 { - out.push('\n'); - } - out.push_str(line); - } - if truncated { - out.push_str("\n…"); - } - Ok(out) -} - -/// The user's home, or the cwd, or `/`. -pub fn home_dir() -> PathBuf { - std::env::var_os("HOME") - .map(PathBuf::from) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from("/")) -} - -/// Where deleted files go. The operations engine has to know this too and -/// cannot depend on this module, so it owns the definition and this is the -/// one name the rest of the app uses. -pub fn trash_dir(home: &Path) -> PathBuf { - crate::ops::trash_dir(home) -} - -/// The last path component, falling back to the whole path for `/`. -pub fn display_name(path: &Path) -> String { - path.file_name() - .map(|name| name.to_string_lossy().into_owned()) - .filter(|name| !name.is_empty()) - .unwrap_or_else(|| path.display().to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - // The preference file is shared by every small choice the app keeps, so - // writing one key must never eat the others. - #[test] - fn setting_one_preference_keeps_the_rest() { - let text = "scan_all=1\nprojection=ortho\n"; - assert_eq!(pref_find(text, "projection").as_deref(), Some("ortho")); - assert_eq!(pref_find(text, "missing"), None); - let replaced = pref_replace(text, "projection", "persp"); - assert_eq!(pref_find(&replaced, "projection").as_deref(), Some("persp")); - assert_eq!(pref_find(&replaced, "scan_all").as_deref(), Some("1")); - // A new key appends; nothing else moves. - let grown = pref_replace(&replaced, "filter_side", "1"); - assert_eq!(pref_find(&grown, "filter_side").as_deref(), Some("1")); - assert_eq!(pref_find(&grown, "scan_all").as_deref(), Some("1")); - assert_eq!(grown.lines().count(), 3); - } - - // The rule that keeps macOS from throwing a permission dialog per - // protected folder: those folders are never entered at all. - #[test] - fn the_map_leaves_apples_folders_alone_and_touches_nothing_else() { - let home = home_dir(); - assert!(skip_for_scan(&home.join("Library"))); - assert!(skip_for_scan(&home.join(".Trash"))); - // The user's own files, which is the entire point. - assert!(!skip_for_scan(&home.join("Documents"))); - assert!(!skip_for_scan(&home.join("Pictures"))); - assert!(!skip_for_scan(&home.join("Downloads"))); - // Only *directly* under home. A project's own `Library` folder is the - // project's, and gets measured like anything else in it. - assert!(!skip_for_scan(&home.join("code/thing/Library"))); - assert!(!skip_for_scan(Path::new("/tmp/Library"))); - // Whatever it leaves out, it says so. - assert!(scan_exclusions().is_some()); - } - - fn entry(name: &str, is_dir: bool, size: u64, modified: u64) -> FileEntry { - let path = PathBuf::from("/x").join(name); - FileEntry { - kind: kind_for(&path, is_dir), - path, - name: name.to_string(), - is_dir, - size, - modified_secs: modified, - created_secs: modified, - permissions: "rw-r--r--".to_string(), - child_count: None, - } - } - - #[test] - fn formats_sizes() { - assert_eq!(format_size(0, true), "—"); - assert_eq!(format_size(999, false), "999 B"); - assert_eq!(format_size(1500, false), "1.5 KB"); - assert_eq!(format_size(15_000, false), "15 KB"); - assert_eq!(format_size(2_500_000_000, false), "2.5 GB"); - } - - #[test] - fn formats_absolute_stamps() { - // 2026-08-27 21:34 UTC, shown in UTC. - let secs = 1_787_866_440; - assert_eq!(format_stamp_at(secs, 0), "Aug 27, 2026 21:34"); - // Two hours east is two hours later on the same clock. - assert_eq!(format_stamp_at(secs, 2 * 3600), "Aug 27, 2026 23:34"); - // And crossing midnight rolls the date. - assert_eq!(format_stamp_at(secs, 3 * 3600), "Aug 28, 2026 00:34"); - assert_eq!(format_stamp_at(0, 0), "—"); - assert_eq!(parse_utc_offset("+0200"), 7200); - assert_eq!(parse_utc_offset("-0730"), -27000); - assert_eq!(parse_utc_offset("garbage"), 0); - } - - #[test] - fn names_the_kinds_the_columns_show() { - assert_eq!(kind_label(Path::new("/a/b"), true, FileKind::Folder), "Folder"); - assert_eq!(kind_label(Path::new("/a/x.mp4"), false, FileKind::Video), "MPEG-4 video"); - assert_eq!(kind_label(Path::new("/a/x.png"), false, FileKind::Image), "PNG image"); - assert_eq!(kind_label(Path::new("/a/x.rs"), false, FileKind::Code), "Rust source"); - // An extension we do not name still reads as itself. - assert_eq!(kind_label(Path::new("/a/x.glb"), false, FileKind::Generic), "GLB file"); - assert_eq!(kind_label(Path::new("/a/x"), false, FileKind::Generic), "File"); - } - - #[test] - fn sizes_read_as_bytes_or_item_counts() { - let mut dir = entry("d", true, 0, 1); - dir.child_count = Some(12); - assert_eq!(dir.size_text(), "12 items"); - dir.child_count = Some(1); - assert_eq!(dir.size_text(), "1 item"); - dir.child_count = None; - assert_eq!(dir.size_text(), "—"); - let file = entry("f.bin", false, 2_000_000, 1); - assert_eq!(file.size_text(), "2.0 MB"); - } - - #[test] - fn every_column_has_a_header_and_a_cell() { - let e = entry("x.png", false, 1234, 1_787_866_440); - for key in SortKey::ALL { - assert!(!key.label().is_empty()); - assert!(key.default_width() > 0.0); - assert!(!key.text(&e).is_empty(), "{:?}", key); - } - } - - #[test] - fn formats_ages() { - assert_eq!(format_age(0, 1000), "Unknown"); - assert_eq!(format_age(990, 1000), "Just now"); - assert_eq!(format_age(1000, 8200), "2 hr ago"); - } - - #[test] - fn classifies_kinds() { - assert_eq!(kind_for(Path::new("/a/b"), true), FileKind::Folder); - assert_eq!(kind_for(Path::new("/a/p.PNG"), false), FileKind::Image); - assert_eq!(kind_for(Path::new("/a/m.rs"), false), FileKind::Code); - assert_eq!(kind_for(Path::new("/a/n.md"), false), FileKind::Text); - assert_eq!(kind_for(Path::new("/a/s.flac"), false), FileKind::Audio); - assert_eq!(kind_for(Path::new("/a/v.mkv"), false), FileKind::Video); - assert_eq!(kind_for(Path::new("/a/z.tar.gz"), false), FileKind::Archive); - assert_eq!(kind_for(Path::new("/a/d.pdf"), false), FileKind::Pdf); - assert_eq!(kind_for(Path::new("/a/.zshrc"), false), FileKind::Text); - assert_eq!(kind_for(Path::new("/a/blob"), false), FileKind::Generic); - } - - #[test] - fn thumbnails_follow_what_can_be_decoded() { - // Pictures and playable video get a real thumbnail. - for ext in IMAGE_EXTS.iter().chain(PLAYABLE_VIDEO_EXTS) { - let path = PathBuf::from(format!("/a/x.{ext}")); - assert!(is_thumbnailable(&path), "{ext}"); - } - for ext in PLAYABLE_VIDEO_EXTS { - assert_eq!( - kind_for(&PathBuf::from(format!("/a/x.{ext}")), false), - FileKind::Video, - "{ext}" - ); - } - // A video we cannot decode still reads as one; it just gets the icon. - assert_eq!(kind_for(Path::new("/a/x.flv"), false), FileKind::Video); - assert!(!is_thumbnailable(Path::new("/a/x.flv"))); - assert!(!is_thumbnailable(Path::new("/a/m.rs"))); - } - - #[test] - fn sorts_folders_first_then_name() { - let entries = vec![ - entry("zeta.txt", false, 10, 100), - entry("Alpha", true, 0, 900), - entry("beta.txt", false, 500, 300), - entry("Gamma", true, 0, 50), - ]; - let mut order: Vec = (0..entries.len()).collect(); - sort_indices(&entries, &mut order, SortSpec::default()); - let names: Vec<&str> = order.iter().map(|i| entries[*i].name.as_str()).collect(); - assert_eq!(names, ["Alpha", "Gamma", "beta.txt", "zeta.txt"]); - } - - #[test] - fn sorts_by_size_and_reverses() { - let entries = vec![ - entry("a.txt", false, 10, 100), - entry("dir", true, 0, 900), - entry("b.txt", false, 500, 300), - ]; - let mut order: Vec = (0..entries.len()).collect(); - sort_indices( - &entries, - &mut order, - SortSpec { - key: SortKey::Size, - ascending: false, - }, - ); - let names: Vec<&str> = order.iter().map(|i| entries[*i].name.as_str()).collect(); - // The folder still leads; files run big -> small. - assert_eq!(names, ["dir", "b.txt", "a.txt"]); - } - - #[test] - fn descending_name_is_the_mirror() { - let entries = vec![ - entry("a.txt", false, 1, 1), - entry("b.txt", false, 2, 2), - entry("c.txt", false, 3, 3), - ]; - let mut asc: Vec = (0..3).collect(); - sort_indices(&entries, &mut asc, SortSpec::default()); - let mut desc: Vec = (0..3).collect(); - sort_indices( - &entries, - &mut desc, - SortSpec { - key: SortKey::Name, - ascending: false, - }, - ); - desc.reverse(); - assert_eq!(asc, desc); - } - - #[test] - fn the_column_set_is_complete_and_unique() { - let mut labels: Vec<&str> = SortKey::ALL.iter().map(|k| k.label()).collect(); - labels.sort_unstable(); - labels.dedup(); - assert_eq!(labels.len(), SortKey::ALL.len()); - assert_eq!(SortKey::ALL[0], SortKey::Name); - assert_eq!(SortKey::default(), SortKey::Name); - } - - #[test] - fn reads_a_head_of_lines() { - let dir = std::env::temp_dir().join("mpfiles-test-head"); - fs::create_dir_all(&dir).unwrap(); - let file = dir.join("head.txt"); - fs::write(&file, "one\ntwo\nthree\nfour\n").unwrap(); - let head = read_head(&file, 2, 4096).unwrap(); - assert_eq!(head, "one\ntwo\n…"); - fs::remove_file(&file).ok(); - } -} diff --git a/apps/mpfiles/src/ops.rs b/apps/mpfiles/src/ops.rs deleted file mode 100644 index 42cebe7a0..000000000 --- a/apps/mpfiles/src/ops.rs +++ /dev/null @@ -1,1437 +0,0 @@ -//! The file-operations engine: copy, cut/paste, rename, new folder, and -//! move-to-Trash, all run on a single worker thread so a big copy never -//! stalls the UI. Progress and results come back through [`Ops::drain`]; -//! everything that can be undone goes on the [`Journal`] as an [`Undo`]. -//! -//! Pure `std`, on purpose: this module is unit-tested standalone (see the -//! command in the crate's contributing notes) and is meant to be dropped -//! into the app crate as `mod ops;` without pulling in makepad or any other -//! dependency along with it. - -use std::{ - cell::{Cell, RefCell}, - collections::{HashMap, VecDeque}, - fs, - io::{self, Read, Write}, - path::{Path, PathBuf}, - sync::{ - atomic::{AtomicBool, AtomicUsize, Ordering}, - mpsc, Arc, Mutex, - }, - thread, - time::{Duration, Instant}, -}; - -// --------------------------------------------------------------------- -// Vocabulary -// --------------------------------------------------------------------- - -/// What an operation does. The UI shows these words, so they are the -/// vocabulary of the progress row and the undo status line — change one -/// and the on-screen language changes with it. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum OpKind { - Copy, - Move, - Trash, - Rename, - NewFolder, - /// Erase, with no Trash behind it. Deliberately has no undo: that is what - /// makes it different from Trash, and pretending otherwise would be a lie - /// the user finds out about at the worst moment. - Delete, -} - -impl OpKind { - /// The present-participle the progress row shows next to the file name, - /// e.g. "Copying report.txt". - pub fn verb(self) -> &'static str { - match self { - OpKind::Copy => "Copying", - OpKind::Move => "Moving", - OpKind::Trash => "Moving to Trash", - OpKind::Rename => "Renaming", - OpKind::NewFolder => "Creating", - OpKind::Delete => "Deleting", - } - } -} - -/// One job handed to the worker. A single request can carry many sources -/// (a multi-select copy/move/trash) but exactly one destination, because a -/// paste always lands in one folder at a time. -#[derive(Clone, Debug)] -pub struct OpRequest { - /// Chosen by the caller (not the engine) so the caller can correlate a - /// submitted job with the [`OpUpdate`]s that come back for it before - /// the worker has even looked at it. - pub id: u64, - pub kind: OpKind, - /// The files/folders acted on. Empty for [`OpKind::NewFolder`], which - /// creates rather than consumes. - pub sources: Vec, - /// Where they land (Copy/Move), the folder the new folder is made in - /// (NewFolder), or the folder the rename happens in (Rename). Unused - /// by Trash, which always has one true destination: the Trash itself. - pub dest_dir: PathBuf, - /// Rename's new name / NewFolder's name. Ignored otherwise. - pub new_name: Option, - /// Trash needs the user's home to find `~/.Trash`; pass it in rather - /// than reading the environment on a worker thread, which is a habit - /// worth keeping even where it wouldn't currently race anything. - pub home: PathBuf, -} - -/// How to undo one finished operation. The [`Journal`] stores these; the -/// engine hands one back on every successful (or partially-cancelled) -/// [`OpUpdate::Done`]. -#[derive(Clone, Debug, PartialEq)] -pub enum Undo { - /// Put `to` back as `from` (rename and move — and a trash, which is - /// just a move into a special folder — are all the same undo). - Moved { pairs: Vec<(PathBuf, PathBuf)> }, - /// Delete what the copy (or new-folder) created. Kept separate from - /// `Moved` because undoing a copy must never touch the original. - Created { paths: Vec }, -} - -impl Undo { - /// A one-line description for the status bar, e.g. "Undo move of 3 - /// items". Singular items get their own name so a status line about - /// one file reads like it is about that file, not a count of one. - pub fn describe(&self) -> String { - match self { - Undo::Moved { pairs } => match pairs.as_slice() { - [(_, to)] => format!("Undo move of \"{}\"", display_name(to)), - pairs => format!("Undo move of {} items", pairs.len()), - }, - Undo::Created { paths } => match paths.as_slice() { - [path] => format!("Undo creation of \"{}\"", display_name(path)), - paths => format!("Undo creation of {} items", paths.len()), - }, - } - } -} - -fn moved_undo(pairs: Vec<(PathBuf, PathBuf)>) -> Option { - if pairs.is_empty() { - None - } else { - Some(Undo::Moved { pairs }) - } -} - -fn created_undo(paths: Vec) -> Option { - if paths.is_empty() { - None - } else { - Some(Undo::Created { paths }) - } -} - -/// What the worker sends back. Drained on the UI thread via [`Ops::drain`]. -#[derive(Clone, Debug)] -pub enum OpUpdate { - /// `done`/`total` are bytes for Copy/Move/Trash (and their undos, which - /// are just moves in the other direction); for undoing a Copy — which - /// deletes rather than transfers — they are an item count instead, - /// since there is no byte stream to measure. - Progress { - id: u64, - kind: OpKind, - done: u64, - total: u64, - current: String, - }, - Done { - id: u64, - kind: OpKind, - message: String, - undo: Option, - touched: Vec, - }, - Failed { - id: u64, - kind: OpKind, - message: String, - }, -} - -// --------------------------------------------------------------------- -// Free functions — the parts with the interesting rules -// --------------------------------------------------------------------- - -/// A path in `dir` for `name` that does not collide: "report.txt" becomes -/// "report (2).txt", then "report (3).txt". The suffix goes before the -/// extension so double-clicking the copy still opens it in the same -/// application; a name with no extension, and a dotfile like ".zshrc" -/// (which `Path` already treats as having none), get the suffix at the -/// very end instead. -pub fn unique_path(dir: &Path, name: &str) -> PathBuf { - let candidate = dir.join(name); - if !candidate.exists() { - return candidate; - } - let (stem, ext) = split_stem_ext(name); - let mut n: u64 = 2; - loop { - let candidate_name = if ext.is_empty() { - format!("{name} ({n})") - } else { - format!("{stem} ({n}).{ext}") - }; - let candidate = dir.join(&candidate_name); - if !candidate.exists() { - return candidate; - } - n += 1; - } -} - -/// Splits `name` the way [`unique_path`] needs: a dotfile or an -/// extensionless name reports an empty extension, which is the signal to -/// put the disambiguating suffix at the very end rather than splicing it -/// into the middle of the only dot the name has. -fn split_stem_ext(name: &str) -> (String, String) { - let path = Path::new(name); - match (path.file_stem(), path.extension()) { - (Some(stem), Some(ext)) => (stem.to_string_lossy().into_owned(), ext.to_string_lossy().into_owned()), - _ => (name.to_string(), String::new()), - } -} - -/// Where `~/.Trash` is on this platform (macOS: `/.Trash`, elsewhere -/// `/.local/share/Trash/files`, the freedesktop.org convention). -/// This module cannot reuse the crate's own copy of this logic (see the -/// module doc comment on why), so it is duplicated deliberately rather -/// than imported. -pub fn trash_dir(home: &Path) -> PathBuf { - #[cfg(target_os = "macos")] - { - home.join(".Trash") - } - #[cfg(not(target_os = "macos"))] - { - home.join(".local/share/Trash/files") - } -} - -/// Recursive byte total of a path — the size a folder reports in the -/// properties panel and the total a copy is measured against. Never -/// follows symlinks (a link's target is not this path's content, and -/// following it risks double-counting or walking outside the tree -/// entirely), and gives up early with whatever it has counted so far once -/// `cancel` is raised, so a huge folder does not block a cancel forever. -pub fn total_bytes(path: &Path, cancel: &AtomicBool) -> u64 { - let Ok(meta) = fs::symlink_metadata(path) else { - return 0; - }; - if meta.file_type().is_symlink() { - return 0; - } - if !meta.is_dir() { - return meta.len(); - } - let mut total = 0u64; - let Ok(read_dir) = fs::read_dir(path) else { - return 0; - }; - for entry in read_dir.flatten() { - if cancel.load(Ordering::SeqCst) { - break; - } - total += total_bytes(&entry.path(), cancel); - } - total -} - -/// Copy a file or a whole tree. Reports bytes as it goes through `on_bytes` -/// (called with the number of bytes just written, not the running total) -/// and gives up when `cancel` is raised, leaving whatever has already been -/// written in place — the caller decides whether to keep or discard a -/// cancelled copy's partial output. Symlinks are recreated as symlinks, -/// not followed, so copying a folder can never walk out of that folder and -/// copy the rest of the disk. -pub fn copy_tree(src: &Path, dst: &Path, cancel: &AtomicBool, on_bytes: &dyn Fn(u64)) -> io::Result<()> { - if cancel.load(Ordering::SeqCst) { - return Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled")); - } - let meta = fs::symlink_metadata(src)?; - if meta.file_type().is_symlink() { - let link_target = fs::read_link(src)?; - #[cfg(unix)] - { - std::os::unix::fs::symlink(&link_target, dst)?; - } - #[cfg(windows)] - { - let points_at_dir = fs::metadata(src).map(|m| m.is_dir()).unwrap_or(false); - if points_at_dir { - std::os::windows::fs::symlink_dir(&link_target, dst)?; - } else { - std::os::windows::fs::symlink_file(&link_target, dst)?; - } - } - return Ok(()); - } - if meta.is_dir() { - fs::create_dir_all(dst)?; - for entry in fs::read_dir(src)? { - if cancel.load(Ordering::SeqCst) { - return Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled")); - } - let entry = entry?; - copy_tree(&entry.path(), &dst.join(entry.file_name()), cancel, on_bytes)?; - } - Ok(()) - } else { - copy_file_with_progress(src, dst, cancel, on_bytes) - } -} - -/// The single-file half of [`copy_tree`]: streamed in chunks so `on_bytes` -/// can report as it goes rather than only at the end, and so `cancel` is -/// checked between chunks instead of only between whole files. -fn copy_file_with_progress(src: &Path, dst: &Path, cancel: &AtomicBool, on_bytes: &dyn Fn(u64)) -> io::Result<()> { - let mut reader = fs::File::open(src)?; - let mut writer = fs::File::create(dst)?; - let mut buf = [0u8; 256 * 1024]; - loop { - if cancel.load(Ordering::SeqCst) { - return Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled")); - } - let n = reader.read(&mut buf)?; - if n == 0 { - break; - } - writer.write_all(&buf[..n])?; - on_bytes(n as u64); - } - // Best-effort: a permissions failure here shouldn't fail a copy whose - // bytes already landed correctly. - if let Ok(perm) = fs::metadata(src).map(|m| m.permissions()) { - let _ = fs::set_permissions(dst, perm); - } - Ok(()) -} - -/// Move by rename when the two sit on the same volume — instant, and -/// atomic from the filesystem's point of view — otherwise copy then -/// delete, which is the fallback macOS needs whenever the Trash (or a -/// paste target) is on another disk than the source. -pub fn move_path(src: &Path, dst: &Path, cancel: &AtomicBool, on_bytes: &dyn Fn(u64)) -> io::Result<()> { - if cancel.load(Ordering::SeqCst) { - return Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled")); - } - // Measured before the attempt: after a successful rename `src` is gone, - // and a failed one still wants an honest size for the fallback below. - let size = total_bytes(src, cancel); - if fs::rename(src, dst).is_ok() { - on_bytes(size); - return Ok(()); - } - // `ErrorKind::CrossesDevices` is not stable across every target this - // app builds for, so — per the module's contract — ANY rename error - // takes the copy-then-delete path rather than trying to distinguish - // "wrong device" from, say, "permission denied". A real permission - // problem simply fails again inside `copy_tree`, with a clearer error. - copy_tree(src, dst, cancel, on_bytes)?; - remove_path(src) -} - -/// Delete a file or a whole directory tree, whichever `path` is. -fn remove_path(path: &Path) -> io::Result<()> { - if fs::symlink_metadata(path)?.is_dir() { - fs::remove_dir_all(path) - } else { - fs::remove_file(path) - } -} - -/// The last path component, falling back to the whole path for something -/// path-shaped but nameless (like `/`). Used only for messages, so a -/// slightly odd fallback here is harmless. -fn display_name(path: &Path) -> String { - path.file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()) -} - -/// Refuses a copy/move whose destination is the source folder itself or -/// anything inside it — the one shape of this operation that would -/// otherwise recurse into its own output forever. Checked with -/// `canonicalize`, i.e. against what actually exists on disk right now, -/// per the module's contract; a destination that does not exist yet -/// cannot be inside a source that does, so it is allowed through. -fn refuse_into_self(sources: &[PathBuf], dest_dir: &Path) -> Option { - let dest_canon = fs::canonicalize(dest_dir).ok()?; - for source in sources { - let Ok(source_canon) = fs::canonicalize(source) else { - continue; - }; - if !source_canon.is_dir() { - continue; - } - if dest_canon == source_canon || dest_canon.starts_with(&source_canon) { - return Some(format!("Can't copy or move \"{}\" into itself", display_name(source))); - } - } - None -} - -/// True when `source` already lives directly inside `dest_dir` — the case -/// a cut-and-paste onto the folder it came from must treat as a no-op -/// rather than as a move that happens to land back where it started -/// (which would otherwise still burn a rename and a journal entry). -fn already_there(source: &Path, dest_dir: &Path, dest_canon: Option<&Path>) -> bool { - if source.parent() == Some(dest_dir) { - return true; - } - let (Some(parent), Some(dest_canon)) = (source.parent(), dest_canon) else { - return false; - }; - fs::canonicalize(parent).map(|p| p == dest_canon).unwrap_or(false) -} - -// --------------------------------------------------------------------- -// Progress reporting -// --------------------------------------------------------------------- - -/// Accumulates bytes for one job and turns them into throttled -/// [`OpUpdate::Progress`] pushes. `on_bytes` callbacks are plain `Fn`, not -/// `FnMut` (the free functions above are shared with contexts that only -/// hand out `&dyn Fn`), so the running counters live behind `Cell`/ -/// `RefCell` instead of being captured by value. -struct Progress { - id: u64, - kind: OpKind, - total: u64, - done: Cell, - bytes_since_emit: Cell, - last_emit: Cell, - current: RefCell, - updates: Arc>>, - notify: Arc, -} - -impl Progress { - fn new(id: u64, kind: OpKind, total: u64, updates: Arc>>, notify: Arc) -> Self { - Progress { - id, - kind, - total, - done: Cell::new(0), - bytes_since_emit: Cell::new(0), - last_emit: Cell::new(Instant::now()), - current: RefCell::new(String::new()), - updates, - notify, - } - } - - /// Call before starting a new top-level source so the progress row's - /// "current" name updates between items even though byte reporting is - /// only ever per-chunk, not per-file. - fn set_current(&self, name: &str) { - *self.current.borrow_mut() = name.to_string(); - } - - /// Emits at most every ~32ms or every 1MB of progress, never per file — - /// per the module's contract, a paste of thousands of tiny files must - /// not flood the update queue faster than the UI thread can drain it. - fn on_bytes(&self, delta: u64) { - let done = self.done.get() + delta; - self.done.set(done); - let since = self.bytes_since_emit.get() + delta; - if since >= 1_000_000 || self.last_emit.get().elapsed() >= Duration::from_millis(32) { - self.bytes_since_emit.set(0); - self.last_emit.set(Instant::now()); - let update = OpUpdate::Progress { - id: self.id, - kind: self.kind, - done, - total: self.total, - current: self.current.borrow().clone(), - }; - self.updates.lock().unwrap().push_back(update); - (self.notify)(); - } else { - self.bytes_since_emit.set(since); - } - } -} - -// --------------------------------------------------------------------- -// The engine -// --------------------------------------------------------------------- - -enum Job { - Run(OpRequest, Arc), - Undo(u64, Undo, PathBuf, Arc), -} - -/// The engine: one worker thread, a queue, a cancel flag per job. -/// -/// Threading contract for whoever wires this to the UI: -/// - [`Ops::drain`] never blocks; it just swaps out a small buffer behind a -/// mutex, so it is safe to call every frame. -/// - `notify` runs on the worker thread, inside whatever pushed the -/// update, so it must not itself try to touch UI state directly — it -/// exists purely to raise a signal the UI thread will see. -/// - Dropping `Ops` drops the job sender, which makes the worker's next -/// `recv()` return `Err` and the thread exit — but the thread is never -/// joined and a job already in flight is not interrupted by the drop -/// (only [`Ops::cancel`], called before the drop, can stop it). A job -/// that outlives its `Ops` finishes writing to a queue nobody will ever -/// drain again; callers that care about a clean shutdown should cancel -/// every outstanding id first and wait for `busy()` to go false. -pub struct Ops { - request_tx: mpsc::Sender, - updates: Arc>>, - cancel_flags: Arc>>>, - busy_count: Arc, -} - -impl Ops { - /// `notify` is called (from the worker thread) whenever an update is - /// queued, so the UI can wake itself. Pass a closure that raises the - /// framework's UI signal. - pub fn new(notify: Box) -> Self { - let (request_tx, request_rx) = mpsc::channel::(); - let updates: Arc>> = Arc::new(Mutex::new(VecDeque::new())); - let cancel_flags: Arc>>> = Arc::new(Mutex::new(HashMap::new())); - let busy_count = Arc::new(AtomicUsize::new(0)); - let notify: Arc = Arc::from(notify); - - let worker_updates = updates.clone(); - let worker_cancel_flags = cancel_flags.clone(); - let worker_busy_count = busy_count.clone(); - let worker_notify = notify.clone(); - thread::spawn(move || { - worker_loop(request_rx, worker_updates, worker_cancel_flags, worker_busy_count, worker_notify); - }); - - Ops { request_tx, updates, cancel_flags, busy_count } - } - - /// Queue a job. `request.id` (chosen by the caller) is what later - /// [`OpUpdate`]s and [`Ops::cancel`] calls refer back to. - pub fn submit(&self, request: OpRequest) { - let cancel = Arc::new(AtomicBool::new(false)); - self.cancel_flags.lock().unwrap().insert(request.id, cancel.clone()); - self.busy_count.fetch_add(1, Ordering::SeqCst); - let _ = self.request_tx.send(Job::Run(request, cancel)); - } - - /// Queue the reversal of a finished operation, under a fresh id so it - /// gets its own progress row and its own `Done`/`Failed` update. - pub fn submit_undo(&self, id: u64, undo: Undo, home: PathBuf) { - let cancel = Arc::new(AtomicBool::new(false)); - self.cancel_flags.lock().unwrap().insert(id, cancel.clone()); - self.busy_count.fetch_add(1, Ordering::SeqCst); - let _ = self.request_tx.send(Job::Undo(id, undo, home, cancel)); - } - - /// Everything the worker finished or progressed since the last call. - /// Non-blocking: this only ever holds the mutex long enough to swap a - /// `VecDeque` out, never while the worker itself is doing filesystem - /// work. - pub fn drain(&self) -> Vec { - let mut guard = self.updates.lock().unwrap(); - guard.drain(..).collect() - } - - /// Ask the running job to stop. A cancelled copy leaves what it - /// already wrote — the resulting `Done` message says so rather than - /// silently deleting it, so the user sees exactly what happened. - pub fn cancel(&self, id: u64) { - if let Some(flag) = self.cancel_flags.lock().unwrap().get(&id) { - flag.store(true, Ordering::SeqCst); - } - } - - /// True while a job is queued or running: the progress row's - /// visibility. - pub fn busy(&self) -> bool { - self.busy_count.load(Ordering::SeqCst) > 0 - } -} - -impl Default for Ops { - fn default() -> Self { - Ops::new(Box::new(|| {})) - } -} - -fn worker_loop( - request_rx: mpsc::Receiver, - updates: Arc>>, - cancel_flags: Arc>>>, - busy_count: Arc, - notify: Arc, -) { - // `recv()` blocks until a job arrives and returns `Err` only once every - // `Sender` (i.e. every `Ops`) has been dropped — that Err is this - // thread's only exit path. - while let Ok(job) = request_rx.recv() { - let (id, update) = match job { - Job::Run(request, cancel) => (request.id, run_request(&request, &cancel, &updates, ¬ify)), - Job::Undo(id, undo, home, cancel) => (id, run_undo(id, &undo, &home, &cancel, &updates, ¬ify)), - }; - push_update(&updates, ¬ify, update); - cancel_flags.lock().unwrap().remove(&id); - busy_count.fetch_sub(1, Ordering::SeqCst); - } -} - -fn push_update(updates: &Arc>>, notify: &Arc, update: OpUpdate) { - updates.lock().unwrap().push_back(update); - notify(); -} - -fn run_request( - request: &OpRequest, - cancel: &AtomicBool, - updates: &Arc>>, - notify: &Arc, -) -> OpUpdate { - match request.kind { - OpKind::NewFolder => run_new_folder(request), - OpKind::Rename => run_rename(request), - OpKind::Copy => run_copy(request, cancel, updates, notify), - OpKind::Move => run_move(request, cancel, updates, notify), - OpKind::Trash => run_trash(request, cancel, updates, notify), - OpKind::Delete => run_delete(request), - } -} - -/// Erase every source outright. No undo entry comes back — there is nothing -/// to put back. -fn run_delete(request: &OpRequest) -> OpUpdate { - let mut gone = 0usize; - for source in &request.sources { - let result = if source.is_dir() && !source.is_symlink() { - fs::remove_dir_all(source) - } else { - fs::remove_file(source) - }; - if let Err(error) = result { - return OpUpdate::Failed { - id: request.id, - kind: request.kind, - message: format!("Could not delete {}: {error}", display_name(source)), - }; - } - gone += 1; - } - OpUpdate::Done { - id: request.id, - kind: request.kind, - message: format!( - "Deleted {gone} item{} permanently", - if gone == 1 { "" } else { "s" } - ), - undo: None, - touched: Vec::new(), - } -} - -fn run_new_folder(request: &OpRequest) -> OpUpdate { - let name = request.new_name.as_deref().unwrap_or("New Folder"); - let path = unique_path(&request.dest_dir, name); - match fs::create_dir(&path) { - Ok(()) => OpUpdate::Done { - id: request.id, - kind: OpKind::NewFolder, - message: format!("Created \"{}\"", display_name(&path)), - undo: created_undo(vec![path.clone()]), - touched: vec![path], - }, - Err(error) => OpUpdate::Failed { - id: request.id, - kind: OpKind::NewFolder, - message: format!("Could not create folder: {error}"), - }, - } -} - -fn run_rename(request: &OpRequest) -> OpUpdate { - let Some(old_path) = request.sources.first() else { - return OpUpdate::Failed { - id: request.id, - kind: OpKind::Rename, - message: "Rename needs a source".to_string(), - }; - }; - let Some(new_name) = request.new_name.as_deref() else { - return OpUpdate::Failed { - id: request.id, - kind: OpKind::Rename, - message: "Rename needs a new name".to_string(), - }; - }; - let new_path = request.dest_dir.join(new_name); - if &new_path != old_path && new_path.exists() { - return OpUpdate::Failed { - id: request.id, - kind: OpKind::Rename, - message: format!("\"{new_name}\" already exists"), - }; - } - match fs::rename(old_path, &new_path) { - Ok(()) => OpUpdate::Done { - id: request.id, - kind: OpKind::Rename, - message: format!("Renamed to \"{new_name}\""), - undo: moved_undo(vec![(old_path.clone(), new_path.clone())]), - touched: vec![new_path], - }, - Err(error) => OpUpdate::Failed { - id: request.id, - kind: OpKind::Rename, - message: format!("Could not rename: {error}"), - }, - } -} - -fn run_copy( - request: &OpRequest, - cancel: &AtomicBool, - updates: &Arc>>, - notify: &Arc, -) -> OpUpdate { - if let Some(message) = refuse_into_self(&request.sources, &request.dest_dir) { - return OpUpdate::Failed { id: request.id, kind: OpKind::Copy, message }; - } - if let Err(error) = fs::create_dir_all(&request.dest_dir) { - return OpUpdate::Failed { - id: request.id, - kind: OpKind::Copy, - message: format!("Could not use destination: {error}"), - }; - } - - let total: u64 = request.sources.iter().map(|s| total_bytes(s, cancel)).sum(); - let progress = Progress::new(request.id, OpKind::Copy, total, updates.clone(), notify.clone()); - - let mut touched = Vec::new(); - let mut cancelled = false; - let mut failure = None; - for source in &request.sources { - if cancel.load(Ordering::SeqCst) { - cancelled = true; - break; - } - let Some(name) = source.file_name().map(|n| n.to_string_lossy().into_owned()) else { - continue; - }; - let target = unique_path(&request.dest_dir, &name); - progress.set_current(&name); - // Recorded before the copy runs: even a cancelled or failed copy - // may have written a partial tree at `target` that a caller's undo - // needs to know about to fully clean up. - touched.push(target.clone()); - if let Err(error) = copy_tree(source, &target, cancel, &|delta| progress.on_bytes(delta)) { - if cancel.load(Ordering::SeqCst) { - cancelled = true; - } else { - failure = Some(format!("{name}: {error}")); - } - break; - } - } - - if cancelled { - return OpUpdate::Done { - id: request.id, - kind: OpKind::Copy, - message: format!("Cancelled: copied {} of {} item(s)", touched.len(), request.sources.len()), - undo: created_undo(touched.clone()), - touched, - }; - } - if let Some(message) = failure { - return OpUpdate::Failed { - id: request.id, - kind: OpKind::Copy, - message: format!("Copy failed: {message}"), - }; - } - OpUpdate::Done { - id: request.id, - kind: OpKind::Copy, - message: format!("Copied {} item(s)", touched.len()), - undo: created_undo(touched.clone()), - touched, - } -} - -fn run_move( - request: &OpRequest, - cancel: &AtomicBool, - updates: &Arc>>, - notify: &Arc, -) -> OpUpdate { - if let Some(message) = refuse_into_self(&request.sources, &request.dest_dir) { - return OpUpdate::Failed { id: request.id, kind: OpKind::Move, message }; - } - if let Err(error) = fs::create_dir_all(&request.dest_dir) { - return OpUpdate::Failed { - id: request.id, - kind: OpKind::Move, - message: format!("Could not use destination: {error}"), - }; - } - - let dest_canon = fs::canonicalize(&request.dest_dir).ok(); - // A no-op paste (cut, then paste back onto the same folder) shouldn't - // make the progress bar pretend there is work to measure, so those - // sources are excluded from the total up front. - let movers: Vec<&PathBuf> = request - .sources - .iter() - .filter(|s| !already_there(s, &request.dest_dir, dest_canon.as_deref())) - .collect(); - let total: u64 = movers.iter().map(|s| total_bytes(s, cancel)).sum(); - let mover_count = movers.len(); - let progress = Progress::new(request.id, OpKind::Move, total, updates.clone(), notify.clone()); - - let mut moved_pairs = Vec::new(); - let mut touched = Vec::new(); - let mut skipped = 0usize; - let mut cancelled = false; - let mut failure = None; - for source in &request.sources { - if already_there(source, &request.dest_dir, dest_canon.as_deref()) { - skipped += 1; - touched.push(source.clone()); - continue; - } - if cancel.load(Ordering::SeqCst) { - cancelled = true; - break; - } - let Some(name) = source.file_name().map(|n| n.to_string_lossy().into_owned()) else { - continue; - }; - let target = unique_path(&request.dest_dir, &name); - progress.set_current(&name); - match move_path(source, &target, cancel, &|delta| progress.on_bytes(delta)) { - Ok(()) => { - moved_pairs.push((source.clone(), target.clone())); - touched.push(target); - } - Err(_) if cancel.load(Ordering::SeqCst) => { - cancelled = true; - break; - } - Err(error) => { - failure = Some(format!("{name}: {error}")); - break; - } - } - } - - if cancelled { - return OpUpdate::Done { - id: request.id, - kind: OpKind::Move, - message: format!("Cancelled: moved {} of {} item(s)", moved_pairs.len(), mover_count), - undo: moved_undo(moved_pairs), - touched, - }; - } - if let Some(message) = failure { - return OpUpdate::Failed { - id: request.id, - kind: OpKind::Move, - message: format!("Move failed: {message}"), - }; - } - if moved_pairs.is_empty() && skipped > 0 { - return OpUpdate::Done { - id: request.id, - kind: OpKind::Move, - message: "Nothing to move — already there".to_string(), - undo: None, - touched, - }; - } - let message = if skipped > 0 { - format!("Moved {} item(s) ({} already there)", moved_pairs.len(), skipped) - } else { - format!("Moved {} item(s)", moved_pairs.len()) - }; - OpUpdate::Done { - id: request.id, - kind: OpKind::Move, - message, - undo: moved_undo(moved_pairs), - touched, - } -} - -fn run_trash( - request: &OpRequest, - cancel: &AtomicBool, - updates: &Arc>>, - notify: &Arc, -) -> OpUpdate { - let trash = trash_dir(&request.home); - if let Err(error) = fs::create_dir_all(&trash) { - return OpUpdate::Failed { - id: request.id, - kind: OpKind::Trash, - message: format!("Could not reach Trash: {error}"), - }; - } - - let total: u64 = request.sources.iter().map(|s| total_bytes(s, cancel)).sum(); - let progress = Progress::new(request.id, OpKind::Trash, total, updates.clone(), notify.clone()); - - let mut pairs = Vec::new(); - let mut touched = Vec::new(); - let mut cancelled = false; - let mut failure = None; - for source in &request.sources { - if cancel.load(Ordering::SeqCst) { - cancelled = true; - break; - } - let Some(name) = source.file_name().map(|n| n.to_string_lossy().into_owned()) else { - continue; - }; - // Collisions in the trash go through the same disambiguation as - // everywhere else — two different "notes.txt" trashed on the same - // day must not clobber one another. - let target = unique_path(&trash, &name); - progress.set_current(&name); - match move_path(source, &target, cancel, &|delta| progress.on_bytes(delta)) { - Ok(()) => { - pairs.push((source.clone(), target.clone())); - touched.push(target); - } - Err(_) if cancel.load(Ordering::SeqCst) => { - cancelled = true; - break; - } - Err(error) => { - failure = Some(format!("{name}: {error}")); - break; - } - } - } - - if cancelled { - return OpUpdate::Done { - id: request.id, - kind: OpKind::Trash, - message: format!("Cancelled: moved {} of {} item(s) to Trash", pairs.len(), request.sources.len()), - undo: moved_undo(pairs), - touched, - }; - } - if let Some(message) = failure { - return OpUpdate::Failed { - id: request.id, - kind: OpKind::Trash, - message: format!("Could not move to Trash: {message}"), - }; - } - OpUpdate::Done { - id: request.id, - kind: OpKind::Trash, - message: format!("Moved {} item(s) to Trash", pairs.len()), - undo: moved_undo(pairs), - touched, - } -} - -/// `home` is accepted for symmetry with [`OpRequest`] (and in case a -/// future `Undo` variant needs to relocate something relative to it), but -/// neither current variant needs it: both already carry fully-resolved -/// paths, which is exactly what makes them reversible without recomputing -/// anything about where they came from. -fn run_undo( - id: u64, - undo: &Undo, - _home: &Path, - cancel: &AtomicBool, - updates: &Arc>>, - notify: &Arc, -) -> OpUpdate { - match undo { - Undo::Moved { pairs } => run_undo_moved(id, pairs, cancel, updates, notify), - Undo::Created { paths } => run_undo_created(id, paths, cancel, updates, notify), - } -} - -fn run_undo_moved( - id: u64, - pairs: &[(PathBuf, PathBuf)], - cancel: &AtomicBool, - updates: &Arc>>, - notify: &Arc, -) -> OpUpdate { - let total: u64 = pairs.iter().map(|(_, to)| total_bytes(to, cancel)).sum(); - // Reported as a Move: undoing a rename, a move, or a trash is always - // itself a move, in the other direction. - let progress = Progress::new(id, OpKind::Move, total, updates.clone(), notify.clone()); - - let mut restored = Vec::new(); - let mut cancelled = false; - let mut failure = None; - for (from, to) in pairs { - if cancel.load(Ordering::SeqCst) { - cancelled = true; - break; - } - progress.set_current(&display_name(to)); - match move_path(to, from, cancel, &|delta| progress.on_bytes(delta)) { - Ok(()) => restored.push(from.clone()), - Err(_) if cancel.load(Ordering::SeqCst) => { - cancelled = true; - break; - } - Err(error) => { - failure = Some(format!("{}: {error}", display_name(from))); - break; - } - } - } - - if cancelled { - return OpUpdate::Done { - id, - kind: OpKind::Move, - message: format!("Cancelled undo: restored {} of {} item(s)", restored.len(), pairs.len()), - undo: None, - touched: restored, - }; - } - if let Some(message) = failure { - return OpUpdate::Failed { id, kind: OpKind::Move, message: format!("Undo failed: {message}") }; - } - OpUpdate::Done { - id, - kind: OpKind::Move, - message: format!("Undid move of {} item(s)", restored.len()), - undo: None, - touched: restored, - } -} - -fn run_undo_created( - id: u64, - paths: &[PathBuf], - cancel: &AtomicBool, - updates: &Arc>>, - notify: &Arc, -) -> OpUpdate { - // Undoing a creation deletes rather than transfers bytes, so progress - // here counts items, not bytes — see the note on `OpUpdate::Progress`. - // Reported under `OpKind::Copy`: today the only source of a `Created` - // undo the UI offers to reverse this way is a finished Copy (a - // NewFolder's own undo is rarely surfaced as a re-doable action), and - // there is no dedicated `OpKind` for "delete" to report instead. - let total = paths.len() as u64; - let mut done = 0u64; - let mut last_emit = Instant::now(); - let mut removed = Vec::new(); - let mut cancelled = false; - let mut failure = None; - for path in paths { - if cancel.load(Ordering::SeqCst) { - cancelled = true; - break; - } - match remove_path(path) { - Ok(()) => { - removed.push(path.clone()); - done += 1; - if done == total || last_emit.elapsed() >= Duration::from_millis(32) { - last_emit = Instant::now(); - push_update( - updates, - notify, - OpUpdate::Progress { id, kind: OpKind::Copy, done, total, current: display_name(path) }, - ); - } - } - Err(error) => { - failure = Some(format!("{}: {error}", display_name(path))); - break; - } - } - } - - if cancelled { - return OpUpdate::Done { - id, - kind: OpKind::Copy, - message: format!("Cancelled undo: removed {} of {} item(s)", removed.len(), paths.len()), - undo: None, - touched: removed, - }; - } - if let Some(message) = failure { - return OpUpdate::Failed { id, kind: OpKind::Copy, message: format!("Undo failed: {message}") }; - } - OpUpdate::Done { - id, - kind: OpKind::Copy, - message: format!("Undid creation of {} item(s)", removed.len()), - undo: None, - touched: removed, - } -} - -// --------------------------------------------------------------------- -// Undo journal -// --------------------------------------------------------------------- - -/// The undo stack. Bounded, because a file browser that remembers forever -/// is a file browser that holds paths to files nobody has any more — an -/// undo from an hour and two hundred operations ago is more likely to -/// surprise than help. -pub struct Journal { - stack: VecDeque, -} - -impl Journal { - /// At least 10 steps are kept, per the app's contract. - pub const DEPTH: usize = 32; - - pub fn new() -> Self { - Journal { stack: VecDeque::new() } - } - - pub fn push(&mut self, undo: Undo) { - self.stack.push_back(undo); - while self.stack.len() > Self::DEPTH { - self.stack.pop_front(); - } - } - - pub fn pop(&mut self) -> Option { - self.stack.pop_back() - } - - pub fn len(&self) -> usize { - self.stack.len() - } - - pub fn peek(&self) -> Option<&Undo> { - self.stack.back() - } -} - -impl Default for Journal { - fn default() -> Self { - Self::new() - } -} - -// --------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - /// A fresh, empty directory under the system temp dir, unique to this - /// test run so parallel `cargo test` threads never collide. Every test - /// that touches disk creates its own with this and removes it with - /// [`cleanup`] — nothing here ever reads or writes outside of it. - fn fresh_dir(tag: &str) -> PathBuf { - use std::sync::atomic::AtomicU64; - static COUNTER: AtomicU64 = AtomicU64::new(0); - let n = COUNTER.fetch_add(1, Ordering::SeqCst); - let dir = std::env::temp_dir().join(format!("mpfiles-ops-test-{tag}-{}-{n}", std::process::id())); - fs::create_dir_all(&dir).unwrap(); - dir - } - - fn cleanup(dir: &Path) { - let _ = fs::remove_dir_all(dir); - } - - /// Polls `drain()` until a `Done`/`Failed` for `id` shows up, bounded - /// by `timeout` so a bug in the engine fails the test instead of - /// hanging the suite. - fn wait_for_done(ops: &Ops, id: u64, timeout: Duration) -> OpUpdate { - let start = Instant::now(); - loop { - for update in ops.drain() { - let is_match = match &update { - OpUpdate::Done { id: uid, .. } | OpUpdate::Failed { id: uid, .. } => *uid == id, - OpUpdate::Progress { .. } => false, - }; - if is_match { - return update; - } - } - if start.elapsed() > timeout { - panic!("timed out waiting for update {id}"); - } - thread::sleep(Duration::from_millis(5)); - } - } - - #[test] - fn unique_path_avoids_collisions() { - let dir = fresh_dir("unique"); - assert_eq!(unique_path(&dir, "report.txt"), dir.join("report.txt")); - fs::write(dir.join("report.txt"), b"x").unwrap(); - assert_eq!(unique_path(&dir, "report.txt"), dir.join("report (2).txt")); - fs::write(dir.join("report (2).txt"), b"x").unwrap(); - assert_eq!(unique_path(&dir, "report.txt"), dir.join("report (3).txt")); - // A dotfile has no extension to protect: the suffix goes at the end. - fs::write(dir.join(".zshrc"), b"x").unwrap(); - assert_eq!(unique_path(&dir, ".zshrc"), dir.join(".zshrc (2)")); - // An extensionless name behaves the same way. - fs::write(dir.join("README"), b"x").unwrap(); - assert_eq!(unique_path(&dir, "README"), dir.join("README (2)")); - cleanup(&dir); - } - - #[test] - fn copy_tree_preserves_bytes() { - let root = fresh_dir("copytree"); - let src = root.join("src"); - fs::create_dir_all(src.join("a/b")).unwrap(); - fs::write(src.join("top.txt"), b"hello").unwrap(); - fs::write(src.join("a/mid.txt"), b"middle file").unwrap(); - fs::write(src.join("a/b/deep.bin"), vec![7u8; 5000]).unwrap(); - let expected_total = 5u64 + 11 + 5000; - - let dst = root.join("dst"); - let cancel = AtomicBool::new(false); - let total = Cell::new(0u64); - copy_tree(&src, &dst, &cancel, &|n| total.set(total.get() + n)).unwrap(); - - assert_eq!(total.get(), expected_total); - assert_eq!(fs::read(dst.join("top.txt")).unwrap(), b"hello"); - assert_eq!(fs::read(dst.join("a/mid.txt")).unwrap(), b"middle file"); - assert_eq!(fs::read(dst.join("a/b/deep.bin")).unwrap(), vec![7u8; 5000]); - cleanup(&root); - } - - #[test] - fn copy_tree_cancellation_copies_nothing() { - let root = fresh_dir("cancel"); - let src = root.join("src"); - fs::create_dir_all(&src).unwrap(); - fs::write(src.join("f.bin"), vec![1u8; 1000]).unwrap(); - let dst = root.join("dst"); - - let cancel = AtomicBool::new(true); // pre-cancelled - let result = copy_tree(&src, &dst, &cancel, &|_| {}); - assert!(result.is_err()); - assert!(!dst.exists(), "a pre-cancelled copy must not create the destination at all"); - cleanup(&root); - } - - #[test] - fn move_path_same_volume_moves_bytes() { - let root = fresh_dir("move"); - let src = root.join("f.bin"); - fs::write(&src, b"payload").unwrap(); - let dst = root.join("moved.bin"); - - let cancel = AtomicBool::new(false); - move_path(&src, &dst, &cancel, &|_| {}).unwrap(); - - assert!(!src.exists()); - assert_eq!(fs::read(&dst).unwrap(), b"payload"); - cleanup(&root); - } - - #[cfg(unix)] - #[test] - fn total_bytes_ignores_symlinked_content() { - let root = fresh_dir("totalbytes"); - let real = root.join("big.bin"); - fs::write(&real, vec![9u8; 10_000]).unwrap(); - let tree = root.join("tree"); - fs::create_dir_all(&tree).unwrap(); - fs::write(tree.join("small.txt"), b"hi").unwrap(); // 2 bytes - std::os::unix::fs::symlink(&real, tree.join("link.bin")).unwrap(); - - let cancel = AtomicBool::new(false); - assert_eq!(total_bytes(&tree, &cancel), 2, "the symlink's target must never be counted"); - cleanup(&root); - } - - #[test] - fn refuses_copy_into_own_descendant() { - let root = fresh_dir("selfcopy"); - let src = root.join("folder"); - fs::create_dir_all(src.join("child")).unwrap(); - - assert!(refuse_into_self(&[src.clone()], &src.join("child")).is_some()); - assert!(refuse_into_self(&[src.clone()], &src).is_some()); - - let elsewhere = root.join("elsewhere"); - fs::create_dir_all(&elsewhere).unwrap(); - assert!(refuse_into_self(&[src.clone()], &elsewhere).is_none()); - cleanup(&root); - } - - #[test] - fn journal_is_bounded_lifo() { - let mut journal = Journal::new(); - assert!(Journal::DEPTH >= 10); - for i in 0..(Journal::DEPTH + 5) { - journal.push(Undo::Created { paths: vec![PathBuf::from(format!("/x/{i}"))] }); - } - assert_eq!(journal.len(), Journal::DEPTH); - - // Most recently pushed comes back first... - match journal.pop().unwrap() { - Undo::Created { paths } => assert_eq!(paths[0], PathBuf::from(format!("/x/{}", Journal::DEPTH + 4))), - other => panic!("wrong variant: {other:?}"), - } - assert_eq!(journal.len(), Journal::DEPTH - 1); - // ...and the oldest 5 were dropped, not the newest. - let mut journal2 = Journal::new(); - for i in 0..(Journal::DEPTH + 5) { - journal2.push(Undo::Created { paths: vec![PathBuf::from(format!("/y/{i}"))] }); - } - for _ in 0..Journal::DEPTH { - journal2.pop().unwrap(); - } - assert!(journal2.peek().is_none()); - } - - #[test] - fn ops_copy_end_to_end_then_undo() { - let root = fresh_dir("e2e-copy"); - let src_dir = root.join("src"); - fs::create_dir_all(src_dir.join("nested")).unwrap(); - fs::write(src_dir.join("a.txt"), b"aaa").unwrap(); - fs::write(src_dir.join("nested/b.txt"), b"bbb").unwrap(); - let dest_dir = root.join("dest"); - fs::create_dir_all(&dest_dir).unwrap(); - - let ops = Ops::default(); - ops.submit(OpRequest { - id: 1, - kind: OpKind::Copy, - sources: vec![src_dir.clone()], - dest_dir: dest_dir.clone(), - new_name: None, - home: std::env::temp_dir(), - }); - - let (undo, touched) = match wait_for_done(&ops, 1, Duration::from_secs(5)) { - OpUpdate::Done { undo, touched, .. } => (undo, touched), - other => panic!("expected Done, got {other:?}"), - }; - assert_eq!(touched.len(), 1); - let copied = touched[0].clone(); - assert!(copied.join("a.txt").exists()); - assert_eq!(fs::read(copied.join("nested/b.txt")).unwrap(), b"bbb"); - - let undo = match undo { - Some(Undo::Created { paths }) => paths, - other => panic!("expected Created undo, got {other:?}"), - }; - assert_eq!(undo, touched); - - ops.submit_undo(2, Undo::Created { paths: undo }, std::env::temp_dir()); - wait_for_done(&ops, 2, Duration::from_secs(5)); - assert!(!copied.exists(), "undoing the copy must remove what it created"); - - cleanup(&root); - } - - #[test] - fn ops_trash_into_fake_home_then_undo() { - let root = fresh_dir("e2e-trash"); - let fake_home = root.join("fakehome"); - fs::create_dir_all(&fake_home).unwrap(); - let victim_dir = root.join("victim_dir"); - fs::create_dir_all(&victim_dir).unwrap(); - let file = victim_dir.join("doomed.txt"); - fs::write(&file, b"bye").unwrap(); - - let ops = Ops::default(); - ops.submit(OpRequest { - id: 10, - kind: OpKind::Trash, - sources: vec![file.clone()], - dest_dir: victim_dir.clone(), - new_name: None, - home: fake_home.clone(), // never the real home - }); - - let undo = match wait_for_done(&ops, 10, Duration::from_secs(5)) { - OpUpdate::Done { undo: Some(undo), .. } => undo, - other => panic!("expected Done with undo, got {other:?}"), - }; - assert!(!file.exists()); - assert!(trash_dir(&fake_home).join("doomed.txt").exists()); - - ops.submit_undo(11, undo, fake_home.clone()); - wait_for_done(&ops, 11, Duration::from_secs(5)); - assert!(file.exists()); - assert_eq!(fs::read(&file).unwrap(), b"bye"); - - cleanup(&root); - } - - #[test] - fn ops_move_onto_same_folder_is_noop() { - let root = fresh_dir("move-noop"); - let dir = root.join("here"); - fs::create_dir_all(&dir).unwrap(); - let file = dir.join("stay.txt"); - fs::write(&file, b"still here").unwrap(); - - let ops = Ops::default(); - ops.submit(OpRequest { - id: 20, - kind: OpKind::Move, - sources: vec![file.clone()], - dest_dir: dir.clone(), - new_name: None, - home: std::env::temp_dir(), - }); - match wait_for_done(&ops, 20, Duration::from_secs(5)) { - OpUpdate::Done { undo, .. } => assert!(undo.is_none(), "a no-op paste must not produce an undo entry"), - other => panic!("expected Done, got {other:?}"), - } - assert!(file.exists()); - assert_eq!(fs::read(&file).unwrap(), b"still here"); - cleanup(&root); - } - - #[test] - fn ops_copy_into_same_folder_gets_suffix() { - let root = fresh_dir("copy-suffix"); - let dir = root.join("here"); - fs::create_dir_all(&dir).unwrap(); - let file = dir.join("dup.txt"); - fs::write(&file, b"original").unwrap(); - - let ops = Ops::default(); - ops.submit(OpRequest { - id: 30, - kind: OpKind::Copy, - sources: vec![file.clone()], - dest_dir: dir.clone(), - new_name: None, - home: std::env::temp_dir(), - }); - match wait_for_done(&ops, 30, Duration::from_secs(5)) { - OpUpdate::Done { touched, .. } => { - assert_eq!(touched, vec![dir.join("dup (2).txt")]); - assert!(file.exists(), "the original must be untouched by a copy of itself"); - } - other => panic!("expected Done, got {other:?}"), - } - cleanup(&root); - } -} diff --git a/apps/mpfiles/src/preview.rs b/apps/mpfiles/src/preview.rs deleted file mode 100644 index 4496ba01e..000000000 --- a/apps/mpfiles/src/preview.rs +++ /dev/null @@ -1,294 +0,0 @@ -//! Opening and previewing files, through `mp_wm_api`. -//! -//! Which app answers is never decided here, and there is deliberately no -//! association table in this crate: `mp_wm_api::viewer_for` is the one the -//! compositor and the browser share (pictures → mpimage, video → mpvideo, -//! csv/tsv → mpsheets, pdf → mppdf, html → mpbrowser, everything else → -//! mpterm's `--preview` pager). A file type that opens in the wrong app is -//! fixed there, never here. -//! -//! Hosted as an mpwm tile, an app never spawns anything: it asks, and the -//! compositor floats the viewer over the desk (Quick Look) or opens it as a -//! tile. Standalone the same call spawns the sibling binary — except for the -//! preview, which mpfiles spawns itself so that Space and Escape can take the -//! popup away again; `mp_wm_api::preview`'s child is detached and could not be -//! dismissed. -//! -//! Whether a Quick Look panel is open is **never** this app's own belief: -//! hosted, the WM says so with `PreviewShown`/`PreviewHidden`, and standalone -//! the answer is whether the child process we spawned is still alive. A flag -//! this app flips itself goes stale the moment the user closes the viewer, and -//! the next Space then silently "closes" a panel that is already gone. - -use makepad_widgets::*; -use mp_wm_api::{viewer_for, WmEvent, WmRequest}; - -use std::{ - path::{Path, PathBuf}, - process::{Child, Command}, -}; - -/// Resolve a sibling binary of the running executable, the way mpwm resolves -/// its clients. -pub fn sibling_bin(bin: &str) -> Option { - let exe = std::env::current_exe().ok()?; - let mut path = exe.parent()?.join(bin); - if cfg!(windows) { - path.set_extension("exe"); - } - path.exists().then_some(path) -} - -/// What came of a Quick Look request. -pub enum Preview { - /// A viewer window is showing the file; the status line to say so. - Shown(String), - /// No viewer could show it — the caller may fall back to its own panel. - NoViewer(String), -} - -/// The one preview this window has open, if any. -#[derive(Default)] -pub struct PreviewHost { - /// Only set when *we* spawned it; hosted, the compositor owns the float. - child: Option, - path: Option, - /// What the window manager says its Quick Look panel is showing. Only the - /// WM's own events write this. - hosted: Option, -} - -impl PreviewHost { - /// The file we can *prove* is still previewed: one we spawned ourselves - /// and whose process is still alive. Hosted, this stays `None` — see - /// [`Self::hosted_showing`]. - pub fn showing(&self) -> Option<&Path> { - self.path.as_deref() - } - - /// What the WM's Quick Look panel is showing, as the WM last reported it. - /// This is set by [`Self::on_wm_event`] and by nothing else, which is what - /// keeps it from going stale. - pub fn hosted_showing(&self) -> Option<&Path> { - self.hosted.as_deref() - } - - /// The panel's state, from the window manager. Everything the app does - /// about previews follows from this rather than from what it last asked - /// for. - pub fn on_wm_event(&mut self, event: &WmEvent) -> bool { - match event { - WmEvent::PreviewShown { path } => { - self.hosted = Some(PathBuf::from(path)); - true - } - WmEvent::PreviewHidden => { - self.hosted = None; - true - } - _ => false, - } - } - - /// Point an already-open panel at another file, by the real file behind - /// its name. Nothing happens unless a - /// panel is open, which is what makes it safe to call on every selection - /// change — that is how arrow keys dial through previews. - pub fn retarget(&mut self, cx: &Cx, path: &Path) -> bool { - if self.hosted.is_none() || path.is_dir() { - return false; - } - mp_wm_api::preview(cx, &crate::vfs::vfs().real_path(path)) - } - - /// Quick Look `path` in its associated viewer. What the viewer is handed - /// is the real file behind the name — identical on a real disk, and the - /// backing asset in the demo. - pub fn open(&mut self, cx: &Cx, path: &Path) -> Preview { - let name = crate::model::display_name(path); - let app = viewer_for(path); - let real = crate::vfs::vfs().real_path(path); - let path = real.as_path(); - if mp_wm_api::hosted(cx) { - // No `close` first: the WM keeps the viewer warm and retargets it, - // so hiding the panel a frame before showing it again would only - // make it blink. The panel's state arrives as `PreviewShown`. - if mp_wm_api::preview(cx, path) { - return Preview::Shown(format!( - "Previewing {} in {} — arrow keys dial through, Space or Esc closes", - name, app - )); - } - return Preview::NoViewer(format!("The window manager could not preview {}", name)); - } - self.close(cx); - let Some(bin) = sibling_bin(app) else { - return Preview::NoViewer(format!("{} is not built — no preview for {}", app, name)); - }; - match Command::new(&bin).arg("--preview").arg(path).spawn() { - Ok(child) => { - self.child = Some(child); - self.path = Some(path.to_path_buf()); - Preview::Shown(format!("Previewing {} in {} — Space or Esc to close", name, app)) - } - Err(error) => Preview::NoViewer(format!("Could not preview {}: {}", name, error)), - } - } - - /// Dismiss the preview. Hosted this only *asks*: the panel is closed when - /// `PreviewHidden` says it is, never because we assumed so. - pub fn close(&mut self, cx: &Cx) { - if self.hosted.is_some() { - mp_wm_api::send(cx, &WmRequest::PreviewClose); - } - if let Some(mut child) = self.child.take() { - let _ = child.kill(); - let _ = child.wait(); - } - self.path = None; - } - - /// Forget a preview whose window the user already closed. A viewer exiting - /// wakes nothing in this process, so this has to be asked *before* any - /// decision that depends on the answer — not only when a signal happens to - /// arrive. - pub fn poll(&mut self) { - if let Some(child) = self.child.as_mut() { - if matches!(child.try_wait(), Ok(Some(_))) { - self.child = None; - self.path = None; - } - } - } -} - -/// Open a file for real (not as a preview): a new tile when hosted, a sibling -/// process standalone, and the desktop's own opener when neither can. -pub fn open_file(cx: &Cx, path: &Path) -> String { - let name = crate::model::display_name(path); - let app = viewer_for(path); - let real = crate::vfs::vfs().real_path(path); - let path = real.as_path(); - if mp_wm_api::open(cx, path) { - return format!("Opening {} in {}", name, app); - } - match os_open(path) { - Ok(()) => format!("Opening {}", name), - Err(error) => format!("Could not open {}: {}", name, error), - } -} - -/// Open `path` in one *named* app, the way the Open With submenu means it. An -/// empty `app` is the desktop's own opener — the honest last resort when none -/// of ours claims the file. -pub fn open_file_with(cx: &Cx, path: &Path, app: &str) -> String { - let name = crate::model::display_name(path); - let real = crate::vfs::vfs().real_path(path); - let path = real.as_path(); - if app.is_empty() { - return match os_open(path) { - Ok(()) => format!("Opening {name} with the desktop default"), - Err(error) => format!("Could not open {name}: {error}"), - }; - } - if mp_wm_api::hosted(cx) { - let request = mp_wm_api::WmRequest::Open { - app: Some(app.to_string()), - path: path.display().to_string(), - }; - if mp_wm_api::send(cx, &request) { - return format!("Opening {name} in {app}"); - } - } - let Some(bin) = sibling_bin(app) else { - return format!("{app} is not built"); - }; - match Command::new(&bin).arg(path).spawn() { - Ok(_) => format!("Opening {name} in {app}"), - Err(error) => format!("Could not start {app}: {error}"), - } -} - -/// True when a sibling app of this name can actually be run — what the Open -/// With submenu offers is only ever what exists. -pub fn app_available(cx: &Cx, app: &str) -> bool { - mp_wm_api::hosted(cx) || sibling_bin(app).is_some() -} - -fn os_open(path: &Path) -> std::io::Result<()> { - #[cfg(target_os = "macos")] - { - Command::new("open").arg(path).spawn().map(|_| ()) - } - #[cfg(target_os = "linux")] - { - Command::new("xdg-open").arg(path).spawn().map(|_| ()) - } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { - let _ = path; - Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "opening files is supported on macOS and Linux", - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use mp_wm_api::WmRequest; - - #[test] - fn the_shared_table_picks_the_viewer() { - // mpfiles keeps no association table of its own: every kind it shows - // is routed by mp_wm_api, and the kinds it thumbnails are exactly the - // ones the picture and video viewers claim. - for ext in crate::model::IMAGE_EXTS { - assert_eq!(viewer_for(&PathBuf::from(format!("/a/x.{ext}"))), "mpimage"); - } - for ext in crate::model::PLAYABLE_VIDEO_EXTS { - assert_eq!(viewer_for(&PathBuf::from(format!("/a/x.{ext}"))), "mpvideo"); - } - // Text and code fall to the terminal's pager, not to nothing. - assert_eq!(viewer_for(Path::new("/a/m.rs")), "mpterm"); - assert_eq!(viewer_for(Path::new("/a/n.txt")), "mpterm"); - // Every type this browser names in its Kind column has an owner, and - // none of them is decided in this crate. - assert_eq!(viewer_for(Path::new("/a/d.pdf")), "mppdf"); - assert_eq!(viewer_for(Path::new("/a/t.csv")), "mpsheets"); - assert_eq!(viewer_for(Path::new("/a/p.html")), "mpbrowser"); - } - - #[test] - fn the_panels_state_comes_from_the_window_manager() { - let mut host = PreviewHost::default(); - assert!(host.hosted_showing().is_none()); - // Asking for a preview proves nothing; only the WM saying so does. - assert!(host.on_wm_event(&WmEvent::PreviewShown { - path: "/a/x.png".to_string() - })); - assert_eq!(host.hosted_showing(), Some(Path::new("/a/x.png"))); - // Dialing to another file is the WM telling us again. - host.on_wm_event(&WmEvent::PreviewShown { - path: "/a/y.png".to_string(), - }); - assert_eq!(host.hosted_showing(), Some(Path::new("/a/y.png"))); - assert!(host.on_wm_event(&WmEvent::PreviewHidden)); - assert!(host.hosted_showing().is_none()); - // Events that are not about the panel leave it alone. - assert!(!host.on_wm_event(&WmEvent::Focus { focused: true })); - // And the standalone half is a different question entirely. - assert!(host.showing().is_none()); - } - - #[test] - fn requests_carry_the_path_the_wm_reads_back() { - // A quote or a backslash in a filename must survive the envelope. - let req = WmRequest::Preview { - app: None, - path: "/q\"uote\\x.png".to_string(), - }; - assert_eq!(WmRequest::parse(&req.to_json()), Some(req)); - } -} diff --git a/apps/mpfiles/src/rename.rs b/apps/mpfiles/src/rename.rs deleted file mode 100644 index 6d6b7d009..000000000 --- a/apps/mpfiles/src/rename.rs +++ /dev/null @@ -1,274 +0,0 @@ -//! Renaming: what a new name is allowed to be, and what a pattern does to a -//! whole selection at once. -//! -//! Both halves are pure string work with no filesystem in them, which is what -//! lets the batch dialog show a live preview of exactly what pressing Rename -//! will do — the preview and the operation run the same function. - -/// Characters a filename may not contain. The separator would silently move -/// the file somewhere else, and NUL cannot survive the syscall. -pub const FORBIDDEN: [char; 2] = ['/', '\0']; - -/// Why a name was refused, in the words the dialog shows. -pub fn name_error(name: &str) -> Option<&'static str> { - let trimmed = name.trim(); - if trimmed.is_empty() { - return Some("A name cannot be empty"); - } - if trimmed == "." || trimmed == ".." { - return Some("That name belongs to the folder itself"); - } - if name.contains(FORBIDDEN) { - return Some("A name cannot contain a slash"); - } - None -} - -/// True when `name` can be handed to the filesystem as-is. -pub fn is_valid_name(name: &str) -> bool { - name_error(name).is_none() -} - -/// Split a filename into (stem, extension-with-dot). A dotfile with no second -/// dot is all stem — `.zshrc` has no extension, it *is* a name that starts -/// with one, and renaming it must not eat the leading dot. -pub fn split_extension(name: &str) -> (&str, &str) { - let body = name.strip_prefix('.').unwrap_or(name); - match body.rfind('.') { - Some(at) if at > 0 => { - let cut = at + (name.len() - body.len()); - (&name[..cut], &name[cut..]) - } - _ => (name, ""), - } -} - -/// How a batch rename builds each new name. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum BatchMode { - /// Replace every occurrence of `find` in the name with `replace`. An - /// empty `find` changes nothing, which is what an untouched dialog shows. - FindReplace { find: String, replace: String }, - /// Build the name from a pattern: `###` becomes the running number, zero - /// padded to the run's length, and `{name}` the original stem. The - /// original extension is always kept, so a template cannot make a folder - /// out of a picture. - Template { pattern: String }, -} - -impl BatchMode { - /// The mode a dialog with these two fields means: a pattern wins when the - /// user typed one, because it is the more specific instruction. - pub fn from_fields(find: &str, replace: &str, pattern: &str) -> BatchMode { - if !pattern.trim().is_empty() { - BatchMode::Template { - pattern: pattern.to_string(), - } - } else { - BatchMode::FindReplace { - find: find.to_string(), - replace: replace.to_string(), - } - } - } -} - -/// The new name for every input, in input order, with collisions inside the -/// batch broken apart — two files that would land on one name would leave the -/// user with one file, so the second gets a " (2)". -/// -/// A name the filesystem would refuse comes back unchanged: the batch renames -/// what it can and leaves the rest alone rather than failing whole. -pub fn batch_rename(names: &[String], mode: &BatchMode, start: u32) -> Vec { - let width = number_width(names.len(), start); - let mut out: Vec = Vec::with_capacity(names.len()); - for (index, name) in names.iter().enumerate() { - let number = start + index as u32; - let candidate = match mode { - BatchMode::FindReplace { find, replace } => { - if find.is_empty() { - name.clone() - } else { - name.replace(find.as_str(), replace) - } - } - BatchMode::Template { pattern } => { - let (stem, extension) = split_extension(name); - format!("{}{}", expand(pattern, stem, number, width), extension) - } - }; - let candidate = if is_valid_name(&candidate) { - candidate.trim().to_string() - } else { - name.clone() - }; - out.push(deduplicate(candidate, &out)); - } - out -} - -/// True when the batch would change nothing — the dialog's Rename button has -/// no work to do and says so instead of running an empty operation. -pub fn is_noop(names: &[String], renamed: &[String]) -> bool { - names.len() == renamed.len() && names.iter().zip(renamed).all(|(a, b)| a == b) -} - -/// Substitute the pattern's tokens for one item. -fn expand(pattern: &str, stem: &str, number: u32, width: usize) -> String { - let mut out = String::with_capacity(pattern.len() + 8); - let mut rest = pattern; - while !rest.is_empty() { - if let Some(tail) = rest.strip_prefix("{name}") { - out.push_str(stem); - rest = tail; - continue; - } - if rest.starts_with('#') { - // A run of hashes is one placeholder, and its length is the - // padding the user asked for — "###" is 001, "#" is 1 (widened - // to whatever the run's largest number needs). - let run = rest.chars().take_while(|c| *c == '#').count(); - out.push_str(&format!("{:0>width$}", number, width = run.max(width))); - rest = &rest[run..]; - continue; - } - let c = rest.chars().next().unwrap_or('\0'); - out.push(c); - rest = &rest[c.len_utf8()..]; - } - out -} - -/// Digits needed for the largest number in the run, so a pattern with a bare -/// `#` still lines up when the selection runs past nine. -fn number_width(count: usize, start: u32) -> usize { - let last = start as u64 + count.max(1) as u64 - 1; - last.to_string().len() -} - -/// " (2)" a name that another item in the same batch already took. -fn deduplicate(candidate: String, taken: &[String]) -> String { - if !taken.contains(&candidate) { - return candidate; - } - let (stem, extension) = split_extension(&candidate); - for n in 2..1000 { - let next = format!("{stem} ({n}){extension}"); - if !taken.contains(&next) { - return next; - } - } - candidate -} - -#[cfg(test)] -mod tests { - use super::*; - - fn names(list: &[&str]) -> Vec { - list.iter().map(|s| s.to_string()).collect() - } - - #[test] - fn refuses_names_the_filesystem_would_not_take() { - assert!(is_valid_name("report.txt")); - assert!(is_valid_name(".zshrc")); - assert!(!is_valid_name("")); - assert!(!is_valid_name(" ")); - assert!(!is_valid_name("a/b")); - assert!(!is_valid_name("..")); - assert_eq!(name_error("a/b"), Some("A name cannot contain a slash")); - } - - #[test] - fn splits_the_extension_without_eating_the_leading_dot() { - assert_eq!(split_extension("report.txt"), ("report", ".txt")); - assert_eq!(split_extension("archive.tar.gz"), ("archive.tar", ".gz")); - assert_eq!(split_extension("README"), ("README", "")); - assert_eq!(split_extension(".zshrc"), (".zshrc", "")); - assert_eq!(split_extension(".config.json"), (".config", ".json")); - } - - #[test] - fn find_and_replace_touches_only_the_match() { - let mode = BatchMode::FindReplace { - find: "IMG".to_string(), - replace: "Holiday".to_string(), - }; - let out = batch_rename(&names(&["IMG_1.png", "IMG_2.png", "other.png"]), &mode, 1); - assert_eq!(out, ["Holiday_1.png", "Holiday_2.png", "other.png"]); - } - - #[test] - fn an_empty_find_changes_nothing() { - let mode = BatchMode::FindReplace { - find: String::new(), - replace: "x".to_string(), - }; - let input = names(&["a.txt", "b.txt"]); - let out = batch_rename(&input, &mode, 1); - assert!(is_noop(&input, &out)); - } - - #[test] - fn a_template_numbers_the_run_and_keeps_the_extension() { - let mode = BatchMode::Template { - pattern: "shot-###".to_string(), - }; - let out = batch_rename(&names(&["a.png", "b.jpg", "c"]), &mode, 1); - assert_eq!(out, ["shot-001.png", "shot-002.jpg", "shot-003"]); - } - - #[test] - fn a_bare_hash_still_lines_up_past_nine() { - let mode = BatchMode::Template { - pattern: "f#".to_string(), - }; - let input: Vec = (0..12).map(|i| format!("x{i}.txt")).collect(); - let out = batch_rename(&input, &mode, 1); - assert_eq!(out[0], "f01.txt"); - assert_eq!(out[11], "f12.txt"); - } - - #[test] - fn a_template_can_keep_the_original_name() { - let mode = BatchMode::Template { - pattern: "2026 {name} (#)".to_string(), - }; - let out = batch_rename(&names(&["trip.png", "beach.png"]), &mode, 1); - assert_eq!(out, ["2026 trip (1).png", "2026 beach (2).png"]); - } - - #[test] - fn collisions_inside_one_batch_are_broken_apart() { - let mode = BatchMode::Template { - pattern: "same".to_string(), - }; - let out = batch_rename(&names(&["a.txt", "b.txt", "c.txt"]), &mode, 1); - assert_eq!(out, ["same.txt", "same (2).txt", "same (3).txt"]); - } - - #[test] - fn a_pattern_that_yields_an_illegal_name_leaves_the_file_alone() { - let mode = BatchMode::Template { - pattern: "a/b".to_string(), - }; - let out = batch_rename(&names(&["keep.txt"]), &mode, 1); - assert_eq!(out, ["keep.txt"]); - } - - #[test] - fn the_dialog_fields_pick_the_mode() { - assert_eq!( - BatchMode::from_fields("a", "b", ""), - BatchMode::FindReplace { - find: "a".to_string(), - replace: "b".to_string() - } - ); - assert!(matches!( - BatchMode::from_fields("a", "b", "p-###"), - BatchMode::Template { .. } - )); - } -} diff --git a/apps/mpfiles/src/sizecache.rs b/apps/mpfiles/src/sizecache.rs deleted file mode 100644 index 502c62182..000000000 --- a/apps/mpfiles/src/sizecache.rs +++ /dev/null @@ -1,375 +0,0 @@ -//! The size map, kept between runs. -//! -//! Measuring a full home directory takes minutes, and the answer barely -//! changes between one look and the next — so the finished tree is written to -//! disk and read straight back the next time the same folder is mapped. A map -//! that appears instantly is the difference between a tool somebody reaches -//! for while cleaning up and one they open once. -//! -//! What the cache costs in truth, it pays back in honesty elsewhere: the view -//! says when the map was made, deletions the app itself performs are folded -//! straight into the cached tree (so a delete never costs a rescan), and a -//! rescan is one keystroke away. Changes made *outside* the app are not seen -//! until then, and the "scanned 2h ago" line is there so nobody is surprised -//! by that. -//! -//! The format is a plain little-endian byte stream with a magic number and a -//! version in front. Nothing here ever tries to read an older layout: a -//! version bump simply makes every existing file unreadable, the load returns -//! `None`, and the app falls back to a fresh scan. A cache is an optimisation, -//! and an optimisation that can fail loudly is worse than one that cannot fail -//! at all. - -use std::{ - fs, - io::{Read, Write}, - path::{Path, PathBuf}, - time::{SystemTime, UNIX_EPOCH}, -}; - -use crate::treemap::Node; - -/// `MPFM`, so a stray file in the cache directory is never mistaken for one -/// of ours. -const MAGIC: u32 = 0x4D50_464D; -/// Bump this whenever [`Node`]'s encoding changes. Every older file then -/// fails to load and is simply rewritten by the next scan. -const VERSION: u32 = 2; - -/// A ceiling on how big a tree is worth keeping. Past this the file itself -/// becomes slow enough to read that a fresh scan is competitive, and writing -/// it would spend more of the user's disk than the map is worth on a disk they -/// are trying to empty. -const MAX_NODES: u64 = 4_000_000; - -/// And a ceiling on the file itself. The node count only estimates the size — -/// names vary — and this is a tool for people whose disk is nearly full. It -/// must never be the thing that fills it. -const MAX_BYTES: usize = 192 << 20; - -/// Longest name we will believe out of a cache file. Anything past it means -/// the file is damaged, and the load gives up rather than allocating whatever -/// number it just read. -const MAX_NAME: u32 = 4096; - -/// A map read back off the disk. -pub struct Cached { - /// When the scan that produced this ran, in seconds since the epoch. - pub scanned_at: u64, - pub tree: Node, -} - -/// Seconds since the epoch, or 0 when the clock cannot say. -pub fn now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) -} - -/// "2h ago", "just now" — how a person reads an age. -pub fn age_text(scanned_at: u64) -> String { - let now = now(); - if scanned_at == 0 || now < scanned_at { - return "scanned just now".to_string(); - } - let seconds = now - scanned_at; - if seconds < 90 { - "scanned just now".to_string() - } else if seconds < 5400 { - format!("scanned {}m ago", seconds / 60) - } else if seconds < 172_800 { - format!("scanned {}h ago", seconds / 3600) - } else { - format!("scanned {}d ago", seconds / 86_400) - } -} - -/// Where the map of `root` lives. -/// -/// The file is named by a hash of the absolute path rather than by the path -/// itself: a path can be longer than a filename may be, and can hold every -/// character a filename may not. -fn cache_path(root: &Path) -> Option { - let home = std::env::var_os("HOME")?; - let dir = PathBuf::from(home).join(".config/mpfiles/sizemaps"); - // The scan scope is part of the map's identity: a tree measured with the - // system folders in it must never be served as the excluded one, or the - // other way round. Both scopes keep their own file, so flipping the - // checkbox back is instant once each has been scanned. - let scope = if crate::model::scan_all() { "-all" } else { "" }; - Some(dir.join(format!("{:016x}{scope}.map", hash_path(root)))) -} - -/// FNV-1a over the path's bytes. Not a security hash — a name, and a stable -/// one across runs, which `DefaultHasher` explicitly is not. -fn hash_path(path: &Path) -> u64 { - let mut hash: u64 = 0xcbf2_9ce4_8422_2325; - for byte in path.as_os_str().as_encoded_bytes() { - hash ^= *byte as u64; - hash = hash.wrapping_mul(0x0000_0100_0000_01b3); - } - hash -} - -/// The map of `root`, if one was saved and still reads. -/// -/// Anything wrong with the file — wrong magic, wrong version, truncated, -/// written for a different folder — is a miss, not an error. The caller scans. -pub fn load(root: &Path) -> Option { - let path = cache_path(root)?; - let mut bytes = Vec::new(); - fs::File::open(&path).ok()?.read_to_end(&mut bytes).ok()?; - let mut reader = Reader { - bytes: &bytes, - at: 0, - }; - if reader.u32()? != MAGIC || reader.u32()? != VERSION { - return None; - } - let scanned_at = reader.u64()?; - let saved_root = reader.string()?; - // The hash names the file; this confirms it. Two different folders whose - // paths collide would otherwise show each other's map. - if Path::new(&saved_root) != root { - return None; - } - let tree = reader.node(0)?; - Some(Cached { scanned_at, tree }) -} - -/// The bytes of a saved map, or `None` when the tree is too big to be worth -/// keeping. Encoding is the caller's to schedule — it walks the whole tree, -/// so it belongs wherever the tree already is rather than behind a clone. -pub fn encode(root: &Path, tree: &Node, scanned_at: u64) -> Option> { - if tree.files as u64 > MAX_NODES { - return None; - } - let mut out = Vec::with_capacity(1 << 16); - out.extend_from_slice(&MAGIC.to_le_bytes()); - out.extend_from_slice(&VERSION.to_le_bytes()); - out.extend_from_slice(&scanned_at.to_le_bytes()); - write_string(&mut out, &root.display().to_string()); - write_node(&mut out, tree); - (out.len() <= MAX_BYTES).then_some(out) -} - -/// Put `bytes` where [`load`] will find them for `root`. Best effort: a cache -/// that cannot be written is a cache miss next time, and never a failure the -/// user has to hear about. -pub fn store(root: &Path, bytes: &[u8]) { - let Some(path) = cache_path(root) else { - return; - }; - if let Some(dir) = path.parent() { - if fs::create_dir_all(dir).is_err() { - return; - } - } - // Written beside the real file and renamed over it, so a map that is - // still being written is never a map that gets read. - let temp = path.with_extension("part"); - let wrote = fs::File::create(&temp).and_then(|mut file| file.write_all(bytes)); - if wrote.is_ok() { - let _ = fs::rename(&temp, &path); - } else { - let _ = fs::remove_file(&temp); - } -} - -/// Throw away the saved map of `root`, so the next look measures the disk. -pub fn forget(root: &Path) { - if let Some(path) = cache_path(root) { - let _ = fs::remove_file(path); - } -} - -fn write_string(out: &mut Vec, text: &str) { - let bytes = text.as_bytes(); - out.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); - out.extend_from_slice(bytes); -} - -fn write_node(out: &mut Vec, node: &Node) { - write_string(out, &node.name); - let flags = (node.is_dir as u8) | ((node.denied as u8) << 1); - out.push(flags); - out.push(node.kind); - out.extend_from_slice(&node.size.to_le_bytes()); - out.extend_from_slice(&node.files.to_le_bytes()); - out.extend_from_slice(&node.modified.to_le_bytes()); - out.extend_from_slice(&(node.children.len() as u32).to_le_bytes()); - for child in &node.children { - write_node(out, child); - } -} - -struct Reader<'a> { - bytes: &'a [u8], - at: usize, -} - -impl<'a> Reader<'a> { - fn take(&mut self, count: usize) -> Option<&'a [u8]> { - let end = self.at.checked_add(count)?; - let slice = self.bytes.get(self.at..end)?; - self.at = end; - Some(slice) - } - - fn u32(&mut self) -> Option { - Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?)) - } - - fn u64(&mut self) -> Option { - Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?)) - } - - fn u8(&mut self) -> Option { - Some(self.take(1)?[0]) - } - - fn string(&mut self) -> Option { - let len = self.u32()?; - if len > MAX_NAME { - return None; - } - String::from_utf8(self.take(len as usize)?.to_vec()).ok() - } - - /// One node and everything under it. `depth` is carried only to stop a - /// damaged file from recursing until the stack runs out — a cache is not - /// a trusted input just because we wrote it. - fn node(&mut self, depth: usize) -> Option { - if depth > 512 { - return None; - } - let name = self.string()?; - let flags = self.u8()?; - let kind = self.u8()?; - let size = self.u64()?; - let files = self.u32()?; - let modified = self.u32()?; - let count = self.u32()?; - // A child count larger than the bytes left could only come from a - // damaged file, and reserving on it would be the damage's whole point. - if count as usize > self.bytes.len() - self.at { - return None; - } - let mut children = Vec::with_capacity(count as usize); - for _ in 0..count { - children.push(self.node(depth + 1)?); - } - Some(Node { - name, - is_dir: flags & 1 != 0, - done: true, - denied: flags & 2 != 0, - kind, - size, - files, - modified, - children, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample() -> Node { - let mut root = Node::dir("root".into(), 0); - root.children.push(Node::file_at("a.mov".into(), 5, 900, 123_456)); - root.modified = 123_456; - let mut sub = Node::dir("sub".into(), 0); - sub.children.push(Node::file("b.txt".into(), 2, 30)); - sub.size = 30; - sub.files = 1; - sub.done = true; - let mut locked = Node::dir("locked".into(), 0); - locked.denied = true; - locked.done = true; - root.children.push(sub); - root.children.push(locked); - root.size = 930; - root.files = 2; - root.done = true; - root - } - - fn round_trip(tree: &Node) -> Node { - let root = Path::new("/some/where"); - let bytes = encode(root, tree, 1234).unwrap(); - let mut reader = Reader { - bytes: &bytes, - at: 0, - }; - assert_eq!(reader.u32().unwrap(), MAGIC); - assert_eq!(reader.u32().unwrap(), VERSION); - assert_eq!(reader.u64().unwrap(), 1234); - assert_eq!(reader.string().unwrap(), "/some/where"); - reader.node(0).unwrap() - } - - #[test] - fn a_tree_survives_the_round_trip_unchanged() { - let tree = sample(); - let back = round_trip(&tree); - assert_eq!(back.name, tree.name); - assert_eq!(back.size, tree.size); - assert_eq!(back.files, tree.files); - assert_eq!(back.children.len(), 3); - assert_eq!(back.children[0].name, "a.mov"); - assert_eq!(back.children[0].kind, 5); - // v2's whole point: the age survives, so "show me what's new" works - // straight off a loaded map. - assert_eq!(back.children[0].modified, 123_456); - assert_eq!(back.modified, 123_456); - assert_eq!(back.children[1].children[0].name, "b.txt"); - // The one thing a reload must not forget: which folders it could not - // read, so the map keeps admitting the total is short. - assert!(back.children[2].denied); - // Everything read back is finished by definition — only a completed - // scan is ever written. - assert!(back.done && back.children[1].done); - } - - #[test] - fn a_damaged_file_is_a_miss_and_never_a_panic() { - let bytes = encode(Path::new("/x"), &sample(), 1).unwrap(); - for cut in [0, 4, 9, 20, bytes.len() - 1] { - let mut reader = Reader { - bytes: &bytes[..cut], - at: 0, - }; - // Whatever it reads, it must stop rather than run off the end. - let _ = reader.u32().and_then(|_| reader.node(0)); - } - // Wrong magic, right length. - let mut wrong = bytes.clone(); - wrong[0] ^= 0xff; - let mut reader = Reader { - bytes: &wrong, - at: 0, - }; - assert_ne!(reader.u32().unwrap(), MAGIC); - } - - #[test] - fn the_file_name_follows_the_folder_not_the_other_way_round() { - assert_ne!(hash_path(Path::new("/a")), hash_path(Path::new("/b"))); - assert_eq!(hash_path(Path::new("/a/b")), hash_path(Path::new("/a/b"))); - } - - #[test] - fn ages_read_the_way_a_person_would_say_them() { - let now = now(); - assert_eq!(age_text(now), "scanned just now"); - assert_eq!(age_text(now - 600), "scanned 10m ago"); - assert_eq!(age_text(now - 7200), "scanned 2h ago"); - assert_eq!(age_text(now - 3 * 86_400), "scanned 3d ago"); - // A clock that went backwards is not a reason to print nonsense. - assert_eq!(age_text(now + 5000), "scanned just now"); - } -} diff --git a/apps/mpfiles/src/theme.rs b/apps/mpfiles/src/theme.rs deleted file mode 100644 index 919ce1218..000000000 --- a/apps/mpfiles/src/theme.rs +++ /dev/null @@ -1,317 +0,0 @@ -//! The palette. mpwm exports its active `theme.splash` as MPWM_THEME_SPLASH; -//! `mp_theme` line-scans it and retints the stock widgets, and this module -//! publishes the same colors as `mod.mpf.*` so mpfiles' own chrome — which is -//! all custom views — follows the desktop theme too. Standalone runs get -//! Tokyo Night, so the app is dark and square either way. - -use makepad_widgets::*; -use std::sync::OnceLock; - -/// Every color the DSL reads, as `#rrggbb` strings. -#[derive(Clone, Debug)] -pub struct Palette { - pub accent: String, - pub bg: String, - pub bg_dark: String, - pub bg_light: String, - pub fg: String, - pub fg_bright: String, - pub fg_dim: String, - pub muted: String, - /// Row/tile selection: the accent lifted out of the background enough to - /// read at a glance, which a raw theme `selection` often is not. - pub sel: String, - /// The same selection as a translucent overlay, for the grid — which - /// paints its selection *over* the cells it has already drawn. - pub sel_soft: String, - /// Hover, one step below selection. - pub hover: String, - /// Zebra stripe for the list view. - pub stripe: String, - /// The popup card's hover: the foreground at 8% over the background, - /// which is the omarchy menu's own rule. - pub hover_soft: String, - /// The one warning color in the app, for the row that cannot be undone. - /// It comes from the theme's red, because a theme that has a red has an - /// opinion about what danger looks like. - pub danger: String, - /// The treemap's kind classes, in the order [`KIND_COLOR_KEYS`] names - /// them: video, image, audio, code, text, archive, other. They come from - /// the theme's terminal palette, which is the only place a WM theme keeps - /// a full spread of hues — the chrome palette is all one family by design. - pub kinds: [String; 7], -} - -/// The terminal-palette keys the treemap's kind colors come from, with the -/// Tokyo Night value each falls back to. Order matches [`Palette::kinds`]. -/// The theme's red, when it has none of its own: Tokyo Night's. -const DANGER_FALLBACK: &str = "#f7768e"; - -const KIND_COLOR_KEYS: [(&str, &str); 7] = [ - ("term.color4", "#7aa2f7"), // video — blue - ("term.color2", "#9ece6a"), // image — green - ("term.color3", "#e0af68"), // audio — yellow - ("term.color6", "#7dcfff"), // code — cyan - ("term.color7", "#a9b1d6"), // text — foreground - ("term.color5", "#bb9af7"), // archive — magenta - ("term.color8", "#414868"), // other — muted -]; - -impl Default for Palette { - fn default() -> Self { - Self::tokyo_night() - } -} - -impl Palette { - /// The fallback theme, matching mpwm's default. - pub fn tokyo_night() -> Self { - Self::derive( - "#7aa2f7", "#1a1b26", "#16161e", "#24283b", "#a9b1d6", "#c0caf5", "#565f89", "#414868", - DANGER_FALLBACK, - KIND_COLOR_KEYS.map(|(_, fallback)| fallback.to_string()), - ) - } - - /// The palette for this process, read once. - pub fn shared() -> &'static Palette { - static PALETTE: OnceLock = OnceLock::new(); - PALETTE.get_or_init(Palette::load) - } - - /// The palette mpwm exported for this process, or Tokyo Night. - pub fn load() -> Self { - let Some(p) = mp_theme::current() else { - return Self::tokyo_night(); - }; - Self::derive( - &p.hex("accent", "#7aa2f7"), - &p.hex("background", "#1a1b26"), - &p.hex("darker_background", "#16161e"), - &p.hex("lighter_background", "#24283b"), - &p.hex("foreground", "#a9b1d6"), - &p.hex("bright_foreground", "#c0caf5"), - &p.hex("dark_foreground", "#565f89"), - &p.hex("muted", "#414868"), - &p.hex("term.color1", DANGER_FALLBACK), - KIND_COLOR_KEYS.map(|(key, fallback)| p.hex(key, fallback)), - ) - } - - #[allow(clippy::too_many_arguments)] - fn derive( - accent: &str, - bg: &str, - bg_dark: &str, - bg_light: &str, - fg: &str, - fg_bright: &str, - fg_dim: &str, - muted: &str, - danger: &str, - kinds: [String; 7], - ) -> Self { - Self { - sel: mix(accent, bg, 0.34), - sel_soft: format!("#{}4d", accent.trim().trim_start_matches('#')), - hover: mix(accent, bg, 0.13), - hover_soft: mix(fg, bg, 0.08), - danger: danger.to_string(), - stripe: mix(bg_light, bg, 0.4), - accent: accent.to_string(), - bg: bg.to_string(), - bg_dark: bg_dark.to_string(), - bg_light: bg_light.to_string(), - fg: fg.to_string(), - fg_bright: fg_bright.to_string(), - fg_dim: fg_dim.to_string(), - muted: muted.to_string(), - kinds, - } - } - - /// The fill for one treemap kind class, by its index in [`Palette::kinds`]. - /// Out-of-range classes read as "other" rather than panicking: a map that - /// paints an unknown file grey is right, one that crashes is not. - pub fn kind_color(&self, class: usize) -> Vec4f { - Self::vec4(&self.kinds[class.min(self.kinds.len() - 1)]) - } - - /// A color as a makepad `Vec4f`, for the handful of places Rust sets one. - pub fn vec4(hex: &str) -> Vec4f { - let (r, g, b) = rgb(hex); - Vec4f { - x: r as f32 / 255.0, - y: g as f32 / 255.0, - z: b as f32 / 255.0, - w: 1.0, - } - } - - /// Publish the palette as `mod.mpf` so `script_mod!` can read it. Call - /// after `makepad_widgets::script_mod` and before the app's own module. - pub fn publish(&self, vm: &mut ScriptVm) { - let code = format!( - "mod.mpf = {{\n\ - accent: {accent}\n\ - bg: {bg}\n\ - bg_dark: {bg_dark}\n\ - bg_light: {bg_light}\n\ - fg: {fg}\n\ - fg_bright: {fg_bright}\n\ - fg_dim: {fg_dim}\n\ - muted: {muted}\n\ - sel: {sel}\n\ - sel_soft: {sel_soft}\n\ - hover: {hover}\n\ - stripe: {stripe}\n\ - }}\n\ - true\n", - accent = self.accent, - bg = self.bg, - bg_dark = self.bg_dark, - bg_light = self.bg_light, - fg = self.fg, - fg_bright = self.fg_bright, - fg_dim = self.fg_dim, - muted = self.muted, - sel = self.sel, - sel_soft = self.sel_soft, - hover = self.hover, - stripe = self.stripe, - ); - vm.eval(ScriptMod { - cargo_manifest_path: env!("CARGO_MANIFEST_DIR").to_string(), - module_path: "mpfiles_palette".to_string(), - file: "palette.splash".to_string(), - line: 0, - column: 0, - code, - values: vec![], - }); - for e in vm.take_errors() { - log!("mpfiles palette: {}", e); - } - } -} - -/// `#rrggbb` (or `#rrggbbaa`) -> components. Unparseable input reads black, -/// which is visible rather than silently theme-shaped. -fn rgb(hex: &str) -> (u8, u8, u8) { - let hex = hex.trim().trim_start_matches('#'); - if hex.len() < 6 { - return (0, 0, 0); - } - let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).unwrap_or(0); - (byte(0), byte(2), byte(4)) -} - -/// `a` over `b` at `t`, as a `#rrggbb` string. -fn mix(a: &str, b: &str, t: f64) -> String { - let (ar, ag, ab) = rgb(a); - let (br, bg, bb) = rgb(b); - let c = |x: u8, y: u8| (x as f64 * t + y as f64 * (1.0 - t)).round().clamp(0.0, 255.0) as u8; - format!("#{:02x}{:02x}{:02x}", c(ar, br), c(ag, bg), c(ab, bb)) -} - -// The one text-field shape the whole app uses: flat, square, borderless, -// sitting inside whatever plate hosts it. It lives here, with the palette, -// because every other module's `use mod.widgets.*` resolves at the top of its -// own block — a widget defined inside the block that uses it is invisible to -// that block, so the shared field has to be registered first. -script_mod! { - use mod.prelude.widgets.* - - // The one text field shape the whole app uses: flat, square, borderless, - // sitting inside whatever plate hosts it. Published on `mod.widgets` so - // the shell's path bar and dialogs get the same field as the inline - // editors here — a `let` binding would be local to this block. - mod.widgets.MpfInput = set_type_default() do TextInput{ - width: Fill - height: Fill - margin: 0.0 - padding: Inset{left: 6 right: 6 top: 3 bottom: 3} - draw_bg +: { - border_radius: uniform(0.0) - border_size: uniform(1.0) - color: mod.mpf.bg - color_hover: uniform(mod.mpf.bg) - color_focus: uniform(mod.mpf.bg) - color_down: uniform(mod.mpf.bg) - color_empty: uniform(mod.mpf.bg) - color_disabled: uniform(mod.mpf.bg) - color_2: uniform(vec4(-1.0, -1.0, -1.0, -1.0)) - border_color: uniform(mod.mpf.muted) - border_color_hover: uniform(mod.mpf.muted) - border_color_focus: uniform(mod.mpf.accent) - border_color_down: uniform(mod.mpf.accent) - border_color_empty: uniform(mod.mpf.muted) - border_color_disabled: uniform(mod.mpf.muted) - border_color_2: uniform(vec4(-1.0, -1.0, -1.0, -1.0)) - } - draw_text +: { - color: mod.mpf.fg_bright - color_hover: uniform(mod.mpf.fg_bright) - color_focus: uniform(mod.mpf.fg_bright) - color_down: uniform(mod.mpf.fg_bright) - color_disabled: uniform(mod.mpf.fg_dim) - color_empty: uniform(mod.mpf.fg_dim) - color_empty_hover: uniform(mod.mpf.fg_dim) - color_empty_focus: uniform(mod.mpf.fg_dim) - text_style: theme.font_regular{font_size: 9.5} - } - draw_cursor +: {color: uniform(mod.mpf.accent)} - draw_selection +: { - border_radius: uniform(0.0) - color: uniform(mod.mpf.sel) - color_hover: uniform(mod.mpf.sel) - color_focus: uniform(mod.mpf.sel) - color_down: uniform(mod.mpf.sel) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mixes_toward_the_first_color() { - assert_eq!(mix("#ffffff", "#000000", 0.0), "#000000"); - assert_eq!(mix("#ffffff", "#000000", 1.0), "#ffffff"); - assert_eq!(mix("#ffffff", "#000000", 0.5), "#808080"); - } - - #[test] - fn selection_sits_between_accent_and_background() { - let p = Palette::tokyo_night(); - assert_ne!(p.sel, p.bg); - assert_ne!(p.sel, p.accent); - // Hover is the quieter of the two. - assert_ne!(p.hover, p.sel); - } - - #[test] - fn every_treemap_kind_has_its_own_hue() { - let p = Palette::tokyo_night(); - let mut seen: Vec<&str> = p.kinds.iter().map(String::as_str).collect(); - seen.sort_unstable(); - seen.dedup(); - assert_eq!(seen.len(), p.kinds.len(), "kind colors must be distinct"); - // Every class resolves, and an out-of-range one falls to "other". - for class in 0..p.kinds.len() { - assert_eq!(p.kind_color(class), Palette::vec4(&p.kinds[class])); - } - assert_eq!(p.kind_color(99), p.kind_color(p.kinds.len() - 1)); - } - - #[test] - fn parses_hex() { - assert_eq!(rgb("#7aa2f7"), (0x7a, 0xa2, 0xf7)); - assert_eq!(rgb("7aa2f7"), (0x7a, 0xa2, 0xf7)); - assert_eq!(rgb("bad"), (0, 0, 0)); - let v = Palette::vec4("#ff8000"); - assert!((v.x - 1.0).abs() < 0.001); - assert!((v.z - 0.0).abs() < 0.001); - } -} diff --git a/apps/mpfiles/src/thumbs.rs b/apps/mpfiles/src/thumbs.rs deleted file mode 100644 index 0d223ffe8..000000000 --- a/apps/mpfiles/src/thumbs.rs +++ /dev/null @@ -1,432 +0,0 @@ -//! Thumbnails and type icons — one widget draws both. -//! -//! Pictures get a real thumbnail: the file is read and decoded on a worker -//! thread (never the UI thread), box-filtered down to at most [`THUMB_PX`] on -//! its long edge, and handed back as BGRA pixels the UI turns into a texture. -//! Decoded thumbs live in a bounded LRU so browsing a 20k-file photo folder -//! costs a fixed amount of GPU memory. -//! -//! Playable video gets the same treatment through the platform's standalone -//! file decoder: its first frame is the thumbnail. Videos the decoder does not -//! demux keep the film-strip icon. -//! -//! Everything else gets its kind's SVG, drawn by the same `Image` widget — -//! which is why [`MpfThumb`] exists: it remembers what it is already showing, -//! so a list item repopulated every frame does not re-parse an SVG or reset a -//! texture (and, through `Image::set_texture`'s redraw, spin the frame clock). - -use makepad_widgets::*; -use makepad_widgets::makepad_platform::thread::SignalToUI; -use makepad_widgets::makepad_platform::video_file::VideoFileDecoder; - -use std::{ - collections::HashMap, - path::{Path, PathBuf}, - sync::{ - mpsc::{channel, Receiver, Sender}, - Arc, OnceLock, - }, - thread, -}; - -use crate::model::FileKind; - -/// Longest edge of a decoded thumbnail, in pixels. Big enough for the icon -/// grid on a retina screen, small enough that 256 of them are pocket change. -pub const THUMB_PX: usize = 192; -/// How many decoded thumbnails stay resident. -pub const THUMB_CACHE_CAP: usize = 256; -/// Files larger than this are not thumbnailed: the decode would cost more -/// than the picture is worth in a grid cell. -pub const THUMB_MAX_FILE_BYTES: u64 = 96 * 1024 * 1024; -/// Decode workers. Two keeps one slow JPEG from stalling the whole grid. -const WORKERS: usize = 2; - -script_mod! { - use mod.prelude.widgets.* - - mod.widgets.MpfThumbBase = #(MpfThumb::register_widget(vm)) - mod.widgets.MpfThumb = set_type_default() do mod.widgets.MpfThumbBase{ - width: Fit - height: Fit - align: Align{x: 0.5 y: 0.5} - img := Image{ - width: 72 - height: 56 - fit: ImageFit.Smallest - } - } -} - -/// Pixels handed back by a worker: BGRA `0xAARRGGBB`, row-major. -pub struct ThumbPixels { - pub width: usize, - pub height: usize, - pub data: Vec, -} - -struct ThumbDone { - path: PathBuf, - pixels: Option, -} - -struct CacheSlot { - /// `None` once a decode failed — remembered so we never retry in a loop. - texture: Option, - tick: u64, -} - -/// The thumbnail cache: request pictures, drain finished decodes, look them up. -pub struct Thumbs { - senders: Vec>, - results: Receiver, - slots: HashMap, - inflight: HashMap, - tick: u64, - next_worker: usize, -} - -impl Default for Thumbs { - fn default() -> Self { - Self::new() - } -} - -impl Thumbs { - pub fn new() -> Self { - let (done_tx, results) = channel::(); - let mut senders = Vec::with_capacity(WORKERS); - for _ in 0..WORKERS { - let (tx, rx) = channel::(); - let done = done_tx.clone(); - // A dedicated channel per worker (instead of one shared, mutex-guarded - // receiver) keeps a blocking `recv` from serializing the pool. - thread::spawn(move || { - while let Ok(path) = rx.recv() { - let pixels = decode_thumb(&path); - if done.send(ThumbDone { path, pixels }).is_err() { - return; - } - SignalToUI::set_ui_signal(); - } - }); - senders.push(tx); - } - Self { - senders, - results, - slots: HashMap::new(), - inflight: HashMap::new(), - tick: 0, - next_worker: 0, - } - } - - /// The texture for `path` if it is decoded; queues a decode if it is not. - /// Returns `None` while the decode is pending or after it failed. - pub fn get_or_request(&mut self, path: &Path) -> Option { - self.tick += 1; - let tick = self.tick; - if let Some(slot) = self.slots.get_mut(path) { - slot.tick = tick; - return slot.texture.clone(); - } - if self.inflight.contains_key(path) { - return None; - } - self.inflight.insert(path.to_path_buf(), ()); - let worker = self.next_worker % self.senders.len(); - self.next_worker = self.next_worker.wrapping_add(1); - let _ = self.senders[worker].send(path.to_path_buf()); - None - } - - /// Turn everything the workers finished into textures. Returns true when - /// something landed, i.e. the views need a redraw. - pub fn drain(&mut self, cx: &mut Cx) -> bool { - let done: Vec = self.results.try_iter().collect(); - if done.is_empty() { - return false; - } - for item in done { - self.inflight.remove(&item.path); - let texture = item.pixels.map(|p| { - Texture::new_with_format( - cx, - TextureFormat::VecBGRAu8_32 { - width: p.width, - height: p.height, - data: Some(p.data), - updated: TextureUpdated::Full, - }, - ) - }); - self.tick += 1; - let tick = self.tick; - self.slots.insert(item.path, CacheSlot { texture, tick }); - } - self.evict(); - true - } - - /// Drop the least recently looked-at slots down to the cap. - fn evict(&mut self) { - while self.slots.len() > THUMB_CACHE_CAP { - let Some(oldest) = self - .slots - .iter() - .min_by_key(|(_, slot)| slot.tick) - .map(|(path, _)| path.clone()) - else { - return; - }; - self.slots.remove(&oldest); - } - } - - /// How many thumbnails are decoded and resident. - pub fn resident(&self) -> usize { - self.slots.len() - } -} - -/// Read, decode and downscale one file's picture. Runs on a worker thread. -/// -/// The *kind* comes from the name the browser shows, and the *bytes* from -/// whatever file actually backs it — the two are the same thing on a real -/// disk and deliberately different in the demo, which is what lets a made-up -/// photo have a real thumbnail. -fn decode_thumb(path: &Path) -> Option { - let real = crate::vfs::vfs().real_path(path); - if crate::model::is_playable_video(path) { - // A video is never read whole — the decoder demuxes to the first - // frame — so the picture-sized file cap does not apply here. - return decode_video_thumb(&real); - } - let meta = std::fs::metadata(&real).ok()?; - if meta.len() > THUMB_MAX_FILE_BYTES { - return None; - } - let data = std::fs::read(&real).ok()?; - let image = decode_image_from_data(&data).ok()?; - Some(downscale(image.width, image.height, &image.data)) -} - -/// The first frame of a video, through the platform's hardware file decoder — -/// the same seam the importer's video probe uses, minus its crate. -fn decode_video_thumb(path: &Path) -> Option { - let mut decoder = VideoFileDecoder::open(path.to_str()?).ok()?; - let frame = decoder.next_frame().ok()??; - let rgb = frame.to_rgb8(); - let (width, height) = (frame.width as usize, frame.height as usize); - if width == 0 || height == 0 || rgb.len() < width * height * 3 { - return None; - } - let bgra: Vec = rgb - .chunks_exact(3) - .map(|p| 0xff00_0000 | ((p[0] as u32) << 16) | ((p[1] as u32) << 8) | p[2] as u32) - .collect(); - Some(downscale(width, height, &bgra)) -} - -/// Box-filter `src` (BGRA `0xAARRGGBB`) down so its long edge is at most -/// [`THUMB_PX`]. Images already that small are copied through. -fn downscale(width: usize, height: usize, src: &[u32]) -> ThumbPixels { - let long = width.max(height); - if width == 0 || height == 0 || long <= THUMB_PX { - return ThumbPixels { - width, - height, - data: src.to_vec(), - }; - } - let scale = THUMB_PX as f64 / long as f64; - let dst_w = ((width as f64 * scale).round() as usize).max(1); - let dst_h = ((height as f64 * scale).round() as usize).max(1); - let mut data = Vec::with_capacity(dst_w * dst_h); - for y in 0..dst_h { - let y0 = y * height / dst_h; - let y1 = (((y + 1) * height).div_ceil(dst_h)).min(height).max(y0 + 1); - for x in 0..dst_w { - let x0 = x * width / dst_w; - let x1 = (((x + 1) * width).div_ceil(dst_w)).min(width).max(x0 + 1); - // Alpha-weighted so transparent texels don't bleed their - // undefined color into the average. - let (mut b, mut g, mut r, mut a, mut n) = (0u64, 0u64, 0u64, 0u64, 0u64); - for sy in y0..y1 { - let row = sy * width; - for sx in x0..x1 { - let p = src[row + sx]; - let pa = ((p >> 24) & 0xff) as u64; - b += (p & 0xff) as u64 * pa; - g += ((p >> 8) & 0xff) as u64 * pa; - r += ((p >> 16) & 0xff) as u64 * pa; - a += pa; - n += 1; - } - } - data.push(if a == 0 { - 0 - } else { - let out_a = (a / n.max(1)) as u32; - (out_a << 24) | (((r / a) as u32) << 16) | (((g / a) as u32) << 8) | (b / a) as u32 - }); - } - } - ThumbPixels { - width: dst_w, - height: dst_h, - data, - } -} - -/// The kind icons, compiled in and shared: `Image::load_svg_from_shared_data` -/// skips a re-parse when handed the same allocation, so every tile showing a -/// folder shares one `Arc` and parses once. -fn kind_svg(kind: FileKind) -> Arc<[u8]> { - static ICONS: OnceLock>> = OnceLock::new(); - let icons = ICONS.get_or_init(|| { - let mut map: HashMap<&'static str, Arc<[u8]>> = HashMap::new(); - map.insert("folder", Arc::from(&include_bytes!("../resources/icons/folder.svg")[..])); - map.insert("file", Arc::from(&include_bytes!("../resources/icons/file.svg")[..])); - map.insert("image", Arc::from(&include_bytes!("../resources/icons/image.svg")[..])); - map.insert("text", Arc::from(&include_bytes!("../resources/icons/text.svg")[..])); - map.insert("code", Arc::from(&include_bytes!("../resources/icons/code.svg")[..])); - map.insert("audio", Arc::from(&include_bytes!("../resources/icons/audio.svg")[..])); - map.insert("video", Arc::from(&include_bytes!("../resources/icons/video.svg")[..])); - map.insert("archive", Arc::from(&include_bytes!("../resources/icons/archive.svg")[..])); - map.insert("pdf", Arc::from(&include_bytes!("../resources/icons/pdf.svg")[..])); - map - }); - icons - .get(kind.icon_name()) - .cloned() - .unwrap_or_else(|| icons["file"].clone()) -} - -/// What an [`MpfThumb`] currently shows. -#[derive(Clone, Debug, Default, PartialEq)] -enum Shown { - #[default] - Nothing, - Kind(FileKind), - Thumb(PathBuf), -} - -/// One icon slot: a picture's thumbnail when it has one, its kind's SVG -/// otherwise, told apart so repopulating a list item costs a comparison. -#[derive(Script, ScriptHook, Widget)] -pub struct MpfThumb { - #[deref] - view: View, - #[rust] - shown: Shown, -} - -impl MpfThumb { - pub fn show_kind(&mut self, cx: &mut Cx, kind: FileKind) { - if self.shown == Shown::Kind(kind) { - return; - } - self.shown = Shown::Kind(kind); - let slot = self.view.image(cx, ids!(img)); - if let Some(mut image) = slot.borrow_mut() { - let _ = image.load_svg_from_shared_data(cx, kind_svg(kind)); - }; - } - - /// Show nothing: an icon slot in a grid cell the folder does not fill. - pub fn show_nothing(&mut self, cx: &mut Cx) { - if self.shown == Shown::Nothing { - return; - } - self.shown = Shown::Nothing; - self.view.image(cx, ids!(img)).set_texture(cx, None); - } - - pub fn show_thumb(&mut self, cx: &mut Cx, path: &Path, texture: Texture) { - if self.shown == Shown::Thumb(path.to_path_buf()) { - return; - } - self.shown = Shown::Thumb(path.to_path_buf()); - self.view.image(cx, ids!(img)).set_texture(cx, Some(texture)); - } -} - -impl Widget for MpfThumb { - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - self.view.draw_walk(cx, scope, walk) - } - - fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - self.view.handle_event(cx, event, scope); - } -} - -/// Blank an icon slot. -pub fn clear_thumb(cx: &mut Cx, slot: &WidgetRef) { - if let Some(mut thumb) = slot.borrow_mut::() { - thumb.show_nothing(cx); - }; -} - -/// Set a thumb slot from an entry: real thumbnail when the picture is -/// decoded, its kind's icon until then (and forever, for non-pictures). -pub fn fill_thumb(cx: &mut Cx, slot: &WidgetRef, entry: &crate::model::FileEntry, thumbs: &mut Thumbs) { - let Some(mut thumb) = slot.borrow_mut::() else { - return; - }; - if crate::model::is_thumbnailable(&entry.path) { - if let Some(texture) = thumbs.get_or_request(&entry.path) { - thumb.show_thumb(cx, &entry.path, texture); - return; - } - } - thumb.show_kind(cx, entry.kind); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn downscale_keeps_aspect_and_caps_long_edge() { - let src = vec![0xff112233u32; 400 * 200]; - let out = downscale(400, 200, &src); - assert_eq!(out.width, THUMB_PX); - assert_eq!(out.height, THUMB_PX / 2); - assert_eq!(out.data.len(), out.width * out.height); - // A flat image survives the box filter exactly. - assert_eq!(out.data[0], 0xff112233); - } - - #[test] - fn downscale_passes_small_images_through() { - let src = vec![0xff445566u32; 8 * 4]; - let out = downscale(8, 4, &src); - assert_eq!((out.width, out.height), (8, 4)); - assert_eq!(out.data.len(), 32); - } - - #[test] - fn every_kind_has_an_icon() { - for kind in [ - FileKind::Folder, - FileKind::Image, - FileKind::Text, - FileKind::Code, - FileKind::Audio, - FileKind::Video, - FileKind::Archive, - FileKind::Pdf, - FileKind::Generic, - ] { - assert!(!kind_svg(kind).is_empty(), "{:?} has no icon", kind); - } - // Distinct kinds get distinct drawings. - assert!(!Arc::ptr_eq(&kind_svg(FileKind::Folder), &kind_svg(FileKind::Generic))); - // The same kind shares one allocation, which is what lets the SVG - // load be skipped on repopulate. - assert!(Arc::ptr_eq(&kind_svg(FileKind::Audio), &kind_svg(FileKind::Audio))); - } -} diff --git a/apps/mpfiles/src/treemap.rs b/apps/mpfiles/src/treemap.rs deleted file mode 100644 index 5c58412b6..000000000 --- a/apps/mpfiles/src/treemap.rs +++ /dev/null @@ -1,2774 +0,0 @@ -//! The data layer behind the folder size map: a streaming recursive scan of a -//! directory's bytes, and the squarified-treemap geometry that turns that tree -//! into non-overlapping rectangles whose areas are proportional to the bytes -//! they stand for. Nothing here knows about widgets, drawing, or the rest of -//! the app — a [`Cell`] is just numbers a view can turn into quads, which is -//! what keeps this module runnable and testable on its own. -//! -//! Two things make this usable on a real, full disk rather than a toy folder. -//! -//! The scan **streams**: every directory, at every depth, announces its -//! listing the moment it has been read ([`ScanStep`]), so a map of a 1.8 TB -//! home starts drawing in milliseconds and sharpens as the walk goes deeper — -//! the picture is never more than one `read_dir` behind the walk. A 500 GB -//! folder four levels down fills in live like everything else, instead of -//! sitting as one opaque growing block until its whole subtree is done. -//! -//! The layout is **pixel-bounded, not depth-bounded**: it recurses all the way -//! down to individual files and stops only where a rectangle gets too small to -//! see. Siblings too small to draw are collapsed into one "N smaller items" -//! rectangle rather than being laid out and thrown away, so the cost of laying -//! out a folder is set by how many pixels it covers, not by how many files are -//! inside it. A folder with 200 000 files in a 40×40 box costs the same as one -//! with 200. - -use std::{ - fs, - path::{Path, PathBuf}, - sync::{ - atomic::{AtomicBool, AtomicU32, Ordering}, - Condvar, Mutex, - }, - thread, - time::{Duration, Instant}, -}; - -/// A rectangle in treemap space. Plain `f64` so this module stays free of -/// any UI vector type — the view converts to its own types at the boundary. -#[derive(Clone, Copy, Debug, PartialEq, Default)] -pub struct Rect { - pub x: f64, - pub y: f64, - pub w: f64, - pub h: f64, -} - -impl Rect { - /// Whether `(px, py)` lands inside this rect. The right and bottom edges - /// are excluded, so two rects tiled edge to edge never both claim the - /// seam between them — a point on a shared border belongs to exactly - /// one of the two, never both and never neither. - pub fn contains(&self, px: f64, py: f64) -> bool { - px >= self.x && px < self.x + self.w && py >= self.y && py < self.y + self.h - } - - /// Plain `w * h`. A degenerate slice (zero or negative on an edge) just - /// falls out of this as zero or negative rather than needing its own - /// case — callers that care about "does this rect draw at all" check - /// the edges directly. - pub fn area(&self) -> f64 { - self.w * self.h - } - - /// This rect pulled in by `edge` on all four sides and by an extra `top` - /// strip along the top. Never returns negative edges. - pub fn shrink(&self, edge: f64, top: f64) -> Rect { - Rect { - x: self.x + edge, - y: self.y + edge + top, - w: (self.w - 2.0 * edge).max(0.0), - h: (self.h - 2.0 * edge - top).max(0.0), - } - } - - /// Whether any part of this rect lies inside `other`. Zero-area rects - /// intersect nothing, which is the right answer for a degenerate slice. - pub fn intersects(&self, other: &Rect) -> bool { - self.x < other.x + other.w - && self.x + self.w > other.x - && self.y < other.y + other.h - && self.y + self.h > other.y - } -} - -/// One scanned entry. Folders carry their children; files are leaves. -/// -/// There is deliberately **no path here**. A full home directory is millions -/// of nodes, and a `PathBuf` per node is hundreds of megabytes of the same -/// prefixes over and over; the path of any node is its ancestors' names -/// joined, which [`layout`] rebuilds for the few thousand rectangles that -/// actually get drawn. -#[derive(Clone, Debug, Default)] -pub struct Node { - pub name: String, - pub is_dir: bool, - /// False while the scan is still filling this subtree in — the rectangle - /// is real but its size is still growing. - pub done: bool, - /// The folder is there and could not be read: a permission the app does - /// not have. Its bytes are unknown, not zero, and the map has to say so - /// rather than quietly leaving them out of the total. - pub denied: bool, - /// Opaque kind tag supplied by the caller's `classify` callback — this - /// module never decides what a file *is*, only how big it is and where - /// it sits in the tree. A folder inherits the kind of its heaviest child, - /// so a folder full of video reads as video rather than as "folder". - pub kind: u8, - /// Recursive byte total for a folder; the file's own size for a leaf. - pub size: u64, - /// Files in this subtree. Cached rather than recounted, because a status - /// line that recounted a million nodes on every progress tick would cost - /// more than the scan. - pub files: u32, - /// When this subtree last changed: minutes since the epoch of the newest - /// file under it (a leaf's own mtime), 0 when unknown. Minutes because a - /// "show me what's new" filter never needs seconds and a u32 of minutes - /// outlives everyone. A folder counts as new when anything inside it is. - pub modified: u32, - pub children: Vec, -} - -impl Node { - /// A folder with nothing in it yet. - pub fn dir(name: String, kind: u8) -> Node { - Node { - name, - is_dir: true, - done: false, - denied: false, - kind, - size: 0, - files: 0, - modified: 0, - children: Vec::new(), - } - } - - /// A file with no known age — the tests' shorthand; the walk always - /// knows better and uses [`Node::file_at`]. - #[cfg(test)] - pub fn file(name: String, kind: u8, size: u64) -> Node { - Node::file_at(name, kind, size, 0) - } - - /// A file with its modification time, in minutes since the epoch. - pub fn file_at(name: String, kind: u8, size: u64, modified: u32) -> Node { - Node { - name, - is_dir: false, - done: true, - denied: false, - kind, - size, - files: 1, - modified, - children: Vec::new(), - } - } - - fn at_mut(&mut self, at: &[u32]) -> Option<&mut Node> { - let mut node = self; - for &index in at { - node = node.children.get_mut(index as usize)?; - } - Some(node) - } - - /// The descendant `names` leads to. - pub fn at(&self, names: &[String]) -> Option<&Node> { - let mut node = self; - for name in names { - node = node.children.iter().find(|c| &c.name == name)?; - } - Some(node) - } - - /// The child called `name`, for resolving a zoom path. - pub fn child_named(&self, name: &str) -> Option<&Node> { - self.children.iter().find(|c| c.name == name) - } - - /// Fold `at`'s ancestors' totals back up after something below them - /// changed. Only the chain named by `at` is touched — the rest of the - /// tree cannot have moved, so nothing else needs recomputing. - fn roll_up(&mut self, at: &[u32]) { - for depth in (0..at.len()).rev() { - let Some(node) = self.at_mut(&at[..depth]) else { - return; - }; - node.size = node.children.iter().map(|c| c.size).sum(); - node.files = node.children.iter().map(|c| c.files).sum(); - node.modified = node.children.iter().map(|c| c.modified).max().unwrap_or(0); - node.kind = heaviest_kind(&node.children).unwrap_or(node.kind); - } - } - - /// Fold one streamed step into this tree. Returns false when the step - /// names a node that is no longer there, which only happens if a caller - /// mixes steps from two different scans. - pub fn apply(&mut self, step: ScanStep) -> bool { - match step { - ScanStep::Opened { - at, - children, - denied, - } => { - let Some(node) = self.at_mut(&at) else { - return false; - }; - node.size = children.iter().map(|c| c.size).sum(); - node.files = children.iter().map(|c| c.files).sum(); - node.modified = children.iter().map(|c| c.modified).max().unwrap_or(0); - node.kind = heaviest_kind(&children).unwrap_or(node.kind); - node.children = children; - node.denied = denied; - self.roll_up(&at); - true - } - ScanStep::Closed { at, node: fresh } => { - let Some(node) = self.at_mut(&at) else { - return false; - }; - *node = fresh; - self.roll_up(&at); - true - } - ScanStep::Pace { .. } => true, - ScanStep::Growing { at, size, files } => { - let Some(node) = self.at_mut(&at) else { - return false; - }; - // A running total, so it must never go backwards and make a - // rectangle shrink under the pointer. - node.size = node.size.max(size); - node.files = node.files.max(files); - self.roll_up(&at); - true - } - } - } - - /// Where `names` leads, as child indices — the form everything else here - /// works in. `None` when any step of it is not in the tree. - fn indices_of(&self, names: &[String]) -> Option> { - let mut node = self; - let mut out = Vec::with_capacity(names.len()); - for name in names { - let index = node.children.iter().position(|c| &c.name == name)?; - out.push(index as u32); - node = &node.children[index]; - } - Some(out) - } - - /// Take the descendant `names` leads to out of the tree and hand it back, - /// subtracting its bytes from every folder above it. - /// - /// This is what makes deleting something cost nothing: the map already - /// knows how big the thing was, so it can be removed from the picture - /// exactly, and nothing has to be read off the disk again. - pub fn detach(&mut self, names: &[String]) -> Option { - let indices = self.indices_of(names)?; - let (parent_at, last) = indices.split_at(indices.len().checked_sub(1)?); - let parent = self.at_mut(parent_at)?; - let index = *last.first()? as usize; - if index >= parent.children.len() { - return None; - } - let node = parent.children.remove(index); - self.roll_up(&indices); - Some(node) - } - - /// Put `node` inside the folder `names` leads to, adding its bytes back - /// up the chain. False when that folder is not in the tree — which is the - /// right answer for a file moved somewhere the map is not of. - pub fn graft(&mut self, names: &[String], node: Node) -> bool { - let Some(indices) = self.indices_of(names) else { - return false; - }; - let Some(parent) = self.at_mut(&indices) else { - return false; - }; - if !parent.is_dir { - return false; - } - // Replacing rather than duplicating: an operation that lands on a - // name already there overwrote it, and two rectangles for one file - // would be a map of a disk that does not exist. - parent.children.retain(|c| c.name != node.name); - parent.children.push(node); - let mut chain = indices; - chain.push(0); - self.roll_up(&chain); - true - } - - /// The folders the scan was not allowed to open, by path relative to this - /// tree, at most `limit` of them. A map that silently leaves out a folder - /// it could not read is a map that lies about the total. - pub fn denied_paths(&self, limit: usize) -> Vec { - let mut out = Vec::new(); - self.collect_denied(&mut String::new(), limit, &mut out); - out - } - - fn collect_denied(&self, prefix: &mut String, limit: usize, out: &mut Vec) { - for child in &self.children { - if out.len() >= limit { - return; - } - if !child.is_dir { - continue; - } - let mark = prefix.len(); - if !prefix.is_empty() { - prefix.push('/'); - } - prefix.push_str(&child.name); - if child.denied { - out.push(prefix.clone()); - } else { - child.collect_denied(prefix, limit, out); - } - prefix.truncate(mark); - } - } - - /// Mark every folder in this tree finished. The walk hands whole subtrees - /// back complete but announces the ones above them a level at a time, so - /// "is this folder still growing" is only knowable for certain once the - /// whole scan is over — which is exactly when this runs. - pub fn seal(&mut self) { - self.done = true; - for child in &mut self.children { - child.seal(); - } - } -} - -/// The kind of the heaviest child — what a folder paints as, so a folder full -/// of video reads blue and a folder full of cache reads grey without anyone -/// having to open it up. -fn heaviest_kind(children: &[Node]) -> Option { - children.iter().max_by_key(|c| c.size).map(|c| c.kind) -} - -/// Progress a blocking [`scan`] reports as it walks, so a caller can show a -/// live "N files, N bytes" line instead of a frozen spinner. -#[derive(Clone, Copy, Debug, Default)] -pub struct ScanProgress { - pub files: u64, - pub bytes: u64, -} - -/// One step of a streaming scan. `at` is an index path from the scan's root: -/// `[]` is the root itself, `[3]` its fourth child, `[3, 1]` that child's -/// second. Indices are stable because [`ScanStep::Opened`] sets a directory's -/// children once and nothing ever reorders them. -#[derive(Debug)] -pub enum ScanStep { - /// The listing of the directory at `at` has been read: these are its - /// children, files already sized, directories still empty and not `done`. - /// `denied` says the folder is there and could not be opened at all. - Opened { - at: Vec, - children: Vec, - denied: bool, - }, - /// The subtree at `at` is finished and replaces whatever stood there. - /// The walk itself no longer produces these — every directory streams its - /// own [`ScanStep::Opened`] — but installing a saved map is exactly this - /// step with `at` empty, so it stays. - Closed { at: Vec, node: Node }, - /// Running totals for a directory that is still being walked, so a big - /// folder's rectangle grows while it is being counted instead of sitting - /// at zero until it is done. Every ancestor's total follows from it. - Growing { at: Vec, size: u64, files: u32 }, - /// How many folders the walk still has open. Not a percentage — a scan - /// cannot know its own denominator before it has walked the tree, and a - /// bar that sits at 95 per cent for a minute is worse than no bar. This - /// number is real, and it goes to zero exactly when the scan ends. - Pace { folders_left: u32 }, -} - -/// How often a still-running subtree reports its running total. -const GROW_EVERY: Duration = Duration::from_millis(120); -/// How many entries pass between progress reports in the blocking [`scan`]. -const PROGRESS_STRIDE: u64 = 512; - -/// What one directory entry looks like before it becomes a [`Node`] — the -/// path is kept only for as long as the walk needs it to recurse, and is -/// never stored in the tree. -struct Listed { - name: String, - path: PathBuf, - is_dir: bool, - size: u64, - modified: u32, - kind: u8, -} - -/// The device a path lives on, so a walk can stay on one volume. -#[cfg(unix)] -fn device_of(path: &Path) -> Option { - use std::os::unix::fs::MetadataExt; - fs::symlink_metadata(path).ok().map(|m| m.dev()) -} - -#[cfg(not(unix))] -fn device_of(_path: &Path) -> Option { - None -} - -/// What a walk is allowed to look at, and what a file *is*. -/// -/// The skip rule is the reason a scan of a home directory does not make macOS -/// throw a permission dialog per protected folder: those folders are never -/// entered in the first place. It is a plain predicate so the policy lives -/// with the app that has an opinion about it, and this module stays a walker. -pub struct ScanRules<'a> { - /// The opaque kind tag stored on each node. This module never decides - /// what a file *is*, only how big it is and where it sits. - pub classify: &'a (dyn Fn(&Path, bool) -> u8 + Sync), - /// True for a directory the walk must not enter and must not count. - pub skip: &'a (dyn Fn(&Path) -> bool + Sync), -} - -/// What one directory read produced: its entries, and whether it could be -/// read at all. -struct Listing { - entries: Vec, - /// The folder exists and the app is not allowed to look inside it. Tried - /// exactly once, never per file — one refusal per folder is a note in the - /// corner of the map, one per file is a storm of dialogs. - denied: bool, -} - -/// One directory's entries. -/// -/// Symlinks are never followed: a link is recorded as its own leaf, sized by -/// the link itself and never by whatever it points at, and it is never -/// recursed into. That single rule is what keeps a cyclic link — a folder -/// somewhere under the root linking back to one of its own ancestors — from -/// turning a scan into an infinite walk. -/// -/// A directory on a different volume than the root is skipped entirely: a -/// mounted backup disk under the folder being measured is not that folder's -/// bytes, and counting it would make every number on the map wrong. So is -/// anything [`ScanRules::skip`] refuses. -fn read_listing( - dir: &Path, - rules: &ScanRules, - device: Option, - growth: &mut Growth, -) -> Listing { - let read_dir = match fs::read_dir(dir) { - Ok(read_dir) => read_dir, - Err(error) => { - // Not a scan failure. It is a folder we know exists and cannot - // see into, recorded as such rather than aborting the walk — and - // never opened a second time. - return Listing { - entries: Vec::new(), - denied: error.kind() == std::io::ErrorKind::PermissionDenied, - }; - } - }; - - // First the names, which cost nothing. `file_type` comes out of the - // directory record itself wherever the filesystem carries one, and it - // never follows a symlink — exactly the leaf treatment a link needs. - let mut found: Vec = Vec::new(); - for entry in read_dir.flatten() { - let Ok(file_type) = entry.file_type() else { - continue; - }; - let name = entry.file_name().to_string_lossy().into_owned(); - // The Finder's per-folder scratch file. It is on every disk, it is - // never what anybody is cleaning up, and it makes a map of a photo - // library half noise. - if name == ".DS_Store" { - continue; - } - found.push(Found { - name, - path: entry.path(), - is_dir: file_type.is_dir(), - size: 0, - modified: 0, - keep: true, - }); - } - - // Then the metadata, which is the whole cost of a scan: one `lstat` per - // entry, each of them a round trip to the disk on a tree nobody has - // touched lately. They are independent, so on a directory big enough for - // it to matter they are made to wait in parallel rather than in turn — - // a folder with a quarter of a million files is otherwise one thread at - // disk latency while five others have nothing to do. - if found.len() >= STAT_PARALLEL_MIN { - let chunk = found.len().div_ceil(STAT_THREADS); - thread::scope(|scope| { - for slice in found.chunks_mut(chunk) { - scope.spawn(move || stat_all(slice, device)); - } - }); - } else { - stat_all(&mut found, device); - } - - let mut entries = Vec::with_capacity(found.len()); - for item in found { - if !item.keep { - continue; - } - if item.is_dir && (rules.skip)(&item.path) { - continue; - } - if !item.is_dir { - // Counted here rather than after the loop, because a directory - // holding a quarter of a million files takes many seconds to - // stat and the numbers on screen must not sit still for all of - // them — a scan that looks frozen is a scan nobody waits for. - growth.add(item.size); - } - entries.push(Listed { - kind: (rules.classify)(&item.path, item.is_dir), - name: item.name, - path: item.path, - is_dir: item.is_dir, - size: item.size, - modified: item.modified, - }); - } - Listing { - entries, - denied: false, - } -} - -/// One entry between "the directory says it is there" and "we know how big it -/// is". Directories carry no size and are only checked for being another -/// volume; files carry nothing but. -struct Found { - name: String, - path: PathBuf, - is_dir: bool, - size: u64, - modified: u32, - keep: bool, -} - -/// A SystemTime as whole minutes since the epoch, saturating; 0 for a time -/// the filesystem would not say. -fn minutes_since_epoch(time: std::io::Result) -> u32 { - time.ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| (d.as_secs() / 60).min(u32::MAX as u64) as u32) - .unwrap_or(0) -} - -/// Size every file in `slice`, and drop every directory that turns out to sit -/// on another volume — a mounted backup disk under the folder being measured -/// is not that folder's bytes, and counting it would make every number on the -/// map wrong. -/// -/// `symlink_metadata` never traverses the link, so a symlink is sized by the -/// link itself and never by whatever it points at. -fn stat_all(slice: &mut [Found], device: Option) { - for item in slice { - if item.is_dir { - item.keep = device.is_none() || device_of(&item.path) == device; - } else if let Ok(meta) = fs::symlink_metadata(&item.path) { - item.size = meta.len(); - // Free with the stat already in hand — this is what lets the map - // answer "show me only what's new". - item.modified = minutes_since_epoch(meta.modified()); - } - } -} - -fn stubs(listing: &[Listed]) -> Vec { - listing - .iter() - .map(|l| { - if l.is_dir { - Node::dir(l.name.clone(), l.kind) - } else { - Node::file_at(l.name.clone(), l.kind, l.size, l.modified) - } - }) - .collect() -} - -/// Running totals for the directory a thread is currently chewing on, -/// reported at a bounded rate so its rectangle grows on screen without the -/// report channel becoming the bottleneck. -struct Growth<'a> { - sink: &'a (dyn Fn(ScanStep) + Sync), - /// How many folders the pool still owes an answer for. Read rather than - /// computed, so a thread that has been inside one enormous directory for - /// a minute still reports a number that moves — a counter that freezes - /// looks exactly like a scan that has died. - open: &'a AtomicU32, - at: Vec, - size: u64, - files: u32, - due: Instant, - pace_due: Instant, -} - -impl<'a> Growth<'a> { - fn new(sink: &'a (dyn Fn(ScanStep) + Sync), open: &'a AtomicU32) -> Growth<'a> { - Growth { - sink, - open, - at: Vec::new(), - size: 0, - files: 0, - due: Instant::now() + GROW_EVERY, - pace_due: Instant::now(), - } - } - - /// Point the running total at a different node and start it over. - fn start(&mut self, at: &[u32]) { - self.at.clear(); - self.at.extend_from_slice(at); - self.size = 0; - self.files = 0; - } - - /// Report how much of the tree is still unopened, at the same bounded - /// rate as everything else — a folder finishes thousands of times a - /// second in a build tree and the number on screen does not need to. - fn pace(&mut self) { - let folders_left = self.open.load(Ordering::Relaxed); - let now = Instant::now(); - if now >= self.pace_due || folders_left == 0 { - self.pace_due = now + GROW_EVERY; - (self.sink)(ScanStep::Pace { folders_left }); - } - } - - fn add(&mut self, size: u64) { - self.size += size; - self.files += 1; - let now = Instant::now(); - if now < self.due { - return; - } - self.due = now + GROW_EVERY; - // The queue depth rides along on the same clock, so it keeps moving - // even while this thread is stuck inside one huge directory. - self.pace(); - if self.at.is_empty() { - return; - } - (self.sink)(ScanStep::Growing { - at: self.at.clone(), - size: self.size, - files: self.files, - }); - } -} - -/// One directory the walk still owes an answer for. -struct Job { - path: PathBuf, - at: Vec, -} - -/// The walk's shared state: folders waiting to be read, and how many threads -/// are inside one right now. A thread that finds the stack empty *and* nobody -/// working knows the walk is over — that is the only termination condition, -/// and it is why the two live under the same lock. -struct Queue { - jobs: Vec, - working: usize, -} - -/// A directory with at least this many entries has its metadata read by -/// several threads at once. Below it the coordination costs more than the -/// wait it saves. -const STAT_PARALLEL_MIN: usize = 1024; -/// Threads one big directory's metadata read is split across. Small on -/// purpose: several folders can be doing this at once, and past a handful of -/// outstanding requests a disk stops going any faster. -const STAT_THREADS: usize = 4; - -/// Threads the walk uses. -/// -/// A single thread is not the answer: walking a tree is latency-bound on -/// every filesystem worth the name. Neither is a thread per top-level folder, -/// which is what this used to be — a home directory is one enormous `Library` -/// and twenty small things, so within a second the "parallel" scan is one -/// thread doing all of the work. Every folder is a work item and any idle -/// thread takes the next one, so the threads stay busy right down to the -/// last directory of the deepest build tree. -const SCAN_THREADS: usize = 6; - -/// Walk `root`, streaming the tree back through `sink` as it is discovered. -/// -/// The root's own listing goes out first, so a caller has a drawable map -/// within one `read_dir`. Every folder — at any depth — then becomes a work -/// item: it announces its own listing and hands its subfolders back to the -/// pool. There is deliberately no depth cutoff: an earlier design walked deep -/// subtrees whole and delivered them in one piece, and on a disk whose bytes -/// sit in one enormous subtree that meant the map showed a single opaque -/// growing block for minutes and then everything at once. -/// -/// Returns false when the walk was cancelled, so a caller never paints a -/// half-built tree as if it were the finished picture. -pub fn scan_stream( - root: &Path, - rules: &ScanRules, - cancel: &AtomicBool, - sink: &(dyn Fn(ScanStep) + Sync), -) -> bool { - if cancel.load(Ordering::Relaxed) { - return false; - } - let device = device_of(root); - let queue = Mutex::new(Queue { - jobs: vec![Job { - path: root.to_path_buf(), - at: Vec::new(), - }], - working: 0, - }); - let wake = Condvar::new(); - let open = AtomicU32::new(1); - thread::scope(|scope| { - for _ in 0..SCAN_THREADS { - let queue = &queue; - let wake = &wake; - let open = &open; - scope.spawn(move || { - let mut growth = Growth::new(sink, open); - while let Some(job) = take(queue, wake, cancel) { - let children = run_job(job, rules, device, cancel, sink, &mut growth); - finish(queue, wake, children, open); - growth.pace(); - } - }); - } - }); - !cancel.load(Ordering::Relaxed) -} - -/// The next folder to read, or `None` when the walk is over — the stack is -/// empty and no thread is still inside a folder that could refill it. -/// -/// Nothing is reported from in here. A sink call under this lock would -/// serialise every worker behind whatever the caller does with a step, and a -/// sink that panicked would leave `working` counted forever and hang the pool. -fn take(queue: &Mutex, wake: &Condvar, cancel: &AtomicBool) -> Option { - let mut queue = queue.lock().unwrap_or_else(|e| e.into_inner()); - loop { - if cancel.load(Ordering::Relaxed) { - wake.notify_all(); - return None; - } - if let Some(job) = queue.jobs.pop() { - queue.working += 1; - return Some(job); - } - if queue.working == 0 { - // Nobody is left who could push more work, so there will not be - // any. Every other waiter has to hear that too. - wake.notify_all(); - return None; - } - queue = wake.wait(queue).unwrap_or_else(|e| e.into_inner()); - } -} - -/// Hand a folder's subfolders back to the pool and stop counting as busy. -/// Returns how many folders are left to open. -fn finish(queue: &Mutex, wake: &Condvar, children: Vec, open: &AtomicU32) { - { - let mut queue = queue.lock().unwrap_or_else(|e| e.into_inner()); - queue.jobs.extend(children); - queue.working -= 1; - open.store((queue.jobs.len() + queue.working) as u32, Ordering::Relaxed); - } - // Outside the lock: every waiting thread is about to try to take it. - wake.notify_all(); -} - -/// Read one folder: announce its listing, hand its subfolders back to the -/// pool as work items of their own. -fn run_job( - job: Job, - rules: &ScanRules, - device: Option, - cancel: &AtomicBool, - sink: &(dyn Fn(ScanStep) + Sync), - growth: &mut Growth, -) -> Vec { - if cancel.load(Ordering::Relaxed) { - return Vec::new(); - } - // The running total belongs to this folder while its own listing is being - // read, which on a folder with a quarter of a million files is most of - // the time this job takes. - growth.start(&job.at); - let listing = read_listing(&job.path, rules, device, growth); - growth.start(&[]); - sink(ScanStep::Opened { - at: job.at.clone(), - children: stubs(&listing.entries), - denied: listing.denied, - }); - listing - .entries - .into_iter() - .enumerate() - .filter(|(_, entry)| entry.is_dir) - .map(|(index, entry)| { - let mut at = job.at.clone(); - at.push(index as u32); - Job { - path: entry.path, - at, - } - }) - .collect() -} - -/// Walk `root` recursively and hand back the whole tree at once. The simple -/// blocking form, for callers that only want a total — the map itself uses -/// [`scan_stream`]. -pub fn scan( - root: &Path, - rules: &ScanRules, - cancel: &AtomicBool, - progress: &dyn Fn(ScanProgress), -) -> Option { - let mut total = ScanProgress::default(); - let mut since = 0u64; - let name = root - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| root.display().to_string()); - let kind = (rules.classify)(root, true); - let device = device_of(root); - let node = scan_blocking( - root, - name, - kind, - rules, - device, - cancel, - progress, - &mut total, - &mut since, - )?; - // One last report so a caller that only reads the callback's argument - // after the walk returns still sees the true final tally, rather than - // whatever the last stride boundary happened to leave behind. - progress(total); - Some(node) -} - -#[allow(clippy::too_many_arguments)] -fn scan_blocking( - dir: &Path, - name: String, - kind: u8, - rules: &ScanRules, - device: Option, - cancel: &AtomicBool, - progress: &dyn Fn(ScanProgress), - total: &mut ScanProgress, - since: &mut u64, -) -> Option { - // Checked on every directory, so a cancelled scan stops within a fraction - // of a second rather than at the end of the walk. - if cancel.load(Ordering::Relaxed) { - return None; - } - let idle = AtomicU32::new(0); - let listing = read_listing(dir, rules, device, &mut Growth::new(&|_| {}, &idle)); - let denied = listing.denied; - let mut children = Vec::with_capacity(listing.entries.len()); - for entry in listing.entries { - if entry.is_dir { - children.push(scan_blocking( - &entry.path, - entry.name, - entry.kind, - rules, - device, - cancel, - progress, - total, - since, - )?); - } else { - total.files += 1; - total.bytes += entry.size; - *since += 1; - if *since >= PROGRESS_STRIDE { - *since = 0; - progress(*total); - } - children.push(Node::file_at(entry.name, entry.kind, entry.size, entry.modified)); - } - } - Some(Node { - size: children.iter().map(|c| c.size).sum(), - files: children.iter().map(|c| c.files).sum(), - modified: children.iter().map(|c| c.modified).max().unwrap_or(0), - kind: heaviest_kind(&children).unwrap_or(kind), - name, - is_dir: true, - done: true, - denied, - children, - }) -} - -// ---------------------------------------------------------------- geometry - -/// The squarified treemap layout of `sizes` inside `rect`, one output rect -/// per input, in the same order as `sizes`. Bruls, Huizing & van Wijk -/// (2000): lay children out in rows along the rect's shorter side, closing a -/// row the moment adding the next child would make the row's worst aspect -/// ratio worse rather than better. That local greedy rule is what keeps -/// treemap cells close to square instead of degenerating into the thin -/// slivers a naive slice-and-dice layout produces. -#[allow(dead_code)] // production packs in canon space; the tests keep this as the oracle -pub fn squarify(sizes: &[u64], rect: Rect) -> Vec { - let n = sizes.len(); - let mut out = vec![ - Rect { - x: rect.x, - y: rect.y, - w: 0.0, - h: 0.0 - }; - n - ]; - if n == 0 || rect.w <= 0.0 || rect.h <= 0.0 { - return out; - } - let total: f64 = sizes.iter().map(|&s| s as f64).sum(); - if total <= 0.0 { - // Every input is zero: every output stays the zero-area rect `out` - // was already filled with, and there is nothing to lay out. - return out; - } - // The layout math below divides by a row's own thickness, which is - // zero for a zero-size item. Rather than guard every division, the - // zero entries are filtered out up front and left as the zero rects - // `out` already holds; only the strictly positive sizes go through the - // real algorithm, sorted descending as it requires. - let mut order: Vec = (0..n).filter(|&i| sizes[i] > 0).collect(); - // Size descending, input index as the tiebreak: equal sizes are - // everywhere on a real disk (shards, dedup'd assets), and a sort that - // may swap them between two layouts of the same data is a map that - // shuffles its tiles every time the camera settles. - order.sort_unstable_by(|&a, &b| sizes[b].cmp(&sizes[a]).then(a.cmp(&b))); - // Scale byte counts to areas that sum exactly to the container's area — - // this is what makes every output rect's area proportional to its size. - let scale = rect.area() / total; - let scaled: Vec = order.iter().map(|&i| sizes[i] as f64 * scale).collect(); - let placed = squarify_rows(&scaled, rect); - for (slot, &original) in order.iter().enumerate() { - out[original] = placed[slot]; - } - out -} - -/// The core algorithm on pre-scaled, strictly positive, descending-sorted -/// areas. Returns one rect per input, in input order. -#[allow(dead_code)] // production packs in canon space; the tests keep this as the oracle -fn squarify_rows(areas: &[f64], mut rect: Rect) -> Vec { - let mut out = Vec::with_capacity(areas.len()); - let mut start = 0; - while start < areas.len() { - if rect.w <= 0.0 || rect.h <= 0.0 { - // Floating-point drift can shave the leftover rect down to - // nothing a touch early; whatever is left just becomes - // zero-area rects instead of a division by zero. - for _ in start..areas.len() { - out.push(Rect { - x: rect.x, - y: rect.y, - w: 0.0, - h: 0.0, - }); - } - break; - } - // Grow the row one item at a time for as long as doing so does not - // make its worst aspect ratio worse — the "squarified" rule. - let mut end = start + 1; - let mut current = worst_ratio(&areas[start..end], rect); - while end < areas.len() { - let grown = worst_ratio(&areas[start..end + 1], rect); - if grown <= current { - current = grown; - end += 1; - } else { - break; - } - } - let row = &areas[start..end]; - out.extend(lay_out_row(row, rect)); - rect = leftover(row, rect); - start = end; - } - out -} - -/// One row's rects: a strip spanning the rect's shorter side, subdivided -/// among `areas` in proportion to their size, with the strip's thickness -/// along the longer side set so the strip's total area equals `sum(areas)`. -fn lay_out_row(areas: &[f64], rect: Rect) -> Vec { - if rect.w >= rect.h { - lay_out_row_stacked(areas, rect) - } else { - lay_out_row_flowed(areas, rect) - } -} - -/// The rect is at least as wide as it is tall, so the row becomes a -/// vertical strip at the left edge, itself subdivided top to bottom. -fn lay_out_row_stacked(areas: &[f64], rect: Rect) -> Vec { - let covered: f64 = areas.iter().sum(); - let width = if rect.h > 0.0 { covered / rect.h } else { 0.0 }; - let mut y = rect.y; - let mut out = Vec::with_capacity(areas.len()); - for &a in areas { - let h = if width > 0.0 { a / width } else { 0.0 }; - out.push(Rect { x: rect.x, y, w: width, h }); - y += h; - } - out -} - -/// The rect is taller than it is wide, so the row becomes a horizontal -/// strip at the top edge, itself subdivided left to right. -fn lay_out_row_flowed(areas: &[f64], rect: Rect) -> Vec { - let covered: f64 = areas.iter().sum(); - let height = if rect.w > 0.0 { covered / rect.w } else { 0.0 }; - let mut x = rect.x; - let mut out = Vec::with_capacity(areas.len()); - for &a in areas { - let w = if height > 0.0 { a / height } else { 0.0 }; - out.push(Rect { x, y: rect.y, w, h: height }); - x += w; - } - out -} - -/// What remains of `rect` after placing a row for `areas`: the same rect -/// with the row's strip removed from whichever side it occupied. Clamped to -/// zero rather than left to go slightly negative under floating-point -/// rounding, so the next iteration's "is this rect degenerate" check is -/// exact instead of an epsilon comparison. -fn leftover(areas: &[f64], rect: Rect) -> Rect { - let covered: f64 = areas.iter().sum(); - if rect.w >= rect.h { - let width = if rect.h > 0.0 { covered / rect.h } else { 0.0 }; - Rect { - x: rect.x + width, - y: rect.y, - w: (rect.w - width).max(0.0), - h: rect.h, - } - } else { - let height = if rect.w > 0.0 { covered / rect.w } else { 0.0 }; - Rect { - x: rect.x, - y: rect.y + height, - w: rect.w, - h: (rect.h - height).max(0.0), - } - } -} - -/// The worst (largest) aspect ratio among the rects a row of `areas` would -/// produce in `rect` — the number [`squarify_rows`] compares before and -/// after adding one more item, to decide whether the row should keep -/// growing or close. -fn worst_ratio(areas: &[f64], rect: Rect) -> f64 { - lay_out_row(areas, rect) - .iter() - .map(|r| { - if r.w <= 0.0 || r.h <= 0.0 { - f64::INFINITY - } else { - (r.w / r.h).max(r.h / r.w) - } - }) - .fold(0.0_f64, f64::max) -} - -// ------------------------------------------------------------------ filter - -/// What the filter box means. Every field is ANDed; a file matches when it -/// passes all of them, and a folder's filtered size is the sum of its -/// matching files — so under ".mov" the map is literally "where do my movie -/// bytes live", and folders holding none of them vanish. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Query { - /// Lowercase substrings. A term is satisfied by the file's own name or - /// by any folder on the way down to it — "cache" means everything under - /// a cache folder, which is what a person pointing at a treemap means. - pub names: Vec, - /// Lowercase extensions, no dot. Any of these (ORed) when non-empty. - pub exts: Vec, - pub min_size: Option, - pub max_size: Option, - /// Only files at least this new: minutes since the epoch. - pub newer_than: Option, - /// Only files at least this old — `>1y` finds the forgotten stuff. - pub older_than: Option, - /// Allowed kind tags as a bitmask over [`Node::kind`]; None = all kinds. - pub kinds: Option, -} - -impl Query { - /// True when this query filters nothing — the map is the whole disk. - pub fn is_empty(&self) -> bool { - *self == Query::default() - } - - /// Parse the typed form: whitespace-separated terms, ANDed. - /// `>100mb` / `<2gb` / `>=1kb` bound file sizes; `<7d` / `<24h` / `<2w` / - /// `<3mo` / `<1y` mean "modified within"; `.mov` / `*.mov` match an - /// extension; anything else is a name substring. `now_min` is the current - /// time in minutes since the epoch, for the age terms. - pub fn parse(text: &str, now_min: u32) -> Query { - let mut query = Query::default(); - for raw in text.split_whitespace() { - let term = raw.to_lowercase(); - if let Some(rest) = term.strip_prefix("*.") { - if !rest.is_empty() { - query.exts.push(rest.to_string()); - } - } else if let Some(rest) = term.strip_prefix('.') { - if !rest.is_empty() && rest.chars().all(|c| c.is_alphanumeric()) { - query.exts.push(rest.to_string()); - } - } else if let Some(bound) = term - .strip_prefix(">=") - .or_else(|| term.strip_prefix('>')) - { - if let Some(minutes) = parse_age(bound) { - // ">7d" reads as "older than a week" — untouched since. - query.older_than = Some(now_min.saturating_sub(minutes)); - } else if let Some(bytes) = parse_size(bound) { - query.min_size = Some(bytes); - } else { - query.names.push(term); - } - } else if let Some(bound) = term - .strip_prefix("<=") - .or_else(|| term.strip_prefix('<')) - { - if let Some(minutes) = parse_age(bound) { - query.newer_than = Some(now_min.saturating_sub(minutes)); - } else if let Some(bytes) = parse_size(bound) { - query.max_size = Some(bytes); - } else { - query.names.push(term); - } - } else { - query.names.push(term); - } - } - query - } - - /// The bitmask of name terms `name` satisfies. - pub fn name_hits(&self, name: &str) -> u32 { - let mut hits = 0u32; - if self.names.is_empty() { - return 0; - } - let lower = name.to_lowercase(); - for (index, term) in self.names.iter().enumerate().take(32) { - if lower.contains(term.as_str()) { - hits |= 1 << index; - } - } - hits - } - - /// The mask that means "every name term satisfied". - fn all_names(&self) -> u32 { - if self.names.is_empty() { - 0 - } else { - (1u32 << self.names.len().min(32)) - 1 - } - } - - /// Whether one file passes, given the name terms its folders already - /// satisfied on the way down. - fn file_matches(&self, node: &Node, inherited: u32) -> bool { - if let Some(min) = self.min_size { - if node.size < min { - return false; - } - } - if let Some(max) = self.max_size { - if node.size > max { - return false; - } - } - if let Some(cutoff) = self.newer_than { - if node.modified < cutoff { - return false; - } - } - if let Some(cutoff) = self.older_than { - if node.modified > cutoff { - return false; - } - } - if let Some(kinds) = self.kinds { - if kinds & (1u16 << (node.kind as u32).min(15)) == 0 { - return false; - } - } - if !self.exts.is_empty() { - let lower = node.name.to_lowercase(); - if !self - .exts - .iter() - .any(|ext| lower.len() > ext.len() && lower.ends_with(ext.as_str()) - && lower.as_bytes()[lower.len() - ext.len() - 1] == b'.') - { - return false; - } - } - (inherited | self.name_hits(&node.name)) == self.all_names() - } -} - -/// "100mb" -> bytes. 1000-based, like every number this app prints. -fn parse_size(text: &str) -> Option { - let unit_at = text.find(|c: char| c.is_alphabetic())?; - let value: f64 = text[..unit_at].parse().ok()?; - let scale: u64 = match &text[unit_at..] { - "b" => 1, - "k" | "kb" => 1_000, - "m" | "mb" => 1_000_000, - "g" | "gb" => 1_000_000_000, - "t" | "tb" => 1_000_000_000_000, - _ => return None, - }; - (value >= 0.0).then(|| (value * scale as f64) as u64) -} - -/// "7d" -> minutes. Hours, days, weeks, months, years. -fn parse_age(text: &str) -> Option { - let unit_at = text.find(|c: char| c.is_alphabetic())?; - let value: f64 = text[..unit_at].parse().ok()?; - let scale: u32 = match &text[unit_at..] { - "h" => 60, - "d" => 60 * 24, - "w" => 60 * 24 * 7, - "mo" => 60 * 24 * 30, - "y" => 60 * 24 * 365, - _ => return None, - }; - (value >= 0.0).then(|| (value * scale as f64) as u32) -} - -/// The bytes and files under `node` that pass `query`, with `inherited` name -/// terms already satisfied by the folders above. One prune-walk — this is -/// what a keystroke in the filter box costs. -#[allow(dead_code)] // production packs in canon space; the tests keep this as the oracle -pub fn filtered_size(node: &Node, query: &Query, inherited: u32) -> (u64, u32) { - if !node.is_dir { - return if query.file_matches(node, inherited) { - (node.size, 1) - } else { - (0, 0) - }; - } - let inherited = inherited | query.name_hits(&node.name); - let mut bytes = 0u64; - let mut files = 0u32; - for child in &node.children { - let (b, f) = filtered_size(child, query, inherited); - bytes += b; - files += f; - } - (bytes, files) -} - -/// The filtered weight of every node, mirrored in the tree's own shape: -/// `children` aligns index-for-index with the node's children. Measured once -/// per (tree, query) and read by every relayout after that — the camera -/// moves every frame, the answer to "which bytes match" does not. Without -/// this, a filtered layout re-walked whole subtrees at every nesting level -/// of every frame of an orbit, and the frame rate showed it. -pub struct Measure { - pub bytes: u64, - pub files: u32, - pub children: Vec, -} - -/// One O(n) walk answering `query` for every node at once. `inherited` is -/// the name-term mask the folders above `node` already satisfied. -pub fn measure(node: &Node, query: &Query, inherited: u32) -> Measure { - if !node.is_dir { - return if query.file_matches(node, inherited) { - Measure { bytes: node.size, files: 1, children: Vec::new() } - } else { - Measure { bytes: 0, files: 0, children: Vec::new() } - }; - } - let inherited = inherited | query.name_hits(&node.name); - let children: Vec = node - .children - .iter() - .map(|child| measure(child, query, inherited)) - .collect(); - Measure { - bytes: children.iter().map(|m| m.bytes).sum(), - files: children.iter().map(|m| m.files).sum(), - children, - } -} - -/// Bytes per kind tag under `node` — the legend's numbers. One walk. -pub fn kind_totals(node: &Node) -> [u64; 16] { - let mut totals = [0u64; 16]; - fn add(node: &Node, totals: &mut [u64; 16]) { - if node.is_dir { - for child in &node.children { - add(child, totals); - } - } else { - totals[(node.kind as usize).min(15)] += node.size; - } - } - add(node, &mut totals); - totals -} - -// ------------------------------------------------------------------ layout - -/// The pixel sizes that decide how far down the map goes. Every one of them -/// is a statement about what a person can see, which is why the layout has no -/// depth limit at all: it stops where the picture stops saying anything, and -/// on a big enough screen that is at the individual file. -#[derive(Clone, Copy, Debug)] -pub struct MapStyle { - /// A rectangle thinner than this on either edge is not drawn. - pub min_side: f64, - /// Where refinement stops: once a row's lead rectangle would come out - /// smaller than this, that row and everything after it are drawn as one - /// "N smaller items" plate over the region their rows would occupy. - /// Deliberately NOT an input to the packing itself — the arrangement is - /// fixed by the weights and the rect's aspect alone, so a zoom can only - /// refine the plate in place, never re-shuffle what was already visible. - /// This is also what bounds the cost of a folder to its pixels rather - /// than to how many files it holds. - pub min_area: f64, - /// The border a folder insets its children by, as a *fraction of the - /// packing area's short side* — never a point size. A point-sized inset - /// made the children's share of their frame depend on the zoom, so the - /// whole map "breathed" as it scaled; a fractional one rides the zoom - /// exactly, which is what makes the geometry a pure function of map - /// space. Narrowing with depth falls out on its own: each level's inset - /// is a fraction of an area that is itself smaller. - pub inset: f64, - /// How tall a group's floating name is drawn, in points. Purely a draw - /// hint — a name never reserves layout room, because a strip that - /// appears at some zoom is a strip that shoves children at that zoom. - pub header: f64, - /// A folder needs to be at least this wide and tall on screen before its - /// floating name is worth drawing; below it, the name would cost more - /// than it tells. Gates drawing only — never geometry. - pub header_min: (f64, f64), - /// A folder whose inside comes out smaller than this on either edge is - /// drawn as one plate instead of being opened up — nesting borders - /// thinner than this are all border and no bytes. - pub group_min: f64, - /// A hard ceiling on rectangles, so a pathological tree cannot make one - /// frame take a second. - pub max_cells: usize, -} - -impl Default for MapStyle { - fn default() -> Self { - MapStyle { - min_side: 2.0, - min_area: 9.0, - // ~3pt at the top level of a default-height window (≈735pt), - // matching the old point-sized look at zoom 1 exactly where it - // was calibrated, then scaling with whatever it frames. - inset: 0.004, - header: 12.0, - header_min: (58.0, 34.0), - group_min: 6.0, - max_cells: 60_000, - } - } -} - -/// One drawable rectangle of the finished map — a folder or a file, already -/// positioned, with nothing left for a view to compute except paint it. -#[derive(Clone, Debug)] -pub struct Cell { - pub path: PathBuf, - pub name: String, - pub size: u64, - pub files: u32, - pub is_dir: bool, - pub kind: u8, - /// 0 for the mapped folder's own children, 1 for their children, and so - /// on — how many group borders separate this cell from the root. - pub depth: usize, - pub rect: Rect, - /// True when this cell is a folder drawn as a bordered group whose - /// children are also in the output; false for a file, and false for a - /// folder too small to open up. - pub is_group: bool, - /// The header strip this group earned, in points; 0 when it earned none. - pub header: f64, - /// True while the scan is still filling this subtree in. - pub pending: bool, - /// When non-zero this cell stands for that many sibling entries at once, - /// each too small to draw on its own. - pub extra: u32, -} - -impl Cell { - /// Whether this cell is the "N smaller items" aggregate rather than one - /// real file or folder. - pub fn is_bundle(&self) -> bool { - self.extra > 0 - } -} - -/// Flatten `node`'s children into drawable cells inside `area`. -/// -/// `root_path` is the folder `node` stands for; every cell's path is built -/// from it and the names on the way down, which is why the tree itself does -/// not carry paths. -/// -/// `viewport` is the part of `area` actually on screen. When a camera has -/// zoomed the map, `area` is the whole map at its blown-up size and the -/// viewport is the window into it: everything outside is skipped entirely — -/// no cell, no recursion — which is what keeps a deep zoom costing what the -/// pixels on screen cost rather than what the whole magnified map would. -/// With no camera the two are the same rect. -/// -/// The output is in painter's order: a group's own cell always comes before -/// its children, so a caller drawing the vector front to back gets children -/// on top of their group for free, with no separate z-ordering step. -pub fn layout( - node: &Node, - root_path: &Path, - area: Rect, - viewport: Rect, - style: &MapStyle, - filter: Option<&Measure>, -) -> Vec { - let mut out = Vec::new(); - let mut path = root_path.to_path_buf(); - // A stale measure — one made of a different tree — must never index out - // of step with the children; the caller keys its cache on the tree - // revision, and this is the belt to that suspender. - let filter = filter.filter(|m| m.children.len() == node.children.len()); - // At the root the canonical packing space IS the area: the body's aspect - // is the same at every zoom, so invariance starts true and the recursion - // keeps it true (see `layout_children` on what canon is for). - layout_children(&node.children, &mut path, area, area, viewport, 0, style, filter, &mut out); - out -} - -/// `canon` is the packing space: a rect with the same area as `area` but a -/// *canonical* aspect, derived purely from the map's own zoom-invariant -/// proportions — never from the point-sized insets and header strips that -/// make the realized `area`'s aspect wobble a few percent as the zoom -/// changes. All row-membership decisions run in canon space and the finished -/// geometry is mapped affinely onto `area`, so the arrangement literally -/// cannot drift with zoom: the packer never sees a zoom-dependent number. -/// The price is rows optimized for an aspect a few percent off the realized -/// one — a squareness error far below what the eye notices, where a row -/// re-break is exactly what the eye is drawn to. -#[allow(clippy::too_many_arguments)] -fn layout_children( - children: &[Node], - path: &mut PathBuf, - area: Rect, - canon: Rect, - viewport: Rect, - depth: usize, - style: &MapStyle, - filter: Option<&Measure>, - out: &mut Vec, -) { - if children.is_empty() || area.w <= 0.0 || area.h <= 0.0 || out.len() >= style.max_cells { - return; - } - if canon.w <= 0.0 || canon.h <= 0.0 { - return; - } - if !area.intersects(&viewport) { - return; - } - // Under a filter every child weighs only its matching bytes — the whole - // map re-proportions to the question being asked. The weights were all - // measured in one walk up front (see [`measure`]); reading them here is - // an index, not a subtree walk. The unfiltered path costs nothing extra. - let measured = filter.map(|m| &m.children); - let weight = |index: usize| match measured { - Some(list) => list[index].bytes, - None => children[index].size, - }; - let files_of = |index: usize| match measured { - Some(list) => list[index].files, - None => children[index].files, - }; - let total: f64 = (0..children.len()).map(|i| weight(i) as f64).sum(); - if total <= 0.0 { - return; - } - // The packing is of ALL the children, always. An earlier design fed only - // the children big enough to see into the packer and swept the rest into - // a synthetic "smaller items" entry — which made the packer's INPUT - // depend on the zoom, so crossing any zoom step re-packed the whole - // group and the map visibly reshuffled. Now the arrangement is fixed by - // the weights and the rect's aspect alone — both zoom-invariant — and - // the zoom only decides how far down the row list refinement runs. - // - // Cost stays bounded by the pixels, not the child count, because the - // rows come out biggest-first: everything too small to see is a suffix - // of the sorted order, so only the items that could reach a visible row - // need sorting at all. `sort_floor` keeps a 64× margin below the - // visibility cutoff so the last visible row closes on exactly the - // neighbours the full sort would have offered it — an item 64× smaller - // than a row-mate makes the aspect test slam the row shut long before, - // so nothing below the margin can ever influence visible geometry. - // The canon packing space, re-anchored at the origin and normalized to - // the realized rect's exact area: row math happens here, so membership - // depends only on the canonical aspect and the weights; the stop - // thresholds stay honest because canon areas equal screen areas. - let canon = { - let aspect = canon.w / canon.h; - Rect { - x: 0.0, - y: 0.0, - w: (area.area() * aspect).sqrt(), - h: (area.area() / aspect).sqrt(), - } - }; - let realize = |r: &Rect| Rect { - x: area.x + r.x / canon.w * area.w, - y: area.y + r.y / canon.h * area.h, - w: r.w / canon.w * area.w, - h: r.h / canon.h * area.h, - }; - let scale = area.area() / total; // square points per byte - let tail_floor = (style.min_area / scale).max(1.0); - let sort_floor = (tail_floor / 64.0).max(1.0) as u64; - let mut order: Vec = Vec::new(); - let mut rest_size: u64 = 0; - let mut rest_count: u32 = 0; - let mut rest_files: u32 = 0; - let mut max_weight: u64 = 0; - for i in 0..children.len() { - let w = weight(i); - max_weight = max_weight.max(w); - if w >= sort_floor { - order.push(i); - } else if w > 0 { - rest_size += w; - rest_count += 1; - rest_files += files_of(i); - } - } - // When even the biggest child is below the visibility floor the whole - // group is one tail plate — no order, no sort, no rows. - if (max_weight as f64) * scale < style.min_area { - let count = rest_count as usize + order.len(); - if count > 0 - && area.w >= style.min_side - && area.h >= style.min_side - && out.len() < style.max_cells - { - out.push(Cell { - path: path.clone(), - name: format!("{count} smaller item{}", if count == 1 { "" } else { "s" }), - size: rest_size + order.iter().map(|&i| weight(i)).sum::(), - files: rest_files + order.iter().map(|&i| files_of(i)).sum::(), - is_dir: false, - kind: u8::MAX, - depth, - rect: area, - is_group: false, - header: 0.0, - pending: false, - extra: count as u32, - }); - } - return; - } - // The sort is cut at what the pixels can hold before sorting: an item - // ranked past `area / min_area` has, by descending order, less than - // `min_area` to its name, so it lives in the tail plate and only its - // sum matters — its exact position in the order buys nothing. This is - // what keeps a quarter-million-file folder costing a selection pass, not - // a quarter-million-element sort, at every distance. - let cap = ((area.area() / style.min_area) as usize + 64).min(style.max_cells + 64); - if order.len() > cap { - order.select_nth_unstable_by(cap, |&a, &b| { - weight(b).cmp(&weight(a)).then(a.cmp(&b)) - }); - for &i in &order[cap..] { - rest_size += weight(i); - rest_count += 1; - rest_files += files_of(i); - } - order.truncate(cap); - } - // Descending, deterministic under ties (child index breaks them), so the - // same children lay out the same way every single time. - order.sort_unstable_by(|&a, &b| weight(b).cmp(&weight(a)).then(a.cmp(&b))); - let scaled: Vec = order.iter().map(|&i| weight(i) as f64 * scale).collect(); - - // Stream the squarified rows biggest-first, exactly as the full packing - // would place them, and stop refining at the first row whose lead item - // is too small to see. Everything from there on — plus whatever never - // made the sort — is drawn as one aggregate plate over the leftover - // rect, which is precisely the region those rows would occupy: zooming - // in only ever subdivides that plate in place, and nothing that was - // already on screen can move, because nothing about its inputs changed. - let mut leftover_canon = canon; - let mut start = 0usize; - while start < scaled.len() { - if leftover_canon.w <= 0.0 || leftover_canon.h <= 0.0 { - break; - } - if scaled[start] < style.min_area { - break; - } - if out.len() >= style.max_cells { - return; - } - if !realize(&leftover_canon).intersects(&viewport) { - // Everything still unplaced lives inside the leftover, and the - // leftover only ever shrinks toward one corner: once it has left - // the window, so has every remaining row and the tail plate. - return; - } - // Grow the row while doing so does not worsen its worst aspect — - // the squarified rule, unchanged, in canon space. - let mut end = start + 1; - let mut current = worst_ratio(&scaled[start..end], leftover_canon); - while end < scaled.len() { - let grown = worst_ratio(&scaled[start..end + 1], leftover_canon); - if grown <= current { - current = grown; - end += 1; - } else { - break; - } - } - // The strip this row occupies. A row whose strip misses the window - // still consumes its area — the leftover chain is the geometry — but - // its items need no rects, no cells and no recursion. - let next_leftover = leftover(&scaled[start..end], leftover_canon); - let strip = if leftover_canon.w >= leftover_canon.h { - Rect { - x: leftover_canon.x, - y: leftover_canon.y, - w: leftover_canon.w - next_leftover.w, - h: leftover_canon.h, - } - } else { - Rect { - x: leftover_canon.x, - y: leftover_canon.y, - w: leftover_canon.w, - h: leftover_canon.h - next_leftover.h, - } - }; - if !realize(&strip).intersects(&viewport) { - leftover_canon = next_leftover; - start = end; - continue; - } - let row_rects = lay_out_row(&scaled[start..end], leftover_canon); - for (slot, canon_rect) in row_rects.iter().enumerate() { - let rect = &realize(canon_rect); - if out.len() >= style.max_cells { - return; - } - if rect.w < style.min_side || rect.h < style.min_side { - // Invisible at this scale: drawing it would just be a - // sliver, and if it is a folder its children are smaller - // still. Skipping it moves nothing — the geometry of every - // neighbour was fixed before this test ran. - continue; - } - if !rect.intersects(&viewport) { - // Off the edge of the window the camera is looking through, - // and so is everything inside it. - continue; - } - let index = order[start + slot]; - let child = &children[index]; - // The frame around a group's children is a fraction of THIS - // level's packing area, so every sibling wears the same border - // and the border scales exactly with the zoom: the children's - // share of their frame is the same at every magnification, which - // is the last thing that used to make the map breathe. Names - // reserve nothing — a group's name floats over its children at - // draw time (`header` below is only the hint that it earned one). - let inner = rect.shrink(style.inset * area.w.min(area.h), 0.0); - let header = if child.is_dir - && rect.w >= style.header_min.0 - && rect.h >= style.header_min.1 - { - style.header - } else { - 0.0 - }; - let is_group = child.is_dir - && !child.children.is_empty() - && inner.w >= style.group_min - && inner.h >= style.group_min; - path.push(&child.name); - out.push(Cell { - path: path.clone(), - name: child.name.clone(), - size: weight(index), - files: files_of(index), - is_dir: child.is_dir, - kind: child.kind, - depth, - rect: *rect, - is_group, - header: if is_group { header } else { 0.0 }, - pending: child.is_dir && !child.done, - extra: 0, - }); - if is_group { - let filter = measured.map(|list| &list[index]); - // The child packs against its own raw canon rect — its - // zoom-invariant share of this packing — never against the - // inset-shrunk realized rect whose aspect wobbles with zoom. - layout_children( - &child.children, - path, - inner, - *canon_rect, - viewport, - depth + 1, - style, - filter, - out, - ); - } - path.pop(); - } - leftover_canon = next_leftover; - start = end; - } - - // The tail: every remaining sorted item plus everything below the sort - // margin, presented as the one "N smaller items" plate over the region - // their rows will occupy when a deeper zoom refines them into being. - let mut tail_size = rest_size; - let mut tail_count = rest_count; - let mut tail_files = rest_files; - for &i in &order[start..] { - tail_size += weight(i); - tail_count += 1; - tail_files += files_of(i); - } - let tail_rect = realize(&leftover_canon); - if tail_count > 0 - && tail_size > 0 - && tail_rect.w >= style.min_side - && tail_rect.h >= style.min_side - && tail_rect.intersects(&viewport) - && out.len() < style.max_cells - { - out.push(Cell { - path: path.clone(), - name: format!( - "{} smaller item{}", - tail_count, - if tail_count == 1 { "" } else { "s" } - ), - size: tail_size, - files: tail_files, - is_dir: false, - kind: u8::MAX, - depth, - rect: tail_rect, - is_group: false, - header: 0.0, - pending: false, - extra: tail_count, - }); - } -} - -/// The deepest (i.e. last in painter's order) cell containing the point — -/// the one a mouse at that point is actually pointing at, not whichever -/// group happens to sit behind it. -pub fn hit(cells: &[Cell], x: f64, y: f64) -> Option { - cells.iter().rposition(|c| c.rect.contains(x, y)) -} - -/// A byte count the way a tooltip reads it — "1.5 KB", "15 KB", "2.5 GB" — -/// using exactly the app's own 1000-based rounding (see `format_size` in -/// `model.rs`), because the same number should never look different just -/// for being shown in a different view of the same file. -pub fn format_bytes(bytes: u64) -> String { - const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; - let mut value = bytes as f64; - let mut unit = 0; - while value >= 1000.0 && unit < UNITS.len() - 1 { - value /= 1000.0; - unit += 1; - } - if unit == 0 { - format!("{} {}", bytes, UNITS[unit]) - } else if value >= 10.0 { - format!("{:.0} {}", value, UNITS[unit]) - } else { - format!("{:.1} {}", value, UNITS[unit]) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Mutex; - - fn leaf(name: &str, size: u64) -> Node { - Node::file(name.to_string(), 0, size) - } - - fn dir(name: &str, children: Vec) -> Node { - Node { - name: name.to_string(), - is_dir: true, - done: true, - denied: false, - kind: 0, - size: children.iter().map(|c| c.size).sum(), - files: children.iter().map(|c| c.files).sum(), - modified: children.iter().map(|c| c.modified).max().unwrap_or(0), - children, - } - } - - // The zoom-invariance contract, mechanically: pack a rich tree at a - // ladder of zooms over the same anchored viewport and demand that every - // cell present at two zooms sits in EXACTLY the same place in map space, - // at every nesting level, with no exemptions. Insets are fractions of - // map space and names reserve no room, so there is nothing left that may - // lawfully move — the only tolerance is floating-point noise. This is - // the test that fails when any zoom-dependent quantity leaks back into - // the geometry. - #[test] - fn the_arrangement_is_zoom_invariant_by_construction() { - // Mixed sizes, an equal-size run (the tie-swap trap), nesting, and - // one folder with ten thousand files (the bundle-floor trap). - let mut crowd = Vec::new(); - for i in 0..10_000u64 { - let size = ((i.wrapping_mul(2_654_435_761)) % 997 + 1) * 4096; - crowd.push(leaf(&format!("c{i}.bin"), size)); - } - let tree = dir( - "root", - vec![ - leaf("huge.mov", 6_000_000_000), - dir( - "nest", - vec![ - dir( - "deep", - vec![leaf("a.bin", 900_000_000), leaf("b.bin", 400_000_000)], - ), - leaf("c.bin", 700_000_000), - ], - ), - dir("crowd", crowd), - dir( - "equal", - (0..8).map(|i| leaf(&format!("e{i}.dat"), 50_000_000)).collect(), - ), - leaf("mid.tar", 350_000_000), - ], - ); - let style = MapStyle::default(); - let viewport = Rect { x: 0.0, y: 0.0, w: 1200.0, h: 800.0 }; - // The camera anchor: map point (0.3, 0.4) pinned to screen (400, 300) - // at every zoom, the way the view's zoom-at-cursor works. - let layout_at = |z: f64| { - let area = Rect { - x: 400.0 - 0.3 * 1200.0 * z, - y: 300.0 - 0.4 * 800.0 * z, - w: 1200.0 * z, - h: 800.0 * z, - }; - (layout(&tree, Path::new("/root"), area, viewport, &style, None), area) - }; - let zooms = [1.0, 2.0, 4.0, 8.0, 16.0, 32.0]; - let laid: Vec<_> = zooms.iter().map(|&z| layout_at(z)).collect(); - - for pair in laid.windows(2) { - let (cells_a, area_a) = &pair[0]; - let (cells_b, area_b) = &pair[1]; - check_invariant(cells_a, *area_a, cells_b, *area_b); - } - // And the far ends against each other: the full 32× throw. - let (first, area_first) = &laid[0]; - let (last, area_last) = &laid[laid.len() - 1]; - check_invariant(first, *area_first, last, *area_last); - } - - /// Every real cell present in both layouts must occupy exactly the same - /// map-space rect — width and height as well as centre — at every depth. - /// No drift budget, no exemptions: only floating-point noise is forgiven. - fn check_invariant(cells_a: &[Cell], area_a: Rect, cells_b: &[Cell], area_b: Rect) { - use std::collections::HashMap; - let by_path_a: HashMap<&Path, &Cell> = cells_a - .iter() - .filter(|c| !c.is_bundle()) - .map(|c| (c.path.as_path(), c)) - .collect(); - let mut compared = 0usize; - for cell in cells_b.iter().filter(|c| !c.is_bundle()) { - let Some(a) = by_path_a.get(cell.path.as_path()) else { - continue; - }; - // Map A's rect into B's screen space and compare all four numbers. - let scale_w = area_b.w / area_a.w; - let scale_h = area_b.h / area_a.h; - let expected = Rect { - x: area_b.x + (a.rect.x - area_a.x) * scale_w, - y: area_b.y + (a.rect.y - area_a.y) * scale_h, - w: a.rect.w * scale_w, - h: a.rect.h * scale_h, - }; - let ratio = (area_b.w / area_a.w).max(1.0); - // Millipoints at zoom 1, ~0.03pt across the full 32× throw — - // orders of magnitude under a pixel, and a genuine geometry - // change moves a tile by whole points at least. - let noise = 1e-3 * ratio; - for (got, want) in [ - (cell.rect.x, expected.x), - (cell.rect.y, expected.y), - (cell.rect.w, expected.w), - (cell.rect.h, expected.h), - ] { - assert!( - (got - want).abs() <= noise, - "{} moved between zooms (depth {}, {:?} vs expected {:?})", - cell.path.display(), - cell.depth, - cell.rect, - expected - ); - } - compared += 1; - } - assert!(compared > 20, "only {compared} cells survived both layouts — the test is not biting"); - } - - // The cost canary the bundle floor used to be for: a quarter-million - // files in one folder must cost the layout what its pixels cost, not - // what its listing costs. Run by hand with - // `cargo test -p mpfiles --release -- --ignored --nocapture`. - #[test] - #[ignore] - fn packing_cost_canary_200k() { - let mut crowd = Vec::new(); - for i in 0..200_000u64 { - let size = ((i.wrapping_mul(2_654_435_761)) % 9973 + 1) * 4096; - crowd.push(leaf(&format!("f{i}.bin"), size)); - } - let tree = dir( - "root", - vec![leaf("huge.mov", 900_000_000_000), dir("crowd", crowd)], - ); - let style = MapStyle::default(); - let viewport = Rect { x: 0.0, y: 0.0, w: 1200.0, h: 800.0 }; - // Far: the crowd is a small tile, its files all in the tail plate. - let far = Rect { x: 0.0, y: 0.0, w: 1200.0, h: 800.0 }; - // Near: 64x in, anchored inside the crowd so its files fill the panel. - let near = Rect { x: -20_000.0, y: -20_000.0, w: 1200.0 * 64.0, h: 800.0 * 64.0 }; - for (name, area) in [("far", far), ("near", near)] { - let t = std::time::Instant::now(); - let mut cells = 0usize; - const RUNS: u32 = 20; - for _ in 0..RUNS { - cells = layout(&tree, Path::new("/root"), area, viewport, &style, None).len(); - } - println!( - "200k-folder {name}: {:.2}ms per layout, {cells} cells", - t.elapsed().as_secs_f64() * 1000.0 / RUNS as f64 - ); - } - } - - /// The rules a test walks under: everything is generic, nothing is - /// skipped. The app's own policy is tested where the app defines it. - fn open_rules<'a>() -> ScanRules<'a> { - ScanRules { - classify: &|_: &Path, _: bool| 0u8, - skip: &|_: &Path| false, - } - } - - fn style() -> MapStyle { - MapStyle { - min_side: 1.0, - min_area: 1.0, - // A fraction of the packing area's short side, like the default: - // 1% keeps a visible margin at the test geometries (a 100pt rect - // frames its children by a full point) without eating them. - inset: 0.01, - header: 6.0, - header_min: (30.0, 20.0), - group_min: 3.0, - max_cells: 10_000, - } - } - - fn overlap_area(a: Rect, b: Rect) -> f64 { - let x_overlap = (a.x + a.w).min(b.x + b.w) - a.x.max(b.x); - let y_overlap = (a.y + a.h).min(b.y + b.h) - a.y.max(b.y); - if x_overlap > 1e-6 && y_overlap > 1e-6 { - x_overlap * y_overlap - } else { - 0.0 - } - } - - fn assert_no_overlaps(rects: &[Rect]) { - for i in 0..rects.len() { - for j in (i + 1)..rects.len() { - let overlap = overlap_area(rects[i], rects[j]); - assert!( - overlap < 1e-6, - "rects {} and {} overlap by {} ({:?} vs {:?})", - i, - j, - overlap, - rects[i], - rects[j] - ); - } - } - } - - fn assert_inside(rect: Rect, container: Rect) { - assert!(rect.x >= container.x - 1e-6); - assert!(rect.y >= container.y - 1e-6); - assert!(rect.x + rect.w <= container.x + container.w + 1e-6); - assert!(rect.y + rect.h <= container.y + container.h + 1e-6); - } - - // The exact example from Bruls, Huizing & van Wijk (2000): sizes that - // sum to the container's area, so proportionality is easy to check by - // hand as well as by assertion. - #[test] - fn squarify_known_case_is_valid() { - let sizes = [6u64, 6, 4, 3, 2, 2, 1]; - let rect = Rect { x: 0.0, y: 0.0, w: 6.0, h: 4.0 }; - let rects = squarify(&sizes, rect); - assert_eq!(rects.len(), sizes.len()); - let total: f64 = sizes.iter().map(|&s| s as f64).sum(); - for (i, r) in rects.iter().enumerate() { - assert_inside(*r, rect); - let expected = sizes[i] as f64 / total * rect.area(); - assert!( - (r.area() - expected).abs() < 1e-6, - "size {} -> area {} but expected {}", - sizes[i], - r.area(), - expected - ); - } - assert_no_overlaps(&rects); - let sum_areas: f64 = rects.iter().map(Rect::area).sum(); - assert!((sum_areas - rect.area()).abs() < 1e-6); - } - - // The property that makes this a *squarified* treemap rather than a - // slice-and-dice strip: equal-weight items come out close to square, - // not as sixteen slivers running the length of the rect. - #[test] - fn squarify_keeps_cells_reasonably_square() { - let sizes = [100u64; 16]; - let rect = Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }; - let rects = squarify(&sizes, rect); - for r in &rects { - let aspect = (r.w / r.h).max(r.h / r.w); - assert!(aspect < 2.0, "aspect {} too extreme for {:?}", aspect, r); - } - } - - // A long descending run is the shape a real folder has, and the shape a - // buggy row-closing rule turns into hairlines. - #[test] - fn squarify_stays_square_on_a_long_descending_run() { - let sizes: Vec = (1..=200).rev().map(|i| i as u64 * i as u64).collect(); - let rect = Rect { x: 0.0, y: 0.0, w: 900.0, h: 600.0 }; - let rects = squarify(&sizes, rect); - let worst = rects - .iter() - .filter(|r| r.area() > 4.0) - .map(|r| (r.w / r.h).max(r.h / r.w)) - .fold(0.0_f64, f64::max); - assert!(worst < 6.0, "worst aspect ratio {worst} is a hairline"); - assert_no_overlaps(&rects); - } - - #[test] - fn squarify_edge_cases() { - let rect = Rect { x: 1.0, y: 2.0, w: 10.0, h: 5.0 }; - - // Empty input gives empty output. - assert!(squarify(&[], rect).is_empty()); - - // A single item fills the whole rect exactly. - let single = squarify(&[42], rect); - assert_eq!(single.len(), 1); - assert!((single[0].x - rect.x).abs() < 1e-9); - assert!((single[0].y - rect.y).abs() < 1e-9); - assert!((single[0].w - rect.w).abs() < 1e-9); - assert!((single[0].h - rect.h).abs() < 1e-9); - - // All zero: every rect is zero-area, nothing panics or produces NaN. - let zeros = squarify(&[0, 0, 0], rect); - assert_eq!(zeros.len(), 3); - for r in &zeros { - assert_eq!(r.area(), 0.0); - assert!(!r.w.is_nan() && !r.h.is_nan()); - } - - // Mixed zero and non-zero: the zero entries get zero area, the - // rest still accounts for the whole rect between them. - let mixed = squarify(&[10, 0, 5, 0], rect); - assert_eq!(mixed.len(), 4); - assert_eq!(mixed[1].area(), 0.0); - assert_eq!(mixed[3].area(), 0.0); - assert!(mixed[0].area() > 0.0 && mixed[2].area() > 0.0); - let sum: f64 = mixed.iter().map(Rect::area).sum(); - assert!((sum - rect.area()).abs() < 1e-6); - for r in &mixed { - assert!(!r.w.is_nan() && !r.h.is_nan()); - } - } - - #[test] - fn layout_paints_groups_before_their_children() { - let tree = dir( - "root", - vec![ - dir("sub", vec![leaf("a.txt", 100), leaf("b.txt", 200)]), - leaf("c.txt", 50), - ], - ); - let area = Rect { x: 0.0, y: 0.0, w: 200.0, h: 100.0 }; - let cells = layout(&tree, Path::new("/root"), area, area, &style(), None); - - let sub_index = cells.iter().position(|c| c.name == "sub").unwrap(); - let a_index = cells.iter().position(|c| c.name == "a.txt").unwrap(); - let b_index = cells.iter().position(|c| c.name == "b.txt").unwrap(); - assert!(sub_index < a_index); - assert!(sub_index < b_index); - assert_eq!(cells[sub_index].depth, 0); - assert_eq!(cells[a_index].depth, 1); - assert_eq!(cells[b_index].depth, 1); - assert!(cells[sub_index].is_group); - assert!(!cells[a_index].is_group); - // Paths are rebuilt from the names on the way down. - assert_eq!(cells[a_index].path, Path::new("/root/sub/a.txt")); - } - - // The whole point of the rewrite: the map goes all the way down to the - // file, not two folders and then a flat plate. - #[test] - fn layout_reaches_individual_files_at_any_depth() { - let mut tree = leaf("buried.bin", 1_000_000); - for name in ["j", "i", "h", "g", "f", "e", "d", "c", "b", "a"] { - tree = dir(name, vec![tree]); - } - let tree = dir("root", vec![tree]); - let area = Rect { x: 0.0, y: 0.0, w: 800.0, h: 600.0 }; - let cells = layout(&tree, Path::new("/root"), area, area, &style(), None); - let buried = cells.iter().find(|c| c.name == "buried.bin").unwrap(); - assert_eq!(buried.depth, 10); - assert_eq!( - buried.path, - Path::new("/root/a/b/c/d/e/f/g/h/i/j/buried.bin") - ); - assert!(buried.rect.area() > 100.0); - } - - #[test] - fn layout_drops_slivers_below_min_side() { - let tree = dir("root", vec![leaf("big.bin", 1_000_000), leaf("tiny.bin", 1)]); - let area = Rect { x: 0.0, y: 0.0, w: 1000.0, h: 1000.0 }; - let mut style = style(); - style.min_side = 4.0; - style.min_area = 16.0; - let cells = layout(&tree, Path::new("/root"), area, area, &style, None); - assert!(cells.iter().any(|c| c.name == "big.bin")); - assert!(!cells.iter().any(|c| c.name == "tiny.bin")); - } - - // A folder with a quarter of a million tiny files must cost the map what - // its rectangle is worth, not what its listing is worth. - #[test] - fn layout_bundles_the_invisible_tail_instead_of_laying_it_out() { - let mut children = vec![leaf("big.bin", 500_000_000)]; - children.extend((0..50_000).map(|i| leaf(&format!("t{i}.tmp"), 100))); - let tree = dir("root", children); - let area = Rect { x: 0.0, y: 0.0, w: 600.0, h: 400.0 }; - let cells = layout(&tree, Path::new("/root"), area, area, &MapStyle::default(), None); - // Two rectangles: the big file, and one that says how many were left. - assert!(cells.len() < 8, "{} cells is a laid-out tail", cells.len()); - let bundle = cells.iter().find(|c| c.is_bundle()).unwrap(); - assert_eq!(bundle.extra, 50_000); - assert_eq!(bundle.size, 5_000_000); - // Every byte is still on the map: the bundle is a sum, not a cull. - let mapped: u64 = cells.iter().filter(|c| c.depth == 0).map(|c| c.size).sum(); - assert_eq!(mapped, tree.size); - } - - // The whole reason the map is worth keeping between runs: a delete is - // arithmetic on a tree we already have, not another walk of the disk. - #[test] - fn deleting_something_costs_no_scan_and_leaves_the_totals_right() { - let mut tree = dir( - "root", - vec![ - dir("movies", vec![leaf("big.mov", 900), leaf("small.mov", 100)]), - leaf("notes.txt", 25), - ], - ); - assert_eq!(tree.size, 1025); - assert_eq!(tree.files, 3); - - let gone = tree - .detach(&["movies".into(), "big.mov".into()]) - .expect("the file was on the map"); - assert_eq!(gone.size, 900); - // Every folder above it shrank by exactly what left. - assert_eq!(tree.size, 125); - assert_eq!(tree.files, 2); - assert_eq!(tree.at(&["movies".into()]).unwrap().size, 100); - - // Nothing is there to take twice. - assert!(tree.detach(&["movies".into(), "big.mov".into()]).is_none()); - } - - // Trash is a move, not a disappearance: if the Trash is inside the map, - // the bytes are still on it and the total must not change. - #[test] - fn a_move_inside_the_map_keeps_the_total() { - let mut tree = dir( - "root", - vec![ - dir("movies", vec![leaf("big.mov", 900)]), - dir("trash", vec![]), - ], - ); - let before = tree.size; - let node = tree.detach(&["movies".into(), "big.mov".into()]).unwrap(); - assert_eq!(tree.size, 0); - assert!(tree.graft(&["trash".into()], node)); - assert_eq!(tree.size, before); - assert_eq!(tree.at(&["trash".into()]).unwrap().size, 900); - // Somewhere the map is not of: the bytes really did leave. - let node = tree.detach(&["trash".into(), "big.mov".into()]).unwrap(); - assert!(!tree.graft(&["nowhere".into()], node)); - assert_eq!(tree.size, 0); - } - - // A folder we were refused is not a folder of zero bytes, and the map has - // to be able to say which ones they were. - #[test] - fn refused_folders_are_named_not_silently_dropped() { - let mut locked = dir("Documents", vec![]); - locked.denied = true; - let mut inner = dir("deep", vec![]); - inner.denied = true; - let tree = dir("root", vec![locked, dir("ok", vec![inner, leaf("a", 1)])]); - let named = tree.denied_paths(8); - assert_eq!(named, vec!["Documents".to_string(), "ok/deep".to_string()]); - // Bounded, because a list nobody can read is not a warning. - assert_eq!(tree.denied_paths(1).len(), 1); - } - - // The camera contract: blowing the map up N× and looking at it through a - // window must cost what the window costs, and must actually show more — - // the detail floor follows the magnified area, not the screen. - fn layout_of(tree: &Node, area: Rect) -> Vec { - layout(tree, Path::new("/root"), area, area, &MapStyle::default(), None) - .into_iter() - .filter(|c| !c.is_bundle()) - .map(|c| c.name) - .collect() - } - - // Equal sizes are everywhere on a real disk — shards, dedup'd assets — - // and they must lay out in the same order every single time, and keep - // that order when the camera's area drifts. Anything else is a map that - // shuffles its tiles whenever the camera settles. - #[test] - fn equal_sized_siblings_never_swap_between_layouts() { - let children: Vec = (0..120) - .map(|i| leaf(&format!("shard-{i:03}"), 510_000_000)) - .chain((0..300).map(|i| leaf(&format!("t{i}.tmp"), 1_000 + i as u64))) - .collect(); - let tree = dir("root", children); - let area = Rect { x: 0.0, y: 0.0, w: 900.0, h: 600.0 }; - - // Twice at the same area: byte-identical order. - let a = layout_of(&tree, area); - let b = layout_of(&tree, area); - assert_eq!(a, b); - // The equal-size run keeps child order, deterministically. - let shards: Vec<&String> = a.iter().filter(|n| n.starts_with("shard")).collect(); - assert!(shards.windows(2).all(|w| w[0] < w[1]), "{shards:?}"); - - // At a slightly different area (a small zoom's settle), tiles may be - // added or dropped — but the ones present in both keep their - // relative order exactly. - let grown = Rect { x: 0.0, y: 0.0, w: 940.0, h: 627.0 }; - let c = layout_of(&tree, grown); - let shared: std::collections::HashSet<&String> = a - .iter() - .collect::>() - .intersection(&c.iter().collect()) - .copied() - .collect(); - let a_shared: Vec<&String> = a.iter().filter(|n| shared.contains(n)).collect(); - let c_shared: Vec<&String> = c.iter().filter(|n| shared.contains(n)).collect(); - assert_eq!(a_shared, c_shared, "surviving tiles reordered across a small zoom"); - } - - #[test] - fn a_zoomed_layout_culls_to_the_viewport_and_gains_detail() { - // 200 equal folders of 40 files each: at screen size a folder is a - // ~17pt tile, so its files land far below the visibility floor. - let children: Vec = (0..200) - .map(|i| { - dir( - &format!("d{i}"), - (0..40).map(|j| leaf(&format!("f{j}.bin"), 25_000)).collect(), - ) - }) - .collect(); - let tree = dir("root", children); - let screen = Rect { x: 0.0, y: 0.0, w: 300.0, h: 200.0 }; - let style = MapStyle::default(); - - // Unzoomed: the folders show, their files are bundled away. - let flat = layout(&tree, Path::new("/root"), screen, screen, &style, None); - assert!(flat.iter().any(|c| c.name == "d0")); - assert!(flat.iter().all(|c| !c.name.starts_with('f'))); - - // 8× camera, looking at the top-left corner of the blown-up map. - let area = Rect { x: 0.0, y: 0.0, w: 2400.0, h: 1600.0 }; - let zoomed = layout(&tree, Path::new("/root"), area, screen, &style, None); - - // Everything delivered is at least partly on screen… - for cell in &zoomed { - assert!( - cell.rect.intersects(&screen), - "{} at {:?} is entirely off screen", - cell.name, - cell.rect - ); - } - // …the off-screen majority was skipped, not delivered… - let dirs = zoomed.iter().filter(|c| c.is_dir).count(); - assert!(dirs < 60, "{dirs} of 200 folders for a 1/64 window"); - // …and the zoom bought real detail: the files inside are visible now. - assert!(zoomed.iter().any(|c| c.name.starts_with('f'))); - } - - #[test] - fn hit_finds_the_deepest_cell() { - let tree = dir("root", vec![dir("sub", vec![leaf("a.txt", 100)])]); - let area = Rect { x: 0.0, y: 0.0, w: 100.0, h: 100.0 }; - let cells = layout(&tree, Path::new("/root"), area, area, &style(), None); - - let group = cells.iter().position(|c| c.name == "sub").unwrap(); - let child = cells.iter().position(|c| c.name == "a.txt").unwrap(); - let group_rect = cells[group].rect; - let child_rect = cells[child].rect; - - // A point in the group's margin (inside the border/header inset, - // before the child's own rect begins) should hit the group. - assert_eq!(hit(&cells, group_rect.x + 0.5, group_rect.y + 0.5), Some(group)); - - // A point solidly inside the child should hit the child, not the - // group sitting behind it, even though both rects contain it. - let cx = child_rect.x + child_rect.w / 2.0; - let cy = child_rect.y + child_rect.h / 2.0; - assert!(group_rect.contains(cx, cy), "test setup: child not nested in group"); - assert_eq!(hit(&cells, cx, cy), Some(child)); - } - - #[test] - fn a_folder_paints_as_its_heaviest_content() { - let mut tree = dir( - "root", - vec![Node::file("clip.mov".into(), 5, 900), Node::file("note.txt".into(), 2, 10)], - ); - // Rebuilt the way `apply` would, so the rule is the one the scan uses. - tree.kind = heaviest_kind(&tree.children).unwrap(); - assert_eq!(tree.kind, 5); - } - - // ------------------------------------------------------------- scanning - - fn temp_root(tag: &str) -> PathBuf { - let root = std::env::temp_dir().join(format!( - "mpfiles-treemap-{tag}-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - let _ = fs::remove_dir_all(&root); - root - } - - fn sample_tree(root: &Path) -> u64 { - fs::create_dir_all(root.join("sub/subsub")).unwrap(); - fs::write(root.join("a.txt"), b"aaaaa").unwrap(); // 5 bytes - fs::write(root.join("sub/b.txt"), b"bbbbbbb").unwrap(); // 7 bytes - fs::write(root.join("sub/subsub/c.txt"), b"ccc").unwrap(); // 3 bytes - - #[cfg(unix)] - { - // A link back up to an ancestor of the folder it sits in: if the - // scan ever followed it, this test would hang rather than - // finish, which is exactly the bug this case exists to catch. - let link = root.join("sub/subsub/loop"); - std::os::unix::fs::symlink(root, &link).unwrap(); - 15 + fs::symlink_metadata(&link).unwrap().len() - } - #[cfg(not(unix))] - { - 15 - } - } - - #[test] - fn scan_rolls_up_recursive_sizes_and_stops_symlink_cycles() { - let root = temp_root("scan"); - let expected = sample_tree(&root); - - let cancel = AtomicBool::new(false); - let node = scan(&root, &open_rules(), &cancel, &|_| {}).expect("scan should complete"); - - assert_eq!(node.size, expected); - assert_eq!(node.files, if cfg!(unix) { 4 } else { 3 }); - - fs::remove_dir_all(&root).ok(); - } - - #[test] - fn scan_returns_none_when_already_cancelled() { - let root = temp_root("cancel"); - fs::create_dir_all(&root).unwrap(); - - let cancel = AtomicBool::new(true); - assert!(scan(&root, &open_rules(), &cancel, &|_| {}).is_none()); - - fs::remove_dir_all(&root).ok(); - } - - #[test] - fn scan_reports_progress_at_a_bounded_rate_not_per_entry() { - let root = temp_root("progress"); - fs::create_dir_all(&root).unwrap(); - for i in 0..600 { - fs::write(root.join(format!("f{i}.bin")), b"x").unwrap(); - } - - let cancel = AtomicBool::new(false); - let calls = std::sync::atomic::AtomicU32::new(0); - let node = scan(&root, &open_rules(), &cancel, &|_| { - calls.fetch_add(1, Ordering::Relaxed); - }) - .unwrap(); - - assert_eq!(node.files, 600); - // 600 entries at a stride of 512 is at most two mid-walk reports - // plus the guaranteed final one — nowhere near one call per file. - let calls = calls.load(Ordering::Relaxed); - assert!(calls <= 4, "too many progress calls: {calls}"); - - fs::remove_dir_all(&root).ok(); - } - - // The streamed walk and the blocking one have to agree, or the map is a - // different disk than the properties panel. - #[test] - fn the_streamed_scan_builds_the_same_tree_as_the_blocking_one() { - let root = temp_root("stream"); - let expected = sample_tree(&root); - // Deep enough that an old depth-cutoff walker would have switched to - // handing back whole subtrees — this tree must stream all the way. - fs::create_dir_all(root.join("deep/a/b/c/d")).unwrap(); - fs::write(root.join("deep/a/b/c/d/e.bin"), vec![0u8; 400]).unwrap(); - - let cancel = AtomicBool::new(false); - let steps = Mutex::new(Vec::new()); - let ok = scan_stream(&root, &open_rules(), &cancel, &|step| { - steps.lock().unwrap().push(step); - }); - assert!(ok); - - let mut tree = Node::dir("root".into(), 0); - for step in steps.into_inner().unwrap() { - assert!(tree.apply(step), "a step named a node that is not there"); - } - assert_eq!(tree.size, expected + 400); - assert_eq!(tree.files, if cfg!(unix) { 5 } else { 4 }); - assert!( - tree.children.iter().any(|c| c.name == "deep"), - "the root's own listing never arrived" - ); - - // Folders are announced a level at a time and are only known to be - // finished when the walk is, which is what `seal` says. - fn all_done(node: &Node) -> bool { - node.children.iter().all(|c| (!c.is_dir || c.done) && all_done(c)) - } - assert!(!all_done(&tree), "nothing should be sealed before the walk ends"); - tree.seal(); - assert!(all_done(&tree)); - - fs::remove_dir_all(&root).ok(); - } - - // The reason the walk has no depth cutoff: a 500 GB folder four levels - // down must fill in live on the map, not sit as one opaque block until - // its whole subtree has been walked. Every directory at every depth - // announces its own listing; nothing is delivered as a finished subtree. - #[test] - fn every_directory_streams_its_own_listing_at_any_depth() { - let root = temp_root("stream-depth"); - sample_tree(&root); // root, sub, sub/subsub - fs::create_dir_all(root.join("deep/a/b/c/d")).unwrap(); - fs::write(root.join("deep/a/b/c/d/e.bin"), vec![0u8; 400]).unwrap(); - - let cancel = AtomicBool::new(false); - let steps = Mutex::new(Vec::new()); - assert!(scan_stream(&root, &open_rules(), &cancel, &|step| { - steps.lock().unwrap().push(step); - })); - - let mut opened_ats: Vec> = Vec::new(); - for step in steps.into_inner().unwrap() { - match step { - ScanStep::Opened { at, .. } => opened_ats.push(at), - ScanStep::Closed { .. } => { - panic!("the walk handed back a whole subtree instead of streaming it") - } - _ => {} - } - } - // One listing per directory: root, sub, subsub, deep, a, b, c, d. - assert_eq!(opened_ats.len(), 8, "opened: {opened_ats:?}"); - let deepest = opened_ats.iter().map(|at| at.len()).max().unwrap(); - assert_eq!(deepest, 5, "deep/a/b/c/d never announced its own listing"); - - fs::remove_dir_all(&root).ok(); - } - - #[test] - fn the_streamed_scan_announces_the_root_before_it_walks_anything() { - let root = temp_root("first-step"); - sample_tree(&root); - - let cancel = AtomicBool::new(false); - let first = Mutex::new(None); - scan_stream(&root, &open_rules(), &cancel, &|step| { - let mut slot = first.lock().unwrap(); - if slot.is_none() { - *slot = Some(match step { - ScanStep::Opened { at, children, .. } => (at, children.len()), - other => panic!("first step was {other:?}, not the root listing"), - }); - } - }); - let (at, count) = first.into_inner().unwrap().expect("no steps at all"); - assert!(at.is_empty()); - assert_eq!(count, 2); // a.txt and sub/ - - fs::remove_dir_all(&root).ok(); - } - - #[test] - fn a_cancelled_stream_reports_that_it_did_not_finish() { - let root = temp_root("stream-cancel"); - sample_tree(&root); - let cancel = AtomicBool::new(true); - assert!(!scan_stream(&root, &open_rules(), &cancel, &|_| {})); - fs::remove_dir_all(&root).ok(); - } - - // ------------------------------------------------------------- filter - - #[test] - fn the_query_parser_reads_every_term_form() { - let now = 1_000_000u32; - let q = Query::parse("cache .mov *.mkv >100mb <2gb <7d qwen", now); - assert_eq!(q.names, vec!["cache".to_string(), "qwen".to_string()]); - assert_eq!(q.exts, vec!["mov".to_string(), "mkv".to_string()]); - assert_eq!(q.min_size, Some(100_000_000)); - assert_eq!(q.max_size, Some(2_000_000_000)); - assert_eq!(q.newer_than, Some(now - 7 * 24 * 60)); - assert!(q.older_than.is_none()); - // ">1y" is the other direction: untouched for a year. - let old = Query::parse(">1y", now); - assert_eq!(old.older_than, Some(now - 525_600)); - // Sizes are 1000-based like every number the app prints; bounds - // that fail to parse fall back to being name terms, never dropped. - let odd = Query::parse(">wat", now); - assert_eq!(odd.names, vec![">wat".to_string()]); - assert!(Query::parse("", now).is_empty()); - } - - #[test] - fn a_filtered_folder_weighs_only_its_matching_bytes() { - let tree = dir( - "root", - vec![ - dir( - "movies", - vec![leaf("a.mov", 900), leaf("b.txt", 50), leaf("c.mov", 100)], - ), - dir("docs", vec![leaf("d.txt", 500)]), - ], - ); - let q = Query::parse(".mov", 0); - let (bytes, files) = filtered_size(&tree, &q, 0); - assert_eq!((bytes, files), (1000, 2)); - // The one-walk measure agrees with the recursive sum, at the root - // and per child — it is the layout's only source of weights now. - let m = measure(&tree, &q, 0); - assert_eq!((m.bytes, m.files), (1000, 2)); - assert_eq!(m.children.len(), 2); - assert_eq!(m.children[0].bytes, 1000); - assert_eq!(m.children[1].bytes, 0); - assert_eq!(m.children[0].children.iter().map(|c| c.bytes).collect::>(), vec![900, 0, 100]); - // A folder with no matching bytes vanishes from the layout entirely. - let area = Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }; - let cells = layout(&tree, Path::new("/root"), area, area, &style(), Some(&m)); - assert!(cells.iter().any(|c| c.name == "movies" && c.size == 1000)); - assert!(!cells.iter().any(|c| c.name == "docs")); - assert!(!cells.iter().any(|c| c.name == "b.txt")); - // And no filter costs nothing different from before. - let plain = layout(&tree, Path::new("/root"), area, area, &style(), None); - assert!(plain.iter().any(|c| c.name == "docs")); - } - - // The measure is a cache, and caches go stale: one made of a different - // tree shape must be refused wholesale, never indexed out of step. - #[test] - fn a_stale_measure_is_refused_not_misapplied() { - let tree = dir("root", vec![leaf("a.mov", 900), leaf("b.txt", 50)]); - let q = Query::parse(".mov", 0); - let mut m = measure(&tree, &q, 0); - m.children.pop(); // now shaped like some other tree - let area = Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }; - let cells = layout(&tree, Path::new("/root"), area, area, &style(), Some(&m)); - // Fell back to the unfiltered weights: everything is on the map. - assert!(cells.iter().any(|c| c.name == "b.txt")); - } - - #[test] - fn a_name_term_matches_everything_under_a_matching_folder() { - let tree = dir( - "root", - vec![ - dir("cache", vec![leaf("blob.bin", 700)]), - leaf("cache.log", 40), - leaf("other.bin", 25), - ], - ); - let q = Query::parse("cache", 0); - let (bytes, files) = filtered_size(&tree, &q, 0); - assert_eq!((bytes, files), (740, 2)); - } - - #[test] - fn age_terms_ride_on_the_rolled_up_mtime() { - let now = 2_000_000u32; - let mut tree = dir( - "root", - vec![dir( - "sub", - vec![ - Node::file_at("new.txt".into(), 0, 10, now - 60), - Node::file_at("old.txt".into(), 0, 20, now - 1_000_000), - ], - )], - ); - tree.children[0].modified = now - 60; - tree.modified = now - 60; - let q = Query::parse("<7d", now); - assert_eq!(filtered_size(&tree, &q, 0), (10, 1)); - // The folder counts as new because something new is inside it — - // that is what the max roll-up means. - assert_eq!(tree.modified, now - 60); - } - - #[test] - fn the_walk_rolls_the_newest_mtime_up_to_the_root() { - let root = temp_root("mtime"); - fs::create_dir_all(root.join("sub")).unwrap(); - fs::write(root.join("sub/a.txt"), b"aa").unwrap(); - let cancel = AtomicBool::new(false); - let steps = Mutex::new(Vec::new()); - assert!(scan_stream(&root, &open_rules(), &cancel, &|step| { - steps.lock().unwrap().push(step); - })); - let mut tree = Node::dir("root".into(), 0); - for step in steps.into_inner().unwrap() { - tree.apply(step); - } - // Written moments ago: the minutes-since-epoch must be recent and - // must have reached the root through the roll-up. - assert!(tree.modified > 0); - let now = super::minutes_since_epoch(Ok(std::time::SystemTime::now())); - assert!(now - tree.modified < 5, "root mtime {} vs now {}", tree.modified, now); - fs::remove_dir_all(&root).ok(); - } - - #[test] - fn kind_totals_sum_files_by_tag() { - let tree = dir( - "root", - vec![ - Node::file("a.mov".into(), 5, 900), - Node::file("b.mov".into(), 5, 100), - Node::file("c.txt".into(), 2, 30), - ], - ); - let totals = kind_totals(&tree); - assert_eq!(totals[5], 1000); - assert_eq!(totals[2], 30); - assert_eq!(totals[0], 0); - } - - #[test] - fn formats_byte_counts() { - assert_eq!(format_bytes(999), "999 B"); - assert_eq!(format_bytes(1500), "1.5 KB"); - assert_eq!(format_bytes(15_000), "15 KB"); - assert_eq!(format_bytes(2_500_000_000), "2.5 GB"); - } -} diff --git a/apps/mpfiles/src/treemap_view.rs b/apps/mpfiles/src/treemap_view.rs deleted file mode 100644 index 3a207d2a3..000000000 --- a/apps/mpfiles/src/treemap_view.rs +++ /dev/null @@ -1,3836 +0,0 @@ -//! The treemap: a spatial map of where a folder's bytes actually are. -//! -//! Every rectangle's area is its bytes, all the way down to the individual -//! file: a 4 GB video is visibly four thousand times the block a 1 MB photo -//! gets, and the folder it sits in is the frame drawn around it. There is no -//! depth limit — the map stops where a rectangle stops being visible, which on -//! a big window is at the file and on a small one is a few folders up. -//! -//! Three things make it readable rather than a field of colour. Each tile is a -//! shaded cushion (Van Wijk), so ten thousand rectangles read as ten thousand -//! things. Hue says what kind of thing it is, and a folder borrows the hue of -//! its own heaviest content, so a folder full of video reads as video without -//! being opened. And nesting is drawn as a frame that narrows with depth, -//! which is what keeps the borders from eating the bytes they surround. -//! -//! The scan never runs on the UI thread and never makes anyone wait for all of -//! it: a worker streams the tree back as it walks (see [`crate::treemap`]), the -//! map is drawable after the first `read_dir`, and it sharpens as the walk goes -//! deeper. The layout itself is pure arithmetic and runs inline, throttled -//! while a scan is still feeding it. - -use makepad_widgets::makepad_platform::thread::SignalToUI; -use makepad_widgets::*; - -use std::{ - collections::HashMap, - path::{Path, PathBuf}, - sync::{ - atomic::{AtomicBool, Ordering}, - mpsc::{channel, Receiver, Sender}, - Arc, Mutex, - }, - thread, - time::{Duration, Instant}, -}; - -use crate::{ - model::FileKind, - theme::Palette, - treemap::{self, Cell, MapStyle, Node, Query, Rect as MapRect, ScanStep}, -}; - -/// The strip along the top carrying the zoom breadcrumb and the scan state. -const CRUMB_H: f64 = 21.0; -/// The strip along the bottom carrying the persistent selection readout. -const FOOT_H: f64 = 21.0; -/// A rectangle needs this much room before its name is worth drawing, and -/// this much again before its size goes on a second line. -const LABEL_MIN: DVec2 = DVec2 { x: 50.0, y: 19.0 }; -const LABEL_TWO_LINE_H: f64 = 32.0; -/// A ceiling on labels per frame. Past a few hundred names nobody is reading -/// them and every one costs a text layout. -const LABEL_BUDGET: usize = 700; -/// The map is only re-laid-out this often while a scan is still feeding it — -/// the tree changes hundreds of times a second and the picture does not need -/// to. -const RELAYOUT_EVERY: Duration = Duration::from_millis(110); -/// How often the worker wakes the UI. The steps themselves queue freely; this -/// only bounds the signals. -const SIGNAL_EVERY: Duration = Duration::from_millis(45); -/// The kind tag [`treemap::layout`] gives the "N smaller items" rectangle. -const KIND_BUNDLE: u8 = u8::MAX; -/// The palette class everything unrecognised falls into. -const OTHER_CLASS: usize = 6; -/// How long a filter change morphs the map from the old cell set to the new. -const TWEEN: Duration = Duration::from_millis(200); -/// Points of elevation one nesting level is worth at camera scale 1 — the -/// whole meaning of the raised projections: height is depth. Big enough -/// that a nested plate clears its parent's label line. -const RISE: f64 = 11.0; -/// The perspective eye's distance from the pivot along the view axis, in the -/// same points. Large on purpose: the 3d mode is the ortho map breathing, -/// not a flyover. -const PERSP_EYE: f64 = 1500.0; -/// Where the orbit starts and where Esc returns it: enough tilt that height -/// reads immediately, nowhere near enough to hide the map behind itself. -const DEFAULT_PITCH: f64 = 0.66; -/// The grazing end of the tilt. Past this the plane degenerates into a -/// horizon and a disk-use instrument stops being one. -const MAX_PITCH: f64 = 1.15; -/// Radians of yaw per point of leftward drag, and of pitch per point down. -const ORBIT_PER_PT: f64 = 0.010; -const PITCH_PER_PT: f64 = 0.008; -/// A press that stays within this many points is a click; past it, the -/// button's drag gesture — and never both. -const DRAG_THRESHOLD: f64 = 4.0; -/// The tile size at which the cushion is at full strength. A cushion lives in -/// the tile's own 0..1 space, so left alone a huge rectangle gets a huge soft -/// gradient that reads as a spotlight rather than as a surface. The shading is -/// there to separate small neighbours, so it fades out on the big ones, where -/// there is a border and a label doing the same job. -const CUSHION_FULL_AT: f64 = 44.0; - -script_mod! { - use mod.prelude.widgets_internal.* - use mod.widgets.* - - /** One face of the map: a Van Wijk cushion — a shallow pillow lit from - * the upper left — inside a hard border. The cushion is what makes a - * dense map readable: adjacent tiles of the same hue are separated by - * their own shading even where there is no room for a border line. - * - * The geometry is a free QUAD, not a rect: the orbit camera hands four - * projected screen corners per instance (c0 top-left, c1 top-right, c2 - * bottom-right, c3 bottom-left) and the vertex stage interpolates them - * bilinearly, so one shared instance batch draws the flat map, the tilted - * plates and the prism walls alike. Because the corners are free, the - * usual vertex-clamp scissor would deform the shape — clipping happens in - * the fragment against the same draw_clip instead. */ - set_type_default() do #(DrawMapTile::script_shader(vm)) { - ..mod.draw.DrawQuad - /** the tile's own colour */ - color: #x40507a - /** the border drawn around the tile */ - edge: #x16161e - /** cushion depth 0..1 step 0.05 */ - cushion: 0.55 - /** border thickness in points 0..3 step 0.25 */ - border: 1.0 - scr: varying(vec2f) - qsize: varying(vec2f) - vertex: fn() { - let p = mix( - mix(self.c0, self.c1, self.geom.pos.x) - mix(self.c3, self.c2, self.geom.pos.x) - self.geom.pos.y - ) - self.pos = self.geom.pos - self.scr = p - self.qsize = vec2( - max(length(self.c1 - self.c0), 1.0) - max(length(self.c3 - self.c0), 1.0) - ) - let ps = p + self.draw_list.view_shift - self.world = self.draw_list.view_transform * vec4( - ps.x - ps.y - self.draw_depth + self.draw_call.zbias - 1.0 - ) - self.vertex_pos = self.draw_pass.camera_projection * (self.draw_pass.camera_view * self.world) - } - pixel: fn() { - // The fragment scissor the free-quad geometry needs: outside the - // clip the fragment simply is not there. - if self.scr.x < self.draw_clip.x || self.scr.y < self.draw_clip.y - || self.scr.x > self.draw_clip.z || self.scr.y > self.draw_clip.w { - return vec4(0.0, 0.0, 0.0, 0.0) - } - let p = self.pos * self.qsize - let d = min(min(p.x, p.y), min(self.qsize.x - p.x, self.qsize.y - p.y)) - // The pillow's surface normal. The height field is the classic - // x(1-x)·y(1-y) parabola, so its slope is linear in the position - // and costs two multiplies. - let nx = self.cushion * (1.0 - 2.0 * self.pos.x) - let ny = self.cushion * (1.0 - 2.0 * self.pos.y) - let n = normalize(vec3(-nx, -ny, 1.0)) - let l = normalize(vec3(-0.45, -0.62, 0.64)) - let h = normalize(l + vec3(0.0, 0.0, 1.0)) - let diff = clamp(dot(n, l), 0.0, 1.0) - let spec = pow(clamp(dot(n, h), 0.0, 1.0), /**highlight tightness 4..64 step 2*/ 26.0) - let lit = self.color.rgb * (/**ambient 0.2..1 step 0.02*/ 0.56 + /**diffuse 0..1.5 step 0.02*/ 0.68 * diff) - + vec3(spec, spec, spec) * /**highlight 0..0.6 step 0.02*/ 0.18 - let cov = clamp((self.border - d) * 2.0 + 0.5, 0.0, 1.0) - let c = mix(vec4(lit, self.color.w), self.edge, cov) - return vec4(c.rgb * c.w, c.w) - } - } - - mod.widgets.MpfTreemapBase = #(TreemapView::register_widget(vm)) - mod.widgets.MpfTreemap = set_type_default() do mod.widgets.MpfTreemapBase{ - width: Fill - height: Fill - draw_bg +: {color: mod.mpf.bg} - draw_tile +: {} - draw_text +: { - color: mod.mpf.fg - text_style: theme.font_regular{font_size: 8.0} - } - draw_bold +: { - color: mod.mpf.fg_bright - text_style: theme.font_bold{font_size: 8.5} - } - } -} - -#[derive(Script, ScriptHook)] -#[repr(C)] -pub struct DrawMapTile { - #[deref] - draw_super: DrawQuad, - #[live] - color: Vec4f, - #[live] - edge: Vec4f, - #[live] - cushion: f32, - #[live] - border: f32, - /// The projected screen corners of this face, clockwise from top-left. - /// Every face the map draws — flat tile, tilted plate, prism wall — is - /// these four points; `rect_pos`/`rect_size` only carry the bounding box. - #[live] - c0: Vec2f, - #[live] - c1: Vec2f, - #[live] - c2: Vec2f, - #[live] - c3: Vec2f, -} - -/// What a press on the map means to the folder view around it. -#[derive(Clone, Debug, Default, PartialEq)] -pub enum TreemapAction { - /// A rectangle was picked. The map keeps showing it; the browser may - /// select it too when it happens to be in the current listing. - Selected(PathBuf), - /// What was picked is not on the disk any more; the map has dropped it. - Vanished(PathBuf), - /// The ✕ on the filter chip: the map is unfiltered again, and whoever - /// owns the filter controls should show them cleared. - FilterCleared, - /// A secondary press released without dragging: the context menu's - /// moment, at this window point. A secondary press that dragged was a - /// pan and asks for nothing. - Context(DVec2), - #[default] - None, -} - -/// The kind class a file's colour comes from: the index into -/// [`Palette::kinds`]. Kept here rather than on `FileKind` because it is a -/// property of *this picture*, not of the file. -pub fn kind_class(kind: FileKind) -> u8 { - match kind { - FileKind::Video => 0, - FileKind::Image => 1, - FileKind::Audio => 2, - FileKind::Code => 3, - // A PDF reads as a document, which is what the text hue means here. - FileKind::Text | FileKind::Pdf => 4, - FileKind::Archive => 5, - FileKind::Folder | FileKind::Generic => 6, - } -} - -/// One message from the scan worker. `generation` is the request it answers, -/// so a scan the user already navigated away from is dropped rather than -/// folded into the folder they are looking at now. -struct ScanMessage { - generation: u64, - step: Option, - finished: Option, -} - -/// How a request for a folder's map ended. -enum Outcome { - /// The disk was walked. The tree in hand is fresh, and worth saving. - Scanned, - /// The saved map was good and is what got delivered — nothing was read - /// off the disk at all, which is the whole point of keeping it. - Loaded { scanned_at: u64 }, - /// Cancelled, or the folder could not be read. - Failed, -} - -/// What the footer keeps saying after a click — held apart from the cell list -/// because a relayout throws every cell away and the selection must survive -/// it. -#[derive(Clone, Debug, PartialEq)] -struct Pick { - path: PathBuf, - size: u64, - files: u32, - is_dir: bool, - bundle: u32, -} - -/// A name waiting to be drawn on top of the finished tiles. -struct Label { - at: DVec2, - room: f64, - line: String, - below: Option, - ink: Vec4f, - /// True for a name that floats over other tiles (a group's) and brings - /// its own dim plate for contrast. A leaf's name sits on the leaf's own - /// cushion and needs none. - scrim: bool, -} - -/// A flat-map label frozen at its relayout: text already fitted, width -/// already measured, anchor in layout space. Drawing translates the anchor -/// through the live camera remap and nothing else — deriving names from the -/// remapped rects every frame made each one flicker through its own -/// truncation points and re-stack its stagger row all through a zoom glide. -struct FrozenLabel { - at: DVec2, - line: String, - below: Option, - width: f64, - ink: Vec4f, - scrim: bool, -} - -#[derive(Script, ScriptHook, Widget)] -pub struct TreemapView { - #[uid] - uid: WidgetUid, - #[source] - source: ScriptObjectRef, - #[walk] - walk: Walk, - #[layout] - layout: Layout, - // The whole panel, not one of the draw calls inside it: a redraw of this - // view has to invalidate the map, and the last thing any of the shaders - // below touched is a strip at one edge of it. - #[redraw] - #[area] - area: Area, - #[live] - draw_bg: DrawColor, - #[live] - draw_tile: DrawMapTile, - #[live] - draw_text: DrawText, - #[live] - draw_bold: DrawText, - - /// The folder the map is of — the browser's folder. - #[rust] - root: PathBuf, - /// The names between the mapped folder and the one the map is zoomed - /// into. Empty means the whole scan is on screen. - #[rust] - zoom: Vec, - #[rust] - tree: Node, - #[rust] - style: MapStyle, - #[rust] - cells: Vec, - /// The rect `cells` was laid out for; a different one means re-layout. - #[rust] - laid_out: Rect, - #[rust] - stale: bool, - #[rust] - last_layout: Option, - #[rust] - frame: NextFrame, - - /// The visual camera over the map: 1.0 shows the whole focused folder - /// fitted to the panel; larger blows it up that many times, with - /// `cam_off` saying how far the window has slid into the blown-up map - /// (in points, from its top-left). Purely a way of *looking* — the - /// breadcrumb, the browser and the scan never move with it. - #[rust] - cam_scale: f64, - #[rust] - cam_off: DVec2, - /// The orbit half of the camera, raised projections only: `yaw` spins - /// the map plane about the panel's centre, `pitch` tilts the eye from - /// straight down (0) toward grazing. The flat map ignores both. - #[rust] - yaw: f64, - #[rust] - pitch: f64, - /// A press of either button waiting to learn whether it is a click or - /// that button's drag gesture. The click itself is decided on release — - /// a press that moved is a gesture and picks nothing, opens nothing, so - /// dragging across the map never changes the selection and never opens - /// the menu. - #[rust] - drag: Option, - - /// How the map is drawn: flat, extruded, or in perspective. - #[rust] - projection: MapProjection, - /// The order cells paint in for the raised projections. Empty for the - /// flat map, whose own vector is already painter's order. - #[rust] - paint_order: Vec, - - /// The live filter. None (or an empty query) is the whole disk. - #[rust] - filter: Option, - /// What the filter matched under the focused folder: (bytes, files). - #[rust] - filtered: Option<(u64, u32)>, - /// Where the filter chip's ✕ was drawn, for the click that clears it. - #[rust] - filter_hit: Rect, - /// Byte totals per kind tag — the legend's numbers, recomputed lazily. - #[rust] - totals: [u64; 16], - #[rust] - totals_dirty: bool, - /// The filter's weights, measured once per (tree revision, query) and - /// reused by every relayout since — the camera relayouts every frame of - /// an orbit, and re-walking six hundred thousand nodes each frame was - /// exactly the frame rate the user was feeling. None while unfiltered. - #[rust] - measure: Option, - #[rust] - measure_rev: u64, - #[rust] - measure_query: Option, - /// Bumped whenever the measured tree itself changes — scan steps landing, - /// moves absorbed, a re-root — never by the camera. - #[rust] - tree_rev: u64, - - /// The camera `cells` were laid out at. While a gesture moves the live - /// camera, the draw path remaps every rect from this camera to that one — - /// the picture rides along as one rigid sheet — and the layout is only - /// rebuilt when the motion settles (or on a coarse cadence during a long - /// one), morphing there. This is what keeps a wheel zoom visually - /// constant: the layout is not scale-invariant (insets and header strips - /// are fixed point sizes, the bundle floor moves with area), so - /// re-laying-out every glide frame made tiles swim and jump mid-zoom. - #[rust] - layout_scale: f64, - #[rust] - layout_off: DVec2, - #[rust] - layout_yaw: f64, - #[rust] - layout_pitch: f64, - /// The ground region the current cells were laid out over (the padded - /// cull). As long as the live camera still looks inside it — and has not - /// zoomed in past what the layout resolves — the layout is not remade at - /// all: a settling gesture keeps the exact arrangement on screen instead - /// of buying a fresh packing nobody asked for. - #[rust] - laid_cull: MapRect, - - /// The wheel's glide: the scale it is headed for, and the ground point - /// pinned under the cursor for the whole ride. Each wheel step retargets; - /// the camera eases there over a few frames instead of jumping. - #[rust] - zoom_glide: Option, - /// Q/E's glide: the yaw the orbit is headed for, and the last tick. - #[rust] - yaw_glide: Option<(f64, Instant)>, - - /// The filter tween: where each surviving path was, the cells that are - /// leaving (with the rect they were last seen at), and when it started. - #[rust] - tween_from: HashMap, - #[rust] - tween_leavers: Vec<(Cell, MapRect, f64)>, - #[rust] - tween_start: Option, - /// A snapshot of the map as it looks right now, taken when the filter - /// changes, consumed by the next relayout to aim the tween. - #[rust] - tween_capture: Option>, - /// True when the pending capture was taken for a settle the *camera* - /// asked for. The picture on screen is already true then — the layout is - /// merely catching up — so detail the refresh brings in must simply be - /// there, not fade in at the user; a zoom is not data appearing. Filter - /// edits and scan changes keep the arrival ceremony. - #[rust] - tween_calm: bool, - /// Bumped by every relayout; keys the frozen labels to their layout. - #[rust] - layout_rev: u64, - /// The flat map's printed names, derived once per relayout against the - /// settled rects and merely translated while a gesture is in flight. - #[rust] - frozen_labels: Vec, - /// Which relayout `frozen_labels` was derived from. - #[rust] - frozen_rev: Option, - - #[rust] - generation: u64, - #[rust] - cancel: Option>, - #[rust] - scanning: bool, - /// Folders the walk has not opened yet. A scan cannot know its own - /// denominator before it has walked the tree, so this is a count, not a - /// percentage — and unlike a percentage it is true. - #[rust] - folders_left: u32, - /// When the numbers on screen were measured, in seconds since the epoch. - /// A cached map is only safe to show if it says how old it is. - #[rust] - scanned_at: u64, - /// Folders the scan was refused, named so the total's shortfall is - /// admitted rather than hidden. - #[rust] - denied: Vec, - /// Where "rescan" was drawn, for the click that starts one. - #[rust] - rescan_hit: Rect, - #[rust] - error: Option, - - #[rust] - sender: Option>, - #[rust] - receiver: Option>, - - #[rust] - hover: Option, - #[rust] - pick: Option, - /// Where each breadcrumb segment was drawn, and how many names of `zoom` - /// it stands for. - #[rust] - crumbs: Vec, -} - -/// One clickable breadcrumb segment. -#[derive(Clone, Copy)] -struct CrumbHit { - rect: Rect, - depth: usize, -} - -/// A wheel zoom in flight: eased toward `target` a frame at a time, always -/// about the same map-ground `anchor`, so the point under the cursor stays -/// put for the whole glide. Each further wheel step just retargets it. -#[derive(Clone, Copy)] -struct ZoomGlide { - target: f64, - anchor: DVec2, - last: Instant, -} - -/// How fast a glide closes on its target: the ease-out's time constant. -/// 45ms settles ~95% of the way in ~135ms — smooth, never floaty. -/// How far past the layout's own scale the camera may zoom IN before the -/// map is worth re-laying-out. Inside this band the detail floor is at most -/// this factor coarser than ideal — imperceptible — and keeping the cells -/// in hand keeps the arrangement rock steady. -const DETAIL_SLACK: f64 = 1.3; -const GLIDE_TAU: f64 = 0.045; -/// How often a long, still-running camera gesture may refresh the layout -/// underneath itself. Coarse on purpose: between refreshes the picture rides -/// a rigid remap of the last layout — visually constant by construction — -/// and each refresh arrives as a morph, never a per-frame reshuffle. -const MOTION_RELAYOUT: Duration = Duration::from_millis(150); -/// How far past the panel the flat cull reaches, as a fraction of the panel -/// per side: the slack that lets a pan or an out-zoom ride the remap without -/// exposing unlaid ground before the next refresh. -const MOTION_CULL_PAD: f64 = 0.25; - -/// A press waiting to learn whether it is a click or its button's drag -/// gesture: primary orbits (pans, on the flat map), secondary pans. -#[derive(Clone, Copy)] -struct Drag { - from: DVec2, - cam_off: DVec2, - yaw: f64, - pitch: f64, - taps: u32, - secondary: bool, - /// Crossed the threshold: this press is a gesture now and will never be - /// a click, however close to `from` it releases. - moved: bool, -} - -/// The frozen trigonometry of the orbit camera for one frame: the map plane -/// spun by yaw about `pivot`, tilted by pitch, and — in perspective — pushed -/// through an eye [`PERSP_EYE`] points up the view axis. The flat map is the -/// same camera at yaw 0, pitch 0, which projects to the identity. -#[derive(Clone, Copy)] -struct Cam { - pivot: DVec2, - sin_yaw: f64, - cos_yaw: f64, - sin_pitch: f64, - cos_pitch: f64, - persp: bool, -} - -impl Cam { - /// The layout point `p` at elevation `z`, on screen. - fn project(&self, p: DVec2, z: f64) -> DVec2 { - let dx = p.x - self.pivot.x; - let dy = p.y - self.pivot.y; - let xr = dx * self.cos_yaw - dy * self.sin_yaw; - let yr = dx * self.sin_yaw + dy * self.cos_yaw; - let vx = xr; - let vy = yr * self.cos_pitch - z * self.sin_pitch; - if !self.persp { - return dvec2(self.pivot.x + vx, self.pivot.y + vy); - } - let depth = yr * self.sin_pitch + z * self.cos_pitch; - let s = (PERSP_EYE / (PERSP_EYE - depth)).clamp(0.5, 2.5); - dvec2(self.pivot.x + vx * s, self.pivot.y + vy * s) - } - - /// The ground point (z = 0) that projects to screen point `s` — the - /// exact inverse of [`Cam::project`], for both projections. - fn unproject_ground(&self, s: DVec2) -> DVec2 { - self.unproject_at(s, 0.0) - } - - /// The point on the plane at elevation `z` that projects to screen - /// point `s`. The cursor in a raised projection rests on a tile *top*, - /// not the ground behind it — anchoring a zoom at z = 0 under a tall - /// tower drifts by the tower's own parallax. - fn unproject_at(&self, s: DVec2, z: f64) -> DVec2 { - let sx = s.x - self.pivot.x; - let sy = s.y - self.pivot.y; - let (xr, yr); - if !self.persp { - // sy = yr·cosφ − z·sinφ. - yr = if self.cos_pitch.abs() < 1e-4 { - 0.0 - } else { - (sy + z * self.sin_pitch) / self.cos_pitch - }; - xr = sx; - } else { - // vy·s = sy with s = E/(E − yr·sinφ − z·cosφ) and - // vy = yr·cosφ − z·sinφ is linear in yr once multiplied out. - let denom = PERSP_EYE * self.cos_pitch + sy * self.sin_pitch; - yr = if denom.abs() < 1e-6 { - 0.0 - } else { - (sy * PERSP_EYE - z * (sy * self.cos_pitch - PERSP_EYE * self.sin_pitch)) - / denom - }; - let sc = (PERSP_EYE / (PERSP_EYE - yr * self.sin_pitch - z * self.cos_pitch)) - .clamp(0.5, 2.5); - xr = sx / sc; - } - let dx = xr * self.cos_yaw + yr * self.sin_yaw; - let dy = -xr * self.sin_yaw + yr * self.cos_yaw; - dvec2(self.pivot.x + dx, self.pivot.y + dy) - } -} - -/// One projected face: four screen corners, top-left first, clockwise. -#[derive(Clone, Copy)] -struct Quad { - p: [DVec2; 4], -} - -impl Quad { - fn of_rect(cam: &Cam, r: &MapRect, z: f64) -> Quad { - Quad { - p: [ - cam.project(dvec2(r.x, r.y), z), - cam.project(dvec2(r.x + r.w, r.y), z), - cam.project(dvec2(r.x + r.w, r.y + r.h), z), - cam.project(dvec2(r.x, r.y + r.h), z), - ], - } - } - - fn bounds(&self) -> Rect { - let mut min = self.p[0]; - let mut max = self.p[0]; - for p in &self.p[1..] { - min.x = min.x.min(p.x); - min.y = min.y.min(p.y); - max.x = max.x.max(p.x); - max.y = max.y.max(p.y); - } - Rect { pos: min, size: max - min } - } - - /// Whether `at` is inside this (convex) face, either winding. - fn contains(&self, at: DVec2) -> bool { - let mut sign = 0.0f64; - for i in 0..4 { - let a = self.p[i]; - let b = self.p[(i + 1) % 4]; - let cross = (b.x - a.x) * (at.y - a.y) - (b.y - a.y) * (at.x - a.x); - if cross.abs() < 1e-9 { - continue; - } - if sign == 0.0 { - sign = cross.signum(); - } else if cross.signum() != sign { - return false; - } - } - sign != 0.0 - } -} - -/// How the map is projected onto the panel. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub enum MapProjection { - /// The flat map — exactly the 2D treemap. - #[default] - Flat, - /// 2.5D: every cell extrudes straight up by its nesting depth, showing a - /// darker riser below its plate. Deep tangles read as towers. - Ortho, - /// The same prisms through a gentle straight-down perspective: higher - /// plates swell and lean away from the middle of the panel. - Persp, -} - -/// Where a cell was when a filter tween started, so it can glide to where it -/// is now. -struct TweenFrom { - rect: MapRect, - depth: f64, -} - -impl TreemapView { - /// The folder the map is currently of. - pub fn root(&self) -> &Path { - &self.root - } - - /// The folder the map is zoomed into — the root itself when it is not. - pub fn focus_path(&self) -> PathBuf { - let mut path = self.root.clone(); - for name in &self.zoom { - path.push(name); - } - path - } - - /// The node the map is drawing, following the zoom as far as it still - /// resolves. - fn focused(&self) -> &Node { - let mut node = &self.tree; - for name in &self.zoom { - match node.child_named(name) { - Some(next) => node = next, - None => break, - } - } - node - } - - /// Map `path`, from the saved map when there is one. A scan already - /// running for another folder is cancelled first — the user asked for - /// this folder, not that one. - pub fn set_root(&mut self, cx: &mut Cx, path: &Path) { - // Asking for the folder already on screen is not a request to measure - // it again. The browser re-lists its folder after every operation, and - // the map it just corrected by arithmetic must survive that — throwing - // it away would undo the whole point of keeping one. - if path == self.root && !self.tree.children.is_empty() && self.error.is_none() { - return; - } - self.begin(cx, path, false); - } - - /// Re-open the current root under whatever the scan rules now say — - /// the scope checkbox's move. The saved map for the *new* scope is - /// welcome (that is what makes flipping back instant); the tree in hand - /// was measured under the old rules and is not. - pub fn remap(&mut self, cx: &mut Cx) { - let root = self.root.clone(); - if root.as_os_str().is_empty() { - return; - } - self.begin(cx, &root, false); - } - - /// Measure the disk again and replace the saved map, whatever its age. - /// The one thing that makes a cached map safe to trust: it is never more - /// than a keystroke away from being made true. - pub fn rescan(&mut self, cx: &mut Cx) { - let root = self.root.clone(); - if root.as_os_str().is_empty() { - return; - } - crate::sizecache::forget(&root); - self.begin(cx, &root, true); - } - - fn begin(&mut self, cx: &mut Cx, path: &Path, fresh: bool) { - if self.sender.is_none() { - let (sender, receiver) = channel(); - self.sender = Some(sender); - self.receiver = Some(receiver); - } - self.stop(cx); - // Re-measuring the folder already on screen is not a reason to lose - // what the user had picked: the selection is a path, and the path is - // as true after the rescan as before it. A different folder is a - // different picture, and there the old pick would be a lie. - let keep_pick = if path == self.root { self.pick.take() } else { None }; - self.root = path.to_path_buf(); - self.zoom.clear(); - self.tree = Node::dir(crate::model::display_name(path), FileKind::Folder as u8); - self.cells.clear(); - self.laid_out = Rect::default(); - self.stale = true; - self.last_layout = None; - self.hover = None; - self.pick = keep_pick; - self.error = None; - self.folders_left = 0; - self.scanned_at = 0; - self.scanning = true; - self.cam_scale = 1.0; - self.cam_off = DVec2::default(); - self.yaw = 0.0; - self.pitch = DEFAULT_PITCH; - // The camera rests: the remap is the identity until the first layout - // of the new map records itself here. - self.layout_scale = 1.0; - self.layout_off = DVec2::default(); - self.layout_yaw = 0.0; - self.layout_pitch = DEFAULT_PITCH; - self.drag = None; - self.zoom_glide = None; - self.yaw_glide = None; - self.filtered = None; - self.totals_dirty = true; - self.tree_rev = self.tree_rev.wrapping_add(1); - self.tween_capture = None; - self.tween_calm = false; - self.tween_start = None; - self.tween_from.clear(); - self.tween_leavers.clear(); - self.frozen_labels.clear(); - self.frozen_rev = None; - - let cancel = Arc::new(AtomicBool::new(false)); - self.cancel = Some(cancel.clone()); - self.generation = self.generation.wrapping_add(1); - let generation = self.generation; - let Some(sender) = self.sender.clone() else { - return; - }; - let root = self.root.clone(); - thread::spawn(move || { - // The four scan threads all report through here, so the channel - // and the signal clock live behind one lock. Waking the UI is the - // expensive half and is what gets rate-limited; the steps - // themselves queue as fast as the disk produces them. - let gate = Mutex::new(Instant::now()); - let sink = |step: ScanStep| { - if sender - .send(ScanMessage { - generation, - step: Some(step), - finished: None, - }) - .is_err() - { - return; - } - let mut due = gate.lock().unwrap_or_else(|e| e.into_inner()); - let now = Instant::now(); - if now >= *due { - *due = now + SIGNAL_EVERY; - SignalToUI::set_ui_signal(); - } - }; - // The saved map first, and off the UI thread: decoding a home - // directory's worth of tree is a tenth of a second of work that - // has no business happening between two frames. - let cached = if fresh || crate::vfs::is_demo() { - None - } else { - crate::sizecache::load(&root) - }; - if let Some(cached) = cached { - let _ = sender.send(ScanMessage { - generation, - step: Some(ScanStep::Closed { - at: Vec::new(), - node: cached.tree, - }), - finished: None, - }); - let _ = sender.send(ScanMessage { - generation, - step: None, - finished: Some(Outcome::Loaded { - scanned_at: cached.scanned_at, - }), - }); - SignalToUI::set_ui_signal(); - return; - } - let ok = crate::vfs::vfs().scan_stream(&root, &cancel, &sink); - let _ = sender.send(ScanMessage { - generation, - step: None, - finished: Some(if ok { Outcome::Scanned } else { Outcome::Failed }), - }); - SignalToUI::set_ui_signal(); - }); - self.redraw(cx); - } - - /// Stop whatever scan is running. Called when the view is left, when the - /// folder changes, and when the window goes away. - pub fn stop(&mut self, cx: &mut Cx) { - if let Some(cancel) = self.cancel.take() { - cancel.store(true, Ordering::Relaxed); - } - if self.scanning { - self.scanning = false; - self.redraw(cx); - } - } - - /// The status line for the map: what it is showing, or how far the scan - /// has got, and what the last click landed on. - pub fn status(&self) -> String { - if let Some(error) = &self.error { - return error.clone(); - } - let node = self.focused(); - let where_it_is = self - .focus_path() - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| self.focus_path().display().to_string()); - if self.scanning { - return format!( - "Scanning {where_it_is} — {} files · {} so far · {} folder{} still open", - self.tree.files, - treemap::format_bytes(self.tree.size), - self.folders_left, - if self.folders_left == 1 { "" } else { "s" }, - ); - } - let picked = match &self.pick { - Some(pick) => format!(" · picked {}", crate::model::display_name(&pick.path)), - None => String::new(), - }; - format!( - "{where_it_is} — {} in {} files · scroll zooms, drag pans, Esc backs out{picked}", - treemap::format_bytes(node.size), - node.files, - ) - } - - /// Take everything the worker sent. True when the view needs a redraw. - pub fn drain(&mut self, cx: &mut Cx) -> bool { - let messages: Vec = self - .receiver - .as_ref() - .map(|r| r.try_iter().collect()) - .unwrap_or_default(); - if messages.is_empty() { - return false; - } - let mut finished = false; - for message in messages { - if message.generation != self.generation { - continue; - } - if let Some(step) = message.step { - if let ScanStep::Pace { folders_left } = &step { - // Cheap and constant: no tree walk, just the walk's own - // count of folders it has not opened yet. - self.folders_left = *folders_left; - continue; - } - self.tree.apply(step); - self.stale = true; - self.totals_dirty = true; - self.tree_rev = self.tree_rev.wrapping_add(1); - } - if let Some(outcome) = message.finished { - self.scanning = false; - self.cancel = None; - self.stale = true; - self.folders_left = 0; - finished = true; - // Nothing is growing any more, so nothing is still pending. - self.tree.seal(); - self.denied = self.tree.denied_paths(4); - match outcome { - Outcome::Scanned => { - self.scanned_at = crate::sizecache::now(); - self.save_cache(); - } - Outcome::Loaded { scanned_at } => self.scanned_at = scanned_at, - Outcome::Failed => { - if self.tree.children.is_empty() { - self.error = Some(format!( - "Could not map {}", - crate::model::display_name(&self.root) - )); - } - } - } - } - } - // While the walk is running the tree changes far faster than the - // picture needs to; a finished scan always redraws at once. - if finished || self.layout_is_due() { - self.redraw(cx); - } else { - // Nothing gets lost: the trailing update is picked up on the next - // frame, once the throttle has expired. - self.frame = cx.new_next_frame(); - } - true - } - - /// Whether the picture may be rebuilt now. The throttle exists only to - /// keep a running scan from re-laying out the map hundreds of times a - /// second; once nothing is feeding it any more there is nothing to - /// throttle, and a map still showing a mid-scan snapshot after the walk - /// has finished would be quietly, plausibly wrong. - fn layout_is_due(&self) -> bool { - if !self.scanning { - return true; - } - match self.last_layout { - Some(at) => at.elapsed() >= RELAYOUT_EVERY, - None => true, - } - } - - /// Which path is highlighted on the map. - pub fn set_selected(&mut self, cx: &mut Cx, path: Option) { - let same = match (&self.pick, &path) { - (Some(pick), Some(path)) => &pick.path == path, - (None, None) => true, - _ => false, - }; - if same { - return; - } - self.pick = path.map(|path| Pick { - path, - size: 0, - files: 0, - is_dir: false, - bundle: 0, - }); - // The real numbers come from the cell when there is one, so a reveal - // from the list view reads the same as a click on the map. - if let Some(pick) = &self.pick { - if let Some(cell) = self.cells.iter().find(|c| c.path == pick.path) { - self.pick = Some(pick_of(cell)); - } - } - self.redraw(cx); - } - - /// The path the last click landed on. - pub fn selection(&self) -> Option { - self.pick.as_ref().map(|p| p.path.clone()) - } - - /// Step the view back out. The camera first — Esc un-orbits and un-zooms - /// what the eye did before it re-roots what a reveal did. False when - /// there is nowhere left to go. - pub fn zoom_out(&mut self, cx: &mut Cx) -> bool { - // Whatever is still gliding stops where Esc found it. - self.zoom_glide = None; - self.yaw_glide = None; - if self.projection != MapProjection::Flat - && (self.yaw.abs() > 0.01 || (self.pitch - DEFAULT_PITCH).abs() > 0.01) - { - self.set_orbit(cx, 0.0, DEFAULT_PITCH); - return true; - } - if self.cam_scale > 1.001 { - self.set_camera(cx, 1.0, DVec2::default()); - return true; - } - if self.zoom.pop().is_none() { - return false; - } - self.after_zoom(cx); - true - } - - /// Zoom to `depth` names deep, for a breadcrumb click. - fn zoom_to(&mut self, cx: &mut Cx, depth: usize) { - if depth >= self.zoom.len() { - return; - } - self.zoom.truncate(depth); - self.after_zoom(cx); - } - - /// Zoom into `path`, which must be under the mapped folder. False when it - /// is not, or is not a folder the scan knows about. - pub fn zoom_into(&mut self, cx: &mut Cx, path: &Path) -> bool { - let Ok(relative) = path.strip_prefix(&self.root) else { - return false; - }; - let names: Vec = relative - .components() - .map(|c| c.as_os_str().to_string_lossy().into_owned()) - .collect(); - if names.is_empty() { - return false; - } - let mut node = &self.tree; - for name in &names { - match node.child_named(name) { - Some(next) if next.is_dir => node = next, - _ => return false, - } - } - self.zoom = names; - self.after_zoom(cx); - true - } - - fn after_zoom(&mut self, cx: &mut Cx) { - self.hover = None; - self.stale = true; - self.last_layout = None; - self.laid_out = Rect::default(); - // A re-root measures a different subtree — the filter cache must not - // outlive the folder it was measured against. - self.tree_rev = self.tree_rev.wrapping_add(1); - // A re-root is a new picture; the camera starts over on it. - self.cam_scale = 1.0; - self.cam_off = DVec2::default(); - self.zoom_glide = None; - self.yaw_glide = None; - self.yaw = 0.0; - self.pitch = DEFAULT_PITCH; - self.layout_scale = 1.0; - self.layout_off = DVec2::default(); - self.layout_yaw = 0.0; - self.layout_pitch = DEFAULT_PITCH; - self.redraw(cx); - } - - // ------------------------------------------------------------- camera - - /// The rigid ride from where `cells` were laid out to where the camera - /// is now: `screen = laid·k + b`. Identity whenever the camera rests on - /// its own layout. - fn cam_remap(&self) -> (f64, DVec2) { - remap_params( - self.laid_out, - self.layout_scale, - self.layout_off, - self.cam_scale, - self.cam_off, - ) - } - - - /// Whether a camera gesture still owns the frame. While one does, camera - /// changes ride the remap and never relayout — that is the visual - /// constancy the whole scheme exists for. - fn cam_in_motion(&self) -> bool { - self.zoom_glide.is_some() - || self.yaw_glide.is_some() - || self.drag.map_or(false, |d| d.moved) - } - - /// Whether the layout no longer honestly covers what the camera shows — - /// the only reason a camera move is ever allowed to remake the map. - /// - /// This is deliberately a wide band, not an equality test. The layout is - /// not scale-invariant (the bundle floor moves with area, insets are - /// fixed point sizes), so *any* relayout at a slightly different camera - /// repacks groups and reads as tiles randomly reordering. Within the - /// band the rigid remap of the cells in hand is visually - /// indistinguishable from a fresh layout — so the fresh layout is not - /// bought. Spent means: zoomed in past what the layout resolves - /// ([`DETAIL_SLACK`]), or looking at ground outside the laid cull. - fn layout_spent(&self) -> bool { - // Spent cuts both ways: zoomed IN past the layout's detail floor, - // or zoomed OUT so far the layout in hand is a microscopic patch — - // a one-sided test here is how a zoom-out once froze the old - // zoomed-in layout on screen forever as wrong-scale ghost plates. - let ratio = self.cam_scale.max(1.0) / self.layout_scale.max(1.0); - if ratio > DETAIL_SLACK || ratio < 1.0 / DETAIL_SLACK { - return true; - } - self.view_escaped_cull() - } - - /// The hard half of [`Self::layout_spent`]: the live view is showing - /// ground the layout never laid. Spent-for-detail can wait for the - /// motion cadence — the picture is merely coarse; escaped cannot wait - /// for anything, because what it shows is *bare background*. - fn view_escaped_cull(&self) -> bool { - let body = self.laid_out; - if body.size.x <= 0.0 || self.laid_cull.w <= 0.0 { - return true; - } - // What the live camera can see, in the layout's own ground space. - let corners = [ - body.pos, - dvec2(body.pos.x + body.size.x, body.pos.y), - dvec2(body.pos.x + body.size.x, body.pos.y + body.size.y), - dvec2(body.pos.x, body.pos.y + body.size.y), - ]; - let mut min = dvec2(f64::MAX, f64::MAX); - let mut max = dvec2(f64::MIN, f64::MIN); - match self.projection { - MapProjection::Flat => { - let (k, b) = self.cam_remap(); - for corner in corners { - let g = dvec2((corner.x - b.x) / k, (corner.y - b.y) / k); - min.x = min.x.min(g.x); - min.y = min.y.min(g.y); - max.x = max.x.max(g.x); - max.y = max.y.max(g.y); - } - } - _ => { - // The draw path is screen = Cam(remap(ground)): undoing the - // projection alone leaves the point in the LIVE camera's - // frame, and the cull lives in the LAYOUT's. Skipping the - // second inverse is how a raised-mode zoom-out once compared - // a body-sized live footprint against a giant stale cull, - // never noticed the escape, and froze ghosts on screen. - let cam = self.cam_at(body); - let (k, b) = self.cam_remap(); - for corner in corners { - let live = cam.unproject_ground(corner); - let g = dvec2((live.x - b.x) / k, (live.y - b.y) / k); - min.x = min.x.min(g.x); - min.y = min.y.min(g.y); - max.x = max.x.max(g.x); - max.y = max.y.max(g.y); - } - // The cull was a rotation-proof square around the *layout* - // camera's footprint plus the lean reach; the live footprint - // needs that same reach to stay honestly inside. - let reach = self.elev(24) + 40.0; - min.x -= reach; - min.y -= reach; - max.x += reach; - max.y += reach; - } - } - min.x < self.laid_cull.x - || min.y < self.laid_cull.y - || max.x > self.laid_cull.x + self.laid_cull.w - || max.y > self.laid_cull.y + self.laid_cull.h - } - - /// Lay the map out at the camera's resting place, morphing there from - /// wherever the picture visually stands. A no-op when the layout in hand - /// still covers the view — which is exactly what keeps a small zoom or - /// pan visually constant end to end. - fn settle(&mut self, cx: &mut Cx) { - if self.tree.children.is_empty() || (!self.stale && !self.layout_spent()) { - return; - } - if self.tween_capture.is_none() { - // Calm unless the tree itself changed underneath the gesture — - // a camera settle re-derives the same picture at more detail. - self.tween_calm = !self.stale; - self.tween_capture = Some(self.visual_snapshot()); - } - self.stale = true; - self.last_layout = None; - self.redraw(cx); - } - - /// Mid-gesture, whether the coarse layout refresh may run: something to - /// refresh — the layout spent, or the tree changed under the scan — and - /// the cadence has passed. - fn motion_refresh_due(&self) -> bool { - if !self.layout_spent() && !self.stale { - return false; - } - match self.last_layout { - Some(at) => at.elapsed() >= MOTION_RELAYOUT, - None => true, - } - } - - /// Move the camera. Mid-gesture the picture rides the remap — one rigid - /// sheet, nothing re-flows — and the layout catches up when the motion - /// settles or on the coarse mid-motion cadence, arriving as a morph. A - /// discrete jump (a double-click fit, Esc) settles at once, so the new - /// detail — bundles dissolving into the things they stood for — morphs - /// in rather than popping. - fn set_camera(&mut self, cx: &mut Cx, scale: f64, off: DVec2) { - let body = self.laid_out; - let scale = scale.clamp(1.0, 512.0); - let off = dvec2( - off.x.clamp(0.0, (body.size.x * (scale - 1.0)).max(0.0)), - off.y.clamp(0.0, (body.size.y * (scale - 1.0)).max(0.0)), - ); - if (scale - self.cam_scale).abs() < 1e-9 && (off - self.cam_off).length() < 1e-9 { - return; - } - self.cam_scale = scale; - self.cam_off = off; - self.hover = None; - if !self.cam_in_motion() { - self.settle(cx); - } - self.redraw(cx); - } - - /// Zoom by `factor`, keeping the map point under `at` exactly where it - /// is — the anchor rule every map application follows. - fn zoom_at(&mut self, cx: &mut Cx, at: DVec2, factor: f64) { - let body = self.laid_out; - if body.size.x <= 0.0 || body.size.y <= 0.0 { - return; - } - let old = self.cam_scale.max(1.0); - let new = (old * factor).clamp(1.0, 512.0); - let factor = new / old; - let anchor = at - body.pos; - self.set_camera( - cx, - new, - dvec2( - (self.cam_off.x + anchor.x) * factor - anchor.x, - (self.cam_off.y + anchor.y) * factor - anchor.y, - ), - ); - } - - // -------------------------------------------------- projection & filter - - /// One nesting level's worth of elevation, in on-screen points. Grows - /// with the square root of the camera so towers stay proud when zoomed - /// without ever dwarfing the tiles. - fn rise(&self) -> f64 { - RISE * self.cam_scale.max(1.0).sqrt() - } - - /// The elevation of a plate at `depth`. The top level sits on the floor - /// — exactly where the flat map has it — and every nesting level steps - /// up one rise from there. - fn elev(&self, depth: usize) -> f64 { - depth.min(24) as f64 * self.rise() - } - - fn elev_f(&self, depth: f64) -> f64 { - depth.min(24.0) * self.rise() - } - - /// The orbit camera for a map drawn into `body`. The flat projection is - /// the same camera pinned straight down and un-spun, which makes it the - /// identity — one code path for all three. - fn cam_at(&self, body: Rect) -> Cam { - let (yaw, pitch) = match self.projection { - MapProjection::Flat => (0.0, 0.0), - _ => (self.yaw, self.pitch), - }; - Cam { - pivot: dvec2( - body.pos.x + body.size.x * 0.5, - body.pos.y + body.size.y * 0.5, - ), - sin_yaw: yaw.sin(), - cos_yaw: yaw.cos(), - sin_pitch: pitch.sin(), - cos_pitch: pitch.cos(), - persp: self.projection == MapProjection::Persp, - } - } - - /// The layout-space direction a raised prism drifts in as it gains - /// elevation — where towers lean, and therefore what the painter's - /// order must follow. Screen-up, un-spun by the yaw. - fn lean(&self) -> DVec2 { - dvec2(-self.yaw.sin(), -self.yaw.cos()) - } - - /// Point the orbit somewhere. The cells stay put — only the projection - /// of them moves — so no relayout mid-gesture; the paint order alone - /// must follow the new lean at once, or towers overlap wrongly the very - /// frame the yaw crosses a quadrant. - fn set_orbit(&mut self, cx: &mut Cx, yaw: f64, pitch: f64) { - let yaw = wrap_angle(yaw); - let pitch = pitch.clamp(0.0, MAX_PITCH); - if (yaw - self.yaw).abs() < 1e-9 && (pitch - self.pitch).abs() < 1e-9 { - return; - } - self.yaw = yaw; - self.pitch = pitch; - self.hover = None; - self.paint_order = match self.projection { - MapProjection::Flat => Vec::new(), - _ => view_order(&self.cells, self.lean()), - }; - if !self.cam_in_motion() { - self.settle(cx); - } - self.redraw(cx); - } - - /// Nudge the orbit — the keyboard's Q/E. A yaw-only nudge glides there - /// rather than snapping, and a second tap mid-glide just aims further. - pub fn orbit_by(&mut self, cx: &mut Cx, dyaw: f64, dpitch: f64) { - if self.projection == MapProjection::Flat { - return; - } - if dpitch == 0.0 { - let base = self.yaw_glide.map_or(self.yaw, |(target, _)| target); - self.yaw_glide = Some((wrap_angle(base + dyaw), Instant::now())); - self.frame = cx.new_next_frame(); - return; - } - self.set_orbit(cx, self.yaw + dyaw, self.pitch + dpitch); - } - - /// One frame of whichever glides are running: ease toward the target, - /// keep the frame clock alive until both arrive. - fn step_glides(&mut self, cx: &mut Cx) { - if let Some(mut glide) = self.zoom_glide.take() { - let now = Instant::now(); - let dt = now.duration_since(glide.last).as_secs_f64().min(0.1); - glide.last = now; - let current = self.cam_scale.max(1.0); - // Zoom lives in ratio space: equal glide time closes an equal - // *proportion* of the remaining ratio, in or out alike. - let remaining = (glide.target / current).ln(); - if remaining.abs() < 0.002 { - // Arrived: the last step runs un-glided, and the layout - // settles under wherever the ride ended. - self.zoom_at(cx, glide.anchor, glide.target / current); - self.settle(cx); - } else { - let k = 1.0 - (-dt / GLIDE_TAU).exp(); - // Restored before the step, so the camera change knows a - // glide still owns it and rides the remap. - self.zoom_glide = Some(glide); - self.zoom_at(cx, glide.anchor, (remaining * k).exp()); - self.frame = cx.new_next_frame(); - } - } - if let Some((target, last)) = self.yaw_glide.take() { - let now = Instant::now(); - let dt = now.duration_since(last).as_secs_f64().min(0.1); - let remaining = wrap_angle(target - self.yaw); - if remaining.abs() < 0.002 { - self.set_orbit(cx, target, self.pitch); - self.settle(cx); - } else { - let k = 1.0 - (-dt / GLIDE_TAU).exp(); - self.yaw_glide = Some((target, now)); - self.set_orbit(cx, self.yaw + remaining * k, self.pitch); - self.frame = cx.new_next_frame(); - } - } - } - - /// Change how the map projects. The layout itself never changes — only - /// what is done with it on the way to the screen. - pub fn set_projection(&mut self, cx: &mut Cx, projection: MapProjection) { - if self.projection == projection { - return; - } - self.projection = projection; - if self.pitch <= 0.0 { - self.pitch = DEFAULT_PITCH; - } - self.hover = None; - self.stale = true; - self.last_layout = None; - self.redraw(cx); - } - - /// Apply (or clear) the live filter, morphing from the picture on screen. - pub fn set_filter(&mut self, cx: &mut Cx, filter: Option) { - let filter = filter.filter(|q| !q.is_empty()); - if filter == self.filter { - return; - } - // Aim the tween from wherever things visually are right now — a - // slider mid-drag retargets smoothly instead of jumping. - self.tween_capture = Some(self.visual_snapshot()); - self.tween_calm = false; - self.filter = filter; - self.stale = true; - self.last_layout = None; - self.hover = None; - self.redraw(cx); - } - - /// Whether a filter is active, and what it matched: (bytes, files). - pub fn filter_matched(&self) -> Option<(u64, u32)> { - self.filter.as_ref()?; - self.filtered - } - - /// Byte totals per kind tag under the mapped folder — the legend's - /// numbers. Recounted only after the tree actually changed. - pub fn kind_totals(&mut self) -> [u64; 16] { - if self.totals_dirty { - self.totals = treemap::kind_totals(&self.tree); - self.totals_dirty = false; - } - self.totals - } - - /// Eased tween progress, or None when nothing is morphing. - fn tween_t(&self) -> Option { - let start = self.tween_start?; - let t = start.elapsed().as_secs_f64() / TWEEN.as_secs_f64(); - if t >= 1.0 { - return None; - } - // Smoothstep: no snap at either end. - Some(t * t * (3.0 - 2.0 * t)) - } - - /// Every cell's current on-screen truth — the rect it is visually at, - /// mid-tween and mid-gesture alike, and its fractional depth — plus the - /// leavers still fading out. Remapped through the live camera, so a - /// tween aimed from here starts exactly where the eye left off. - fn visual_snapshot(&self) -> Vec<(Cell, MapRect, f64)> { - let t = self.tween_t(); - let (rk, rb) = self.cam_remap(); - let mut out: Vec<(Cell, MapRect, f64)> = Vec::with_capacity(self.cells.len()); - for cell in &self.cells { - let (rect, depth, alive) = self.tweened(cell, t); - if alive > 0.0 { - out.push((cell.clone(), remap_rect(&rect, rk, rb), depth)); - } - } - if let Some(t) = t { - for (cell, rect, _) in &self.tween_leavers { - if 1.0 - t > 0.05 { - out.push((cell.clone(), remap_rect(rect, rk, rb), cell.depth as f64)); - } - } - } - out - } - - /// Where `cell` is right now: (layout rect, fractional depth, alpha). - fn tweened(&self, cell: &Cell, t: Option) -> (MapRect, f64, f64) { - let Some(t) = t else { - return (cell.rect, cell.depth as f64, 1.0); - }; - match self.tween_from.get(&cell.path) { - Some(from) => ( - lerp_rect(&from.rect, &cell.rect, t), - from.depth + (cell.depth as f64 - from.depth) * t, - 1.0, - ), - None => { - // An arriver: grows out of its own footprint. - let grown = 0.7 + 0.3 * t; - let rect = MapRect { - x: cell.rect.x + cell.rect.w * (1.0 - grown) * 0.5, - y: cell.rect.y + cell.rect.h * (1.0 - grown) * 0.5, - w: cell.rect.w * grown, - h: cell.rect.h * grown, - }; - (rect, cell.depth as f64, t) - } - } - } - - /// Fill the panel with `rect` — what a double-click means: go look at - /// this one, without re-rooting anything. - fn fit_rect(&mut self, cx: &mut Cx, rect: Rect) { - let body = self.laid_out; - if rect.size.x <= 1.0 || rect.size.y <= 1.0 || body.size.x <= 0.0 { - return; - } - let old = self.cam_scale.max(1.0); - let fit = (body.size.x / rect.size.x).min(body.size.y / rect.size.y) * 0.94; - let new = (old * fit).clamp(1.0, 512.0); - let factor = new / old; - let pos = dvec2( - (rect.pos.x - body.pos.x + self.cam_off.x) * factor, - (rect.pos.y - body.pos.y + self.cam_off.y) * factor, - ); - let size = dvec2(rect.size.x * factor, rect.size.y * factor); - self.set_camera( - cx, - new, - dvec2( - pos.x - (body.size.x - size.x) * 0.5, - pos.y - (body.size.y - size.y) * 0.5, - ), - ); - } - - /// Write the finished tree out for next time. Encoding walks the whole - /// tree so it happens here, where the tree is; the file write is somebody - /// else's problem, on a thread nobody is waiting for. - fn save_cache(&self) { - if crate::vfs::is_demo() { - return; - } - let Some(bytes) = crate::sizecache::encode(&self.root, &self.tree, self.scanned_at) else { - return; - }; - let root = self.root.clone(); - thread::spawn(move || crate::sizecache::store(&root, &bytes)); - } - - /// `path` as the chain of names between the mapped folder and it. - fn names_of(&self, path: &Path) -> Option> { - let relative = path.strip_prefix(&self.root).ok()?; - let names: Vec = relative - .components() - .map(|c| c.as_os_str().to_string_lossy().into_owned()) - .collect(); - (!names.is_empty()).then_some(names) - } - - /// Fold a set of finished moves into the map instead of measuring the - /// disk again. Each pair is where something was and where it went, with - /// `None` for "it stopped existing". - /// - /// This is the whole reason the map is worth caching: the app already - /// knows exactly how big the thing it just deleted was, so the picture can - /// be made true again by arithmetic — a delete costs no disk reads at all. - /// A scan in flight owns the tree and will produce the truth on its own, - /// so this stays out of its way. - pub fn absorb_moves(&mut self, cx: &mut Cx, moves: &[(PathBuf, Option)]) { - if self.scanning || self.tree.children.is_empty() { - return; - } - let mut changed = false; - for (from, to) in moves { - let Some(names) = self.names_of(from) else { - continue; - }; - let Some(mut node) = self.tree.detach(&names) else { - continue; - }; - changed = true; - if self.pick.as_ref().is_some_and(|p| &p.path == from) { - self.pick = None; - } - // Moved rather than removed, and landed somewhere still on the - // map: the bytes did not leave, so neither does the rectangle. - let Some(to) = to else { continue }; - let Some(name) = to.file_name() else { continue }; - node.name = name.to_string_lossy().into_owned(); - self.graft_at(to, node); - } - if changed { - self.after_change(cx); - } - } - - /// Fold finished copies in: the source stays where it is and a second - /// rectangle of the same size appears at the destination. - pub fn absorb_copies(&mut self, cx: &mut Cx, copies: &[(PathBuf, PathBuf)]) { - if self.scanning || self.tree.children.is_empty() { - return; - } - let mut changed = false; - for (from, to) in copies { - let Some(names) = self.names_of(from) else { - continue; - }; - let Some(indices) = self.names_of(to) else { - continue; - }; - let Some(source) = self.tree.at(&names) else { - continue; - }; - let mut node = source.clone(); - let Some(name) = to.file_name() else { continue }; - node.name = name.to_string_lossy().into_owned(); - let _ = indices; - if self.graft_at(to, node) { - changed = true; - } - } - if changed { - self.after_change(cx); - } - } - - /// Put `node` where `full` says, which is a path *including* the node's - /// own name — the parent is what actually receives it. - fn graft_at(&mut self, full: &Path, node: Node) -> bool { - let Some(parent) = full.parent() else { - return false; - }; - if parent == self.root { - return self.tree.graft(&[], node); - } - match self.names_of(parent) { - Some(names) => self.tree.graft(&names, node), - None => false, - } - } - - /// Drop `path` from the map because the disk says it is not there any - /// more. Cheap, exact, and the answer to a cached map going stale one - /// file at a time. - pub fn forget(&mut self, cx: &mut Cx, path: &Path) { - if self.scanning { - return; - } - let Some(names) = self.names_of(path) else { - return; - }; - if self.tree.detach(&names).is_some() { - if self.pick.as_ref().is_some_and(|p| p.path == path) { - self.pick = None; - } - self.after_change(cx); - } - } - - fn after_change(&mut self, cx: &mut Cx) { - self.hover = None; - self.stale = true; - self.totals_dirty = true; - self.tree_rev = self.tree_rev.wrapping_add(1); - self.last_layout = None; - self.save_cache(); - self.redraw(cx); - } - - fn relayout(&mut self, rect: Rect) { - let base = self.focus_path(); - // The region the *outgoing* layout covered, before it is replaced — - // the line between a camera reveal and data actually appearing. - let old_cull = self.laid_cull; - let old_remap = self.cam_remap(); - // The map is laid out at the camera's magnification and culled to - // the panel: zoomed in, the layout does the work of the pixels on - // screen, not of the whole magnified picture. - let scale = self.cam_scale.max(1.0); - let area = MapRect { - x: rect.pos.x - self.cam_off.x, - y: rect.pos.y - self.cam_off.y, - w: rect.size.x * scale, - h: rect.size.y * scale, - }; - // A glide in flight knows where it will land. The cull covers the - // destination's footprint too, so the glide itself can never outrun - // the layout: a violent out-zoom lays out the whole ride's ground - // now, in this one relayout, instead of flashing bare background and - // chasing it frame by frame. The destination offset is the glide's - // own anchor arithmetic run to its target, under set_camera's - // clamps. Per axis the destination view maps into this layout's - // ground frame affinely (same yaw and pitch all glide long). - let dest = self.zoom_glide.map(|glide| { - let scale_c = self.cam_scale.max(1.0); - let scale_t = glide.target.clamp(1.0, 512.0); - let anchor = glide.anchor - rect.pos; - let factor = scale_t / scale_c; - let off_t = dvec2( - ((self.cam_off.x + anchor.x) * factor - anchor.x) - .clamp(0.0, (rect.size.x * (scale_t - 1.0)).max(0.0)), - ((self.cam_off.y + anchor.y) * factor - anchor.y) - .clamp(0.0, (rect.size.y * (scale_t - 1.0)).max(0.0)), - ); - // ground = rect.pos - off_now + (screen - rect.pos + off_dest) - // * (scale_now / scale_dest), per axis. - let r = scale_c / scale_t; - ( - dvec2( - rect.pos.x - self.cam_off.x + (off_t.x) * r, - rect.pos.y - self.cam_off.y + (off_t.y) * r, - ), - r, - ) - }); - // Map a point of the live view into where the glide's destination - // camera will show that screen spot, in this layout's ground frame. - let to_dest = |q: DVec2, dest: &(DVec2, f64)| { - dvec2( - dest.0.x + (q.x - rect.pos.x) * dest.1, - dest.0.y + (q.y - rect.pos.y) * dest.1, - ) - }; - // What the camera can see, on the ground plane: the panel's corners - // un-projected, boxed, and grown by the tallest possible lean — the - // cull has to keep whatever could spin or lean into view. - let viewport = match self.projection { - MapProjection::Flat => { - // A margin past the panel, so a pan or an out-zoom rides the - // remap without exposing unlaid ground before the next - // refresh. Cells in the margin are laid out but skipped at - // draw time, so they cost layout, not paint. - let pad = dvec2( - rect.size.x * MOTION_CULL_PAD, - rect.size.y * MOTION_CULL_PAD, - ); - let mut min = dvec2(rect.pos.x - pad.x, rect.pos.y - pad.y); - let mut max = dvec2( - rect.pos.x + rect.size.x + pad.x, - rect.pos.y + rect.size.y + pad.y, - ); - if let Some(dest) = &dest { - let a = to_dest(rect.pos, dest); - let b = to_dest( - dvec2(rect.pos.x + rect.size.x, rect.pos.y + rect.size.y), - dest, - ); - min.x = min.x.min(a.x); - min.y = min.y.min(a.y); - max.x = max.x.max(b.x); - max.y = max.y.max(b.y); - } - MapRect { - x: min.x, - y: min.y, - w: max.x - min.x, - h: max.y - min.y, - } - } - _ => { - let cam = self.cam_at(rect); - let corners = [ - rect.pos, - dvec2(rect.pos.x + rect.size.x, rect.pos.y), - dvec2(rect.pos.x + rect.size.x, rect.pos.y + rect.size.y), - dvec2(rect.pos.x, rect.pos.y + rect.size.y), - ]; - let mut min = dvec2(f64::MAX, f64::MAX); - let mut max = dvec2(f64::MIN, f64::MIN); - for corner in corners { - let g = cam.unproject_ground(corner); - min.x = min.x.min(g.x); - min.y = min.y.min(g.y); - max.x = max.x.max(g.x); - max.y = max.y.max(g.y); - if let Some(dest) = &dest { - // The glide's destination sees this screen corner at - // a different ground spot; the cull keeps both. Yaw - // and pitch hold still during a zoom glide, so the - // scale/offset affine is the whole difference. - let d = to_dest(g, dest); - min.x = min.x.min(d.x); - min.y = min.y.min(d.y); - max.x = max.x.max(d.x); - max.y = max.y.max(d.y); - } - } - let reach = self.elev(24) + 40.0; - // Rotation-proof: a mid-drag orbit swings the visible - // footprint around the pivot without a relayout, so the cull - // is the square that covers the footprint at any yaw — its - // centre, sides the footprint's diagonal. - let half = ((max.x - min.x).powi(2) + (max.y - min.y).powi(2)).sqrt() * 0.5 - + reach; - let mid = dvec2((min.x + max.x) * 0.5, (min.y + max.y) * 0.5); - MapRect { - x: mid.x - half, - y: mid.y - half, - w: half * 2.0, - h: half * 2.0, - } - } - }; - // The filter's weights come from a measure tree cached against the - // tree revision and the query: a camera move re-lays-out every frame - // and must never pay for re-measuring what did not change. - match &self.filter { - None => { - self.measure = None; - self.measure_query = None; - self.filtered = None; - } - Some(query) => { - if self.measure.is_none() - || self.measure_rev != self.tree_rev - || self.measure_query.as_ref() != Some(query) - { - let focused = self.focused(); - let measured = - treemap::measure(focused, query, query.name_hits(&focused.name)); - self.measure = Some(measured); - self.measure_rev = self.tree_rev; - self.measure_query = Some(query.clone()); - } - self.filtered = self.measure.as_ref().map(|m| (m.bytes, m.files)); - } - } - let cells = treemap::layout( - self.focused(), - &base, - area, - viewport, - &self.style, - self.measure.as_ref(), - ); - self.laid_cull = viewport; - self.cells = cells; - self.paint_order = match self.projection { - MapProjection::Flat => Vec::new(), - _ => view_order(&self.cells, self.lean()), - }; - // A filter change captured the map as it looked; aim the tween from - // there to the layout just built. - if let Some(snapshot) = self.tween_capture.take() { - let calm = std::mem::take(&mut self.tween_calm); - if calm && self.tween_t().is_none() { - // A camera-asked settle with nothing already morphing runs - // no animation at all. With zoom-invariant packing a - // survivor's fresh rect IS its remapped old rect, and the - // detail the refresh brought in was always there on disk — - // the new layout simply is. Starting the interpolator here - // would be 200ms of re-presentation per cadence: the "boxes - // animating" a quiet zoom must not have. - self.tween_from.clear(); - self.tween_leavers.clear(); - self.tween_start = None; - } else { - let now_here: std::collections::HashSet<&Path> = - self.cells.iter().map(|c| c.path.as_path()).collect(); - self.tween_from = snapshot - .iter() - .filter(|(cell, _, _)| now_here.contains(cell.path.as_path())) - .map(|(cell, rect, depth)| { - (cell.path.clone(), TweenFrom { rect: *rect, depth: *depth }) - }) - .collect(); - if calm { - // A camera settle that landed while an older morph (a - // filter edit moments ago) was still in flight: re-aim - // the running morph from the visual truth and let it - // finish. Everything the refresh added joins at its own - // rect, full alpha — camera-brought detail never fades. - for cell in &self.cells { - if !self.tween_from.contains_key(&cell.path) { - self.tween_from.insert( - cell.path.clone(), - TweenFrom { rect: cell.rect, depth: cell.depth as f64 }, - ); - } - } - self.tween_leavers = Vec::new(); - } else { - // A cell new to the list is either a camera reveal — it - // was sitting outside the outgoing layout's cull, always - // there on disk, merely unlaid — or detail that genuinely - // appeared (a bundle dissolving, a scan step, a filter - // edit). Reveals must simply *be there*: styling them - // with the arrival fade reads as data popping into - // existence at the edge of an orbit or pan. So a reveal - // joins the tween at its own rect (no motion, full alpha) - // and only true arrivals keep the fade-and-grow. The - // snapshot and the fresh layout share a frame — snapshot - // rects were remapped to the live camera, which the - // fresh layout now rests under — so the old cull is - // compared in that frame too, through the remap the - // snapshot itself used. - if old_cull.w > 0.0 { - let (rk, rb) = old_remap; - let seen = remap_rect(&old_cull, rk, rb); - for cell in &self.cells { - if !self.tween_from.contains_key(&cell.path) - && !cell.rect.intersects(&seen) - { - self.tween_from.insert( - cell.path.clone(), - TweenFrom { rect: cell.rect, depth: cell.depth as f64 }, - ); - } - } - } - // Symmetric on the way out: a cell the new layout culled - // away is just going off-view — it vanishes with the - // frame, no goodbye fade. Only a leaver still inside the - // laid region (absorbed, filtered out) earns one. - self.tween_leavers = snapshot - .into_iter() - .filter(|(cell, rect, _)| { - !now_here.contains(cell.path.as_path()) - && rect.intersects(&self.laid_cull) - }) - .collect(); - } - self.tween_start = Some(Instant::now()); - } - } - self.laid_out = rect; - self.layout_rev = self.layout_rev.wrapping_add(1); - // The layout now rests exactly under the live camera: the remap is - // the identity again until the next gesture departs from here. - self.layout_scale = self.cam_scale.max(1.0); - self.layout_off = self.cam_off; - self.layout_yaw = self.yaw; - self.layout_pitch = self.pitch; - self.stale = false; - self.last_layout = Some(Instant::now()); - // The cell list is new, so the hovered index means nothing any more. - self.hover = None; - // The selection is a path, not an index, so it survives — but its - // numbers are refreshed from whatever cell now stands for it. - if let Some(pick) = self.pick.take() { - let refreshed = self - .cells - .iter() - .find(|c| c.path == pick.path && !c.is_bundle()) - .map(pick_of); - self.pick = refreshed.or(Some(pick)); - } - } - - /// The cell under a window point, if any. In the raised projections the - /// test happens on the projected faces — top plate first, then the walls - /// it stands on — front-most first: the reverse of paint order, which is - /// what "front" means. - fn hit_cell(&self, pos: DVec2) -> Option { - let (rk, rb) = self.cam_remap(); - if self.projection == MapProjection::Flat || self.paint_order.len() != self.cells.len() { - // The inverse ride: the pointer comes back from the screen into - // the space the cells were laid out in, so a mid-gesture hover - // or click lands on what the eye actually sees. - let p = dvec2((pos.x - rb.x) / rk, (pos.y - rb.y) / rk); - return treemap::hit(&self.cells, p.x, p.y); - } - let cam = self.cam_at(self.laid_out); - let rise = self.rise(); - for &index in self.paint_order.iter().rev() { - let cell = &self.cells[index]; - let rect = remap_rect(&cell.rect, rk, rb); - let z = self.elev(cell.depth); - if Quad::of_rect(&cam, &rect, z).contains(pos) { - return Some(index); - } - if z > 0.0 { - for wall in wall_quads(&cam, &rect, z, rise.min(z)).into_iter().flatten() { - if wall.quad.contains(pos) { - return Some(index); - } - } - } - } - None - } - - /// The file or folder under a window point — what a right-click there is - /// about. Never the "N smaller items" bundle, which is not a thing on - /// disk and must never become the target of an operation. - pub fn path_at(&self, pos: DVec2) -> Option { - self.hit_cell(pos) - .map(|i| &self.cells[i]) - .filter(|c| !c.is_bundle()) - .map(|c| c.path.clone()) - } - - // ------------------------------------------------------------- painting - - fn tile_colors(&self, cell: &Cell, palette: &Palette) -> (Vec4f, f32) { - let bg = Palette::vec4(&palette.bg); - if cell.kind == KIND_BUNDLE { - // Not a file: the sum of everything too small to see. It reads as - // a texture rather than as a thing, which is what it is. - return (blend(Palette::vec4(&palette.muted), bg, 0.45), 0.35); - } - let class = kind_class(cell_kind(cell)) as usize; - let mut hue = palette.kind_color(class); - if class == OTHER_CLASS && !cell.is_dir { - // The theme's "other" is a chrome grey — the colour of a border, - // not of a thing. A 4 GB disk image or a database file painted in - // it disappears into the background, and on a real disk the - // unclassifiable blobs are most of what there is to clean up. - hue = blend(hue, Palette::vec4(&palette.fg), 0.55); - } - if cell.is_group { - // A group is the plate its children sit on: nearly background, but - // carrying a trace of its own heaviest content's hue so the shape - // of the disk survives even where nothing inside it fits. - let plate = blend(hue, bg, 0.86 - 0.02 * cell.depth.min(4) as f32); - return (plate, 0.22); - } - // Leaves darken slightly with depth, which reads as "further in" - // without ever making two kinds look like each other. - let depth_shade = 1.0 - 0.05 * cell.depth.min(6) as f32; - let base = if cell.is_dir { - // A folder too small to open is still a folder: half way to the - // plate, so it never reads as one big file. - blend(hue, bg, 0.45) - } else { - hue - }; - (scale_rgb(base, depth_shade), 0.62) - } - - fn draw_map(&mut self, cx: &mut Cx2d, palette: &Palette, clip: Rect) -> Vec