diff --git a/.gitignore b/.gitignore index 0efa6743a..2a6e1d695 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ # 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 d7ed84544..460538847 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,18 +1,33 @@ 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/asset/chat_ui", + "libs/chat_ui", "libs/asset/annotate", "libs/render", "libs/raytrace", @@ -22,6 +37,8 @@ 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", @@ -39,6 +56,7 @@ workspace.members = [ "examples/datagrid", "examples/todo", "examples/portallist_hit", + "examples/modal_footprint", "examples/vector", "examples/aichat", "examples/pdf", @@ -71,6 +89,20 @@ 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) === @@ -79,14 +111,25 @@ 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", @@ -99,10 +142,12 @@ workspace.exclude = [ # like a subdirectory. Not a required member so a clean makepad tree # still loads. "apps/sandbox", - "apps/sandbox/tools/sandbox-eval", + # Private Source Library picture wall. Same shape as sandbox: clone into + # apps/source-library; path deps stay ../../. + "apps/source-library", "libs/terminal_core", # standalone GPU generate service (own workspace, like diffusion) - "libs/asset/ai", + "libs/ai/hub", "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 new file mode 100644 index 000000000..0ecb86897 --- /dev/null +++ b/apps/ai-hub/Cargo.toml @@ -0,0 +1,35 @@ +[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/libs/asset/ai/src/main.rs b/apps/ai-hub/src/main.rs similarity index 62% rename from libs/asset/ai/src/main.rs rename to apps/ai-hub/src/main.rs index 956d82597..6d42b5fbe 100644 --- a/libs/asset/ai/src/main.rs +++ b/apps/ai-hub/src/main.rs @@ -1,14 +1,14 @@ -//! makepad-asset-ai service binary. Runs on each GPU box; wraps all AI +//! makepad-ai-hub service binary. Runs on each GPU box; wraps all AI //! content generation behind a port. //! //! ```text -//! makepad-asset-ai [--port N] [--host ADDR] [--cache-dir PATH] [--registry PATH] +//! makepad-ai-hub [--port N] [--host ADDR] [--cache-dir PATH] [--registry PATH] //! //! --port listen port (env MAKEPAD_ASSET_AI_PORT, default 8765) //! --host bind address (default 0.0.0.0) //! --fleet partition name (env MAKEPAD_ASSET_AI_FLEET, default default) //! --cache-dir model + artifact dir (env MAKEPAD_ASSET_AI_CACHE, -//! default /.makepad/ai_content) +//! default /.makepad/weights) //! --registry registry json path (default: /registry.json if it //! exists, else the embedded registry) //! @@ -16,10 +16,10 @@ //! env MAKEPAD_ASSET_AI_HF_BASE alternate HF endpoint / LAN mirror //! ``` -use makepad_asset_ai::download::Downloader; -use makepad_asset_ai::registry::Registry; -use makepad_asset_ai::server::{start_service, ServiceConfig}; -use makepad_asset_ai::{AssetAiError, DEFAULT_PORT, SERVICE_NAME, SERVICE_VERSION}; +use makepad_ai_hub::download::Downloader; +use makepad_ai_hub::registry::Registry; +use makepad_ai_hub::server::{start_service, ServiceConfig}; +use makepad_ai_hub::{AssetAiError, DEFAULT_PORT, SERVICE_NAME, SERVICE_VERSION}; use std::path::PathBuf; fn main() { @@ -35,6 +35,7 @@ fn run() -> Result<(), AssetAiError> { let mut fleet: Option = None; let mut cache_dir: Option = None; let mut registry_path: Option = None; + let mut machine = false; let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { @@ -69,9 +70,15 @@ fn run() -> Result<(), AssetAiError> { AssetAiError::Io("--registry needs a value".into()) })?)); } + // The machine node (aicore §3): loopback-only, registered in + // ~/.makepad/run for the apps on this machine, and gone on its + // own once nothing needs it — a cache, not a daemon. + "--machine" => { + machine = true; + } "--help" | "-h" => { println!( - "{SERVICE_NAME} {SERVICE_VERSION}\nusage: {SERVICE_NAME} [--port N] [--host ADDR] [--fleet NAME] [--cache-dir PATH] [--registry PATH]" + "{SERVICE_NAME} {SERVICE_VERSION}\nusage: {SERVICE_NAME} [--port N] [--host ADDR] [--fleet NAME] [--cache-dir PATH] [--registry PATH] [--machine]" ); return Ok(()); } @@ -91,8 +98,8 @@ fn run() -> Result<(), AssetAiError> { }, }; let cache_dir = cache_dir.unwrap_or_else(default_cache_dir); - let fleet = makepad_asset_ai::discovery::normalize_fleet( - &fleet.unwrap_or_else(makepad_asset_ai::discovery::fleet_from_env), + let fleet = makepad_ai_hub::discovery::normalize_fleet( + &fleet.unwrap_or_else(makepad_ai_hub::discovery::fleet_from_env), ); // Registry: explicit path > registry.json dropped into the cache dir @@ -108,6 +115,11 @@ fn run() -> Result<(), AssetAiError> { } }; + // The machine node is machine-local by definition: loopback bind, no + // matter what --host said. + if machine { + host = "127.0.0.1".to_string(); + } let downloader = Downloader::from_env()?; let handle = start_service(ServiceConfig { host, @@ -134,19 +146,63 @@ fn run() -> Result<(), AssetAiError> { " endpoints: /health /models /jobs /loras POST:/generate /job/ POST:/job//cancel /artifact/ /v1/model_inventory /v1/model_blob/ POST:/realtime GET(ws):/realtime/" ); + if machine { + return run_machine_node(handle); + } + // The http listener thread runs until the process is killed. let _ = handle.http_thread.join(); Ok(()) } +/// The machine node's life: register in ~/.makepad/run so the apps on this +/// machine find it, then idle down and exit once nothing has needed it for +/// the TTL — it reads as a cache, not a daemon (aicore §3). Visible in any +/// process list as makepad-ai-hub. +fn run_machine_node(handle: makepad_ai_hub::server::ServiceHandle) -> Result<(), AssetAiError> { + use makepad_ai_hub::machine::{write_node_entry, NodeEntry}; + use std::time::{Duration, Instant}; + + let ttl_min: u64 = std::env::var("MAKEPAD_AI_HUB_MACHINE_TTL_MIN") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(15); + let entry = NodeEntry { + pid: std::process::id() as u64, + port: handle.addr.port(), + pipes_hash: 0, + }; + let entry_path = write_node_entry(&handle.shared.node_key, &entry) + .map_err(|e| AssetAiError::Io(format!("write node entry: {e}")))?; + println!(" machine node: registered {} (ttl {ttl_min}m idle)", entry_path.display()); + + let mut idle_since = Instant::now(); + loop { + std::thread::sleep(Duration::from_secs(30)); + // Busy = queued/running work, or a model somebody paid to load. + let pending = handle.shared.jobs.with(|store| store.pending_count()) > 0; + let resident = handle + .shared + .models + .lock() + .unwrap() + .values() + .any(|track| matches!(track, makepad_ai_hub::server::ModelTrack::Loaded)); + if pending || resident { + idle_since = Instant::now(); + } else if idle_since.elapsed() > Duration::from_secs(ttl_min * 60) { + println!("{SERVICE_NAME}: machine node idle for {ttl_min}m — exiting"); + let _ = std::fs::remove_file(&entry_path); + return Ok(()); + } + } +} + fn default_cache_dir() -> PathBuf { if let Some(dir) = std::env::var_os("MAKEPAD_ASSET_AI_CACHE") { return PathBuf::from(dir); } - // USERPROFILE on Windows, HOME elsewhere; temp dir as a last resort. - let home = std::env::var_os("USERPROFILE") - .or_else(|| std::env::var_os("HOME")) - .map(PathBuf::from) - .unwrap_or_else(std::env::temp_dir); - home.join(".makepad").join("ai_content") + makepad_ai_hub::home::default_weights_dir_with_migration(&mut |message| { + eprintln!("{SERVICE_NAME}: {message}"); + }) } diff --git a/apps/asset-server/src/embed.rs b/apps/asset-server/src/embed.rs new file mode 100644 index 000000000..6f5ea2ce4 --- /dev/null +++ b/apps/asset-server/src/embed.rs @@ -0,0 +1,358 @@ +//! 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 bf4380f57..123c1346c 100644 --- a/apps/asset-server/src/lib.rs +++ b/apps/asset-server/src/lib.rs @@ -6,39 +6,31 @@ //! 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, 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. +//! 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. //! //! # What it carries //! -//! [`Host::start`] composes three things that a fleet of clients needs, all -//! of them existing, tested code: +//! [`Host::start`] composes two things, both existing, tested code: //! //! 1. [`makepad_asset_store::AssetServer`] — catalog + CAS over the control -//! 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. +//! and data planes, the games publish path, the committed events hub, +//! game rooms, the 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 //! -//! 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. +//! 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. //! //! # Single-owner laws //! @@ -48,23 +40,19 @@ //! 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. -//! - 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. + +pub mod embed; use makepad_asset_client::{ApiEndpoints, AssetClient, ClientConfig, PublishRights}; -use makepad_asset_store::{AssetServer, DiscoveryConfig, ServerConfig}; +use makepad_asset_store::{AssetServer, BlobRefPolicy, 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 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. +/// Default namespace the library publisher publishes into. Same value the +/// creator apps publish with, so rows from either path sit side by side. pub const DEFAULT_NAMESPACE: &str = "gen"; /// Everything the host needs. Every knob is a named field: nothing here is @@ -85,37 +73,21 @@ pub struct HostConfig { pub beacon: bool, /// ai-content library to publish continuously. `None` = no publisher. pub library: Option, - /// 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. + /// Namespace for published rows. 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, - /// 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, + /// 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, /// Log to stderr. pub log: bool, } impl HostConfig { - /// Deployment defaults: ephemeral planes on every interface, beacon on, - /// both background loops on, LAN fleet. + /// Deployment defaults: ephemeral planes on every interface, beacon on. pub fn new(root: PathBuf) -> Self { let work_root = root .parent() @@ -127,81 +99,49 @@ 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, - chat_fleet_bases: Vec::new(), - chat_fleet: String::new(), - chat_max_sessions: 0, - chat_max_sessions_per_owner: 0, + blob_refs: BlobRefPolicy::default(), log: true, } } } /// A running host. Dropping it (or calling [`Host::shutdown`]) stops the -/// loops first and the server last, so nothing is still publishing into a -/// catalog that is closing. +/// publisher first and the server last, so nothing is still publishing into +/// a catalog that is closing. pub struct Host { - // Declaration order IS drop order: both loops are joined while the - // server they talk to is still answering. + // Declaration order IS drop order: the loop is joined while the server + // it talks 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: Stop, + stop: Arc, 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.raise(); + self.stop.store(true, Ordering::Release); 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 background loops. + /// Bring the server up, then the publisher. /// - /// 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. + /// 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. pub fn start(config: &HostConfig) -> Result { let mut cfg = ServerConfig::new(config.root.clone()); cfg.control_addr = config.control_addr; @@ -211,14 +151,7 @@ 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.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.blob_refs = config.blob_refs.clone(); cfg.log = config.log; let server = AssetServer::start(cfg).map_err(|error| format!("asset server: {error}"))?; @@ -242,37 +175,23 @@ 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 {} · coordinator {}", + "asset host up: control {} data {} · publisher {}", 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, }) } @@ -295,24 +214,14 @@ 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() } - /// 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. + /// Stop the publisher, 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(); } @@ -379,64 +288,7 @@ fn start_publisher( log(log_enabled, "library publisher: stopped"); }) .map_err(|error| format!("cannot spawn the publisher thread: {error}"))?; - 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}")) - } - } + Ok(BackgroundLoop { stop, join: Some(join) }) } fn log(enabled: bool, message: &str) { @@ -462,15 +314,13 @@ mod tests { } /// An isolated host: loopback-only ephemeral planes, NO beacon (a test - /// must never advertise itself to the operator's LAN), no coordinator - /// (it would claim the real fleet's jobs). + /// must never advertise itself to the operator's LAN). 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 @@ -551,7 +401,6 @@ 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 753cd8496..bb0c560e8 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, publishes the ai-content library, and coordinates fleet jobs — i.e. -//! everything the Asset UI's embedded mode does, minus the window. +//! LAN, and publishes the ai-content library — i.e. everything the Asset +//! UI's embedded mode does, minus the window. //! //! See `README.md` beside this file for the deployment runbook. @@ -17,10 +17,11 @@ use std::time::Duration; const USAGE: &str = "\ makepad-asset-server [options] -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. +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. Options: --root Server root. Default: $AI_CONTENT_ASSET_ROOT, else @@ -32,21 +33,9 @@ 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 "; @@ -105,15 +94,8 @@ 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 { @@ -136,37 +118,8 @@ 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}"); @@ -192,9 +145,6 @@ 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"); @@ -204,10 +154,6 @@ 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 fc8e25666..a5d4b4649 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-asset-ai = { path = "../../libs/asset/ai", default-features = false } +makepad-ai-hub = { path = "../../libs/ai/hub", default-features = false } makepad-micro-serde = { path = "../../libs/micro_serde" } makepad-base64 = { path = "../../libs/base64" } # Mesh viewer: Renderer stage for a single GLB. @@ -23,6 +23,7 @@ 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). @@ -33,13 +34,18 @@ 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-asset-chat-ui = { path = "../../libs/asset/chat_ui" } +makepad-chat-ui = { path = "../../libs/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 963516e9e..052f97668 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_asset_ai::testpattern::encode_png_rgba( + let png = makepad_ai_hub::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_asset_ai::testpattern::encode_png_rgba(&rgba, 1024, 1024).unwrap(); + let png = makepad_ai_hub::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_asset_ai::testpattern::encode_png_rgba(&rgba, 512, 128).unwrap(); + let png = makepad_ai_hub::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 a32506e79..37a007e5f 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, RequestId, RetireDto, SessionConfig, SessionConnector, - SessionHandles, SessionMsg, SessionStatus, + GcStatusDto, JobProfileDto, PageCursor, PipelineId, RequestId, RetireDto, SessionConfig, + SessionConnector, SessionHandles, SessionMsg, SessionStatus, }; use makepad_asset_data::{AssetId, AssetRevisionId}; pub use makepad_asset_data::AssetKind; @@ -180,6 +180,8 @@ 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", } } @@ -362,10 +364,6 @@ 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. @@ -395,10 +393,11 @@ pub struct AssetStore { pub selected: Option, pub detail: Remote, detail_req: Option, - /// Advertised generation capabilities (`/v1/jobs/profiles`) — the REAL - /// server-side generation surface for the Runs panel. + /// 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). pub profiles: Remote>, - profiles_req: Option, + profiles_rx: Option>>, /// Committed catalog events, newest first, capped. pub events: VecDeque, /// The event feed delivered its initial cursor and is following commits. @@ -410,6 +409,12 @@ 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 @@ -649,6 +654,22 @@ 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 { @@ -787,7 +808,6 @@ 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 @@ -802,7 +822,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 profiles fetch is a plain + /// otherwise look like a death forever. A GC status 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) { @@ -810,10 +830,7 @@ impl AssetStore { return; } let Some(handles) = &mut self.handles else { return }; - if let Ok(id) = handles - .catalog - .submit(ClientRequest::FetchJobProfiles { domain: None }) - { + if let Ok(id) = handles.catalog.submit(ClientRequest::GcStatus) { self.probe_req = Some(id); } } @@ -839,7 +856,7 @@ impl AssetStore { self.search_continuation = false; self.next_cursor = None; self.detail_req = None; - self.profiles_req = None; + self.profiles_rx = None; self.probe_req = None; self.gc_req = None; self.gc_cancel_req = None; @@ -1006,6 +1023,11 @@ 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) } @@ -1123,18 +1145,23 @@ 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 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()), - } + 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); + }); } fn on_catalog_event(&mut self, event: ClientEvent) -> bool { @@ -1224,8 +1251,6 @@ impl AssetStore { 0 } else if Some(id) == self.detail_req { 1 - } else if Some(id) == self.profiles_req { - 2 } else { return false; }; @@ -1272,10 +1297,6 @@ 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) => { @@ -1284,14 +1305,10 @@ impl AssetStore { self.next_cursor = None; self.search = Remote::Failed(format!("unexpected output {other:?}")); } - (1, other) => { + (_, 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 } @@ -1306,14 +1323,10 @@ 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 } @@ -1376,6 +1389,11 @@ 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); @@ -1451,7 +1469,7 @@ fn now_ms() -> u64 { .unwrap_or(0) } -fn checkout_root() -> PathBuf { +pub(crate) fn checkout_root() -> PathBuf { if let Ok(root) = std::env::var("MAKEPAD_ROOT") { return PathBuf::from(root); } @@ -1655,76 +1673,6 @@ 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. @@ -2090,6 +2038,9 @@ 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, }; @@ -2205,6 +2156,9 @@ mod tests { game_id: None, game_revision: None, alias: None, + model_preview: None, + pipeline: None, + pipeline_state: None, content_kind: None, ts_ms: 1, }]); @@ -2228,6 +2182,9 @@ 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 39f5ca7db..717e0cb85 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_asset_ai::testpattern::encode_png_rgba( + return makepad_ai_hub::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_asset_ai::testpattern::encode_png_rgba(&rgba, WAVEFORM_THUMB_W, WAVEFORM_THUMB_H) + makepad_ai_hub::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_asset_ai::wav::encode_wav_pcm16_mono(&samples, 24_000); + let wav = makepad_ai_hub::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 8e8ce57a4..dfc6365b3 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_asset_chat_ui`], the same one the game +//! shared component in [`makepad_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_asset_ai::fleet::BoxSnapshot; +use makepad_ai_hub::fleet::BoxSnapshot; use makepad_asset_chat::tools::{ContentToolCall, GenerateThen}; use makepad_asset_chat::wire::ToolOutcome; -use makepad_asset_chat_ui::feed::{default_call_title, default_outcome_summary, ellipsis}; -use makepad_asset_chat_ui::{ChatFeed, ClientTools, FeedConfig}; +use makepad_chat_ui::feed::{default_call_title, default_outcome_summary, ellipsis}; +use makepad_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_asset_chat_ui::{ChatData, ChatRole}; +pub use makepad_chat_ui::{ChatData, ChatRole}; // Test-only: the module tests below read the shared transcript directly. #[cfg(test)] -use makepad_asset_chat_ui::CHAT; +use makepad_chat_ui::CHAT; // --------------------------------------------------------------------------- // mutable generation defaults @@ -452,6 +452,10 @@ 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, } // --------------------------------------------------------------------------- @@ -644,6 +648,17 @@ 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 { @@ -685,6 +700,8 @@ impl AppTools { video_steps: VIDEO_LENGTHS[0].1, seconds: MUSIC_DEFAULT_SECONDS, voice: None, + lyrics: None, + seed: None, }) } @@ -742,6 +759,8 @@ 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, @@ -755,6 +774,8 @@ impl AppTools { video_steps: 0, seconds: 0, voice: None, + lyrics: None, + seed: None, }), ContentToolCall::SpeechGenerate { prompt, model, voice } => self.queue_job(ChatJob { prompt, @@ -768,20 +789,26 @@ impl AppTools { video_steps: 0, seconds: 0, voice, + lyrics: None, + seed: None, }), - 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::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::MeshGenerate { prompt, model, width, height, steps } => { match self.image_job( ChatJobKind::Mesh, @@ -907,12 +934,12 @@ impl ClientTools for AppTools { #[cfg(test)] mod tests { use super::*; - use makepad_asset_ai::protocol::{HealthJson, ModelInfoJson}; + use makepad_ai_hub::protocol::{HealthJson, ModelInfoJson}; fn snap(url: &str, domain: &str, id: &str, state: &str) -> BoxSnapshot { BoxSnapshot { base_url: url.into(), - health: Some(HealthJson { + health: Some(HealthJson { realtime: None, 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 02a50cb0c..a53188bd0 100644 --- a/apps/asset-ui/src/enhance_meta.rs +++ b/apps/asset-ui/src/enhance_meta.rs @@ -1,34 +1,15 @@ -//! Post-process pass: walk the local library and fill missing metadata. +//! Post-process pass: rewrite path-like library labels into catalog titles +//! (Doom lump → Imp, Quake MDL → Shambler). No model, no invented lore. //! -//! 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. +//! 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. 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 { @@ -127,28 +108,6 @@ 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::*; @@ -206,10 +165,3 @@ 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 4ddcd0980..b4c4111ea 100644 --- a/apps/asset-ui/src/fast_presets.rs +++ b/apps/asset-ui/src/fast_presets.rs @@ -202,6 +202,8 @@ 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 9b93b665c..7ac893a17 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_asset_ai::fleet::BoxSnapshot`]s — the scheduler +//! [`makepad_ai_hub::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_asset_ai::discovery::DiscoveredNode; -use makepad_asset_ai::fleet::BoxSnapshot; -use makepad_asset_ai::protocol::{HealthJson, JobStatusJson, JobsJson, LorasJson, ModelsJson}; +use makepad_ai_hub::discovery::DiscoveredNode; +use makepad_ai_hub::fleet::BoxSnapshot; +use makepad_ai_hub::protocol::{HealthJson, JobStatusJson, JobsJson, LorasJson, ModelsJson}; use makepad_micro_serde::DeJson; use makepad_widgets::*; use std::collections::HashMap; @@ -58,9 +58,47 @@ 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 { @@ -69,6 +107,9 @@ 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(), } } @@ -79,6 +120,25 @@ 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 { @@ -123,6 +183,9 @@ 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; } } @@ -206,12 +269,14 @@ 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) = match item { + let (request_id, response, transport_error) = match item { NetworkResponse::HttpResponse { request_id, response, - } => (*request_id, Some(response)), - NetworkResponse::HttpError { request_id, .. } => (*request_id, None), + } => (*request_id, Some(response), None), + NetworkResponse::HttpError { request_id, error } => { + (*request_id, None, Some(error.message.clone())) + } _ => return false, }; let Some(in_flight) = self.in_flight.remove(&request_id) else { @@ -222,15 +287,22 @@ 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. @@ -240,49 +312,114 @@ impl FleetPoll { let url = format!("{base_url}/models"); self.get(cx, base_url, url, Pending::Models); } - } else { - self.snapshots[index].models.clear(); - self.busy[index] = false; + 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 + } } - 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) => { - log!( - "fleet: {} /models JSON rejected ({} bytes): {:?}", - in_flight.base_url, - body.len(), - error + complain( + quiet, + format!( + "fleet: {} /models JSON rejected ({} bytes): {:?}", + in_flight.base_url, + body.len(), + error + ), ); None } }, None => { - log!("fleet: {} /models returned no text body", in_flight.base_url); + complain( + quiet, + format!( + "fleet: {} /models returned no text body", + in_flight.base_url + ), + ); None } } } Some(response) => { - log!( - "fleet: {} /models returned HTTP {}", - in_flight.base_url, - response.status_code + complain( + quiet, + format!( + "fleet: {} /models returned HTTP {}", + in_flight.base_url, response.status_code + ), ); None } None => { - log!("fleet: {} /models request failed", in_flight.base_url); + complain( + quiet, + format!( + "fleet: {} /models request failed ({}) — keeping its last model list", + in_flight.base_url, + transport_error.as_deref().unwrap_or("no response") + ), + ); None } }; if let Some(models) = models { + *quiet = false; self.snapshots[index].models = models.models; } // Live job list last (running + queued, other clients too). @@ -461,10 +598,70 @@ mod tests { DiscoveredNode { base_url: base_url.to_string(), node_id, - fleet: makepad_asset_ai::discovery::DEFAULT_FLEET.to_string(), + fleet: makepad_ai_hub::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 976646ac9..eccbb5645 100644 --- a/apps/asset-ui/src/import.rs +++ b/apps/asset-ui/src/import.rs @@ -7,6 +7,7 @@ //! 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, @@ -54,6 +55,10 @@ pub enum ImportJob { Duke3d { path: String, }, + EaClassic { + source: ClassicSource, + path: String, + }, Quake2 { path: String, }, @@ -82,6 +87,7 @@ 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(), @@ -100,6 +106,10 @@ 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) @@ -4130,6 +4140,8 @@ 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 fdf15db34..8c37a107c 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 Freedoom, LibreQuake, and official shareware. +//! Additive Import cards for libre, freeware, and official shareware packs. //! //! Downloads go through platform `cx.http_request`. Bytes are unpacked by //! [`makepad_asset_importer::classic_fetch`], then converted via @@ -107,6 +107,87 @@ 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", @@ -157,6 +238,10 @@ 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, @@ -1813,8 +1898,10 @@ 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; } @@ -1839,11 +1926,30 @@ 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", "image"), + AssetKind::Texture => ( + path, + "image/png", + if ea_source && asset.key.starts_with("icons/") { + "texture" + } else { + "image" + }, + ), AssetKind::Audio => { let music = asset.key.starts_with("music/") || asset.tags.iter().any(|t| t.eq_ignore_ascii_case("music")); - (path, "audio/wav", if music { "music" } else { "sfx" }) + 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" + }, + ) } AssetKind::Billboard => { let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); @@ -1908,12 +2014,17 @@ 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(title.clone()) + && !seen_titles.insert(dedupe_key) { continue; } @@ -2191,6 +2302,10 @@ 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, @@ -2204,6 +2319,10 @@ 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), @@ -2219,6 +2338,10 @@ 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, @@ -2232,6 +2355,10 @@ 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, @@ -2244,6 +2371,10 @@ 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() @@ -2293,6 +2424,10 @@ 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, @@ -2306,10 +2441,14 @@ impl ClassicImportPage { let c = self.librequake.poll(); let d = self.quake.poll(); let e = self.duke3d.poll(); - let f = self.quake2.poll(); - let g = self.quake3.poll(); - let h = self.darkmod.poll(); - a || b || c || d || e || f || g || h + 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 } pub fn take_all_landings(&mut self) -> Vec { @@ -2318,6 +2457,10 @@ 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()); @@ -2330,6 +2473,10 @@ 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()); @@ -2399,6 +2546,10 @@ mod tests { DOOM_MODULE, QUAKE_MODULE, DUKE3D_MODULE, + EA_MODULES[0], + EA_MODULES[1], + EA_MODULES[2], + EA_MODULES[3], QUAKE2_MODULE, QUAKE3_MODULE, ] { @@ -2416,6 +2567,62 @@ 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 7f5a160cd..4178ff7d6 100644 --- a/apps/asset-ui/src/library.rs +++ b/apps/asset-ui/src/library.rs @@ -1219,7 +1219,15 @@ 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.starts_with("the dark mod") { + 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") { "darkmod" } else if p.starts_with("freedoom") { "freedoom" @@ -2409,7 +2417,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_asset_ai::wav::encode_wav_pcm16_mono(&samples, 24_000); + let wav_bytes = makepad_ai_hub::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 b1c3ba573..ea73ff321 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_asset_ai::fleet` +//! are routed by the model-affinity scheduler in `makepad_ai_hub::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 OSS pack modules — Kenney first, local-folder only), +//! IMPORT (hardcoded licensed pack modules — Kenney first), //! RUNS + WORKERS (local pipeline + LAN fleet, cancellable), //! and ADMIN + AUDIT. The left generator column //! stays on all of them. Server @@ -59,6 +59,7 @@ 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; @@ -70,8 +71,10 @@ 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; @@ -88,7 +91,7 @@ use crate::fast_presets::{SavedFastPreset, MAX_FAST_PRESETS}; use makepad_asset_widgets::{VideoAction, VideoView}; use crate::pipeline::{ ENHANCE_FACTORS, - consumer_only_domain, format_clock, format_music_duration, seed_replaces_prefix, stage_display_name, CandidateSetState, GenParams, + consumer_only_domain, 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, @@ -247,7 +250,7 @@ use makepad_micro_serde::SerJson; use makepad_widgets::*; use makepad_xr::obj::ViewSplat; use std::collections::{HashMap, HashSet, VecDeque}; -use makepad_asset_ai::fleet::BoxSnapshot; +use makepad_ai_hub::fleet::BoxSnapshot; use std::path::{Path, PathBuf}; app_main!(App); @@ -563,6 +566,153 @@ 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 @@ -1219,33 +1369,46 @@ script_mod! { } } } + // Every spawned unit of work, in the one grammar. + CardR := RunCardBody{} StageR := Card{ - flow: Right spacing: 8 + flow: Down spacing: 4 padding: Inset{left: 10 right: 6 top: 6 bottom: 6} - 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} + 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} + } } + // 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 } } - // 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{ + // 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{ width: Fill - max_lines: 1 - text_overflow: TextOverflow.Ellipsis - draw_text +: { - color: #x8a939d - text_style: theme.font_regular{font_size: 8} - } + visible: false } - stage_bar := ProgressBar{ width: 140 height: 4 } - stage_cancel := DangerButton{ text: "Stop" visible: false } } QueuedR := Card{ flow: Right spacing: 4 @@ -2016,57 +2179,25 @@ script_mod! { } - 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: "" } + // 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: "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{ + create_runs_note := HintLabel{ width: Fill - height: 170 - stages_label := DimLabel{ text: "No pipeline yet — pick a chain above and Generate." } + text: "Nothing running — pick a chain above and Generate." } } // Say it in words and the same run appears in the @@ -2299,6 +2430,25 @@ 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. @@ -2904,6 +3054,7 @@ 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 @@ -3074,6 +3225,32 @@ 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{ @@ -3202,6 +3379,45 @@ 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 @@ -3405,6 +3621,49 @@ 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 + } + } + } + } } } } @@ -3532,7 +3791,6 @@ 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; @@ -3653,6 +3911,10 @@ 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, } @@ -3668,7 +3930,7 @@ const VOICES: &[&str] = &[ ]; /// Right-pane surface behind the nav tabs. Create keeps the viewer + -/// History strip; Import is the OSS pack catalog; the others are Asset +/// History strip; Import is the licensed pack catalog; the others are Asset /// Store views. #[derive(Clone, Copy, PartialEq, Eq, Default)] enum Surface { @@ -3695,7 +3957,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="duke3d": queue that classic Import card once the UI is up. + /// ASSET_UI_IMPORT: queue named Import cards once the UI is up. import: Option, capture: Option, /// AI_CONTENT_CAPTURE_AT_S: capture at a fixed time after startup @@ -3733,11 +3995,22 @@ 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] @@ -3879,6 +4152,9 @@ 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). @@ -3946,6 +4222,10 @@ 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, @@ -4053,6 +4333,19 @@ 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. @@ -4070,12 +4363,7 @@ impl App { // empty library changes nothing. self.refresh_gallery(cx, true); - // 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.discovered = Some(makepad_ai_hub::discovery::start_listener()); self.fleet = Some(FleetPoll::new()); self.maybe_connect_chat(cx); self.fleet_timer = cx.start_interval(3.0); @@ -4092,6 +4380,16 @@ 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()]); @@ -4479,7 +4777,7 @@ impl App { }; } } - if let Some(license) = makepad_asset_ai::registry::license_for_model(model_id) { + if let Some(license) = makepad_ai_hub::registry::license_for_model(model_id) { let identity = license.identity(); return LicensePrompt { model_id: model_id.to_string(), @@ -4558,6 +4856,15 @@ 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.", @@ -4698,7 +5005,17 @@ 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 stage = job.stage.clone().unwrap_or_else(|| job.state.clone()); + 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 more = pending.saturating_sub(1); let tail = if more > 0 { format!(" +{more} queued") } else { String::new() }; return (format!("{what} · {stage} {pct}%{tail}"), BUSY); @@ -4784,7 +5101,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| { @@ -4811,8 +5128,17 @@ 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(), @@ -5297,6 +5623,7 @@ 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, } } @@ -5544,7 +5871,7 @@ impl App { return; }; let first_domain = PRESETS[preset_index].domains[0]; - if makepad_asset_ai::fleet::pick_for_domain(&self.routing_snapshots(), first_domain).is_none() { + if makepad_ai_hub::fleet::pick_for_domain(&self.routing_snapshots(), first_domain).is_none() { return; // wait for discovery } self.auto.fired = true; @@ -5597,7 +5924,12 @@ 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 = @@ -5811,6 +6143,7 @@ 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, }) @@ -5843,7 +6176,7 @@ impl App { self.open_license_modal(cx, prompt); return; } - let request = makepad_asset_ai::protocol::GenerateRequestJson { + let request = makepad_ai_hub::protocol::GenerateRequestJson { model: model.clone(), pull_only: Some(true), queue_policy: Some("queue".to_string()), @@ -6001,8 +6334,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_asset_ai::fleet::model_admission(snapshot, model), - None => makepad_asset_ai::fleet::domain_admission(snapshot, &domain), + Some(model) => makepad_ai_hub::fleet::model_admission(snapshot, model), + None => makepad_ai_hub::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()); @@ -6158,6 +6491,20 @@ 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(), @@ -6165,7 +6512,7 @@ impl App { run.model_overrides.clone(), run.box_override.clone(), run.voice.clone(), - run.gen.clone(), + gen, ); let skip = run.input.as_ref().map_or(0, |seed| seed.skip); if let Some(seed) = &run.input { @@ -6224,6 +6571,10 @@ 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); @@ -6265,156 +6616,188 @@ impl App { .or_else(|| self.runs.last()) } - /// NOW card + queue rows + spinner + retry visibility. + /// 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. 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 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(_))) - }); + let failed = self + .display_run() + .is_some_and(|run| { + run.pipeline + .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()); - // 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, - ) - } - 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), - }; - self.ui.label(cx, ids!(now_head)).set_text(cx, &head); - self.ui.label(cx, ids!(now_detail)).set_text(cx, &detail); + let cards = self.run_cards(); + // The chip is always readable, whatever surface is open. 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]); + .button(cx, ids!(runs_chip)) + .set_text(cx, &crate::runs_chip::chip_text(&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]); + // 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), } } + 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() + }; + 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); + + if self.runs_chip.panel_open { + self.refresh_runs_modal(cx, &cards); + } if self.surface == Surface::Runs { self.refresh_runs_panel(cx); } 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); @@ -6422,23 +6805,6 @@ 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) { @@ -6675,105 +7041,6 @@ 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 { @@ -7057,7 +7324,6 @@ impl App { } } } - self.refresh_stages(cx); self.refresh_run_ui(cx); if done_or_failed { if self.auto.capture.is_some() @@ -8004,7 +8270,7 @@ impl App { return; }; let rgba = webcam::bgra_to_rgba8(&frame.bgra); - let Ok(png) = makepad_asset_ai::testpattern::encode_png_rgba(&rgba, frame.width, frame.height) + let Ok(png) = makepad_ai_hub::testpattern::encode_png_rgba(&rgba, frame.width, frame.height) else { self.set_webcam_status(cx, "snapshot PNG encode failed"); return; @@ -8107,7 +8373,7 @@ impl App { (px >> 24) as u8, ]); } - makepad_asset_ai::testpattern::encode_png_rgba(&rgba, image.width, image.height) + makepad_ai_hub::testpattern::encode_png_rgba(&rgba, image.width, image.height) .ok() }) { Some(png) => png, @@ -8451,7 +8717,7 @@ impl App { .count(); if differing * 200 > width * height { if let Ok(png) = - makepad_asset_ai::testpattern::encode_png_rgba(&bgra, width, height) + makepad_ai_hub::testpattern::encode_png_rgba(&bgra, width, height) { match self .library @@ -11162,8 +11428,9 @@ impl App { &fleet.snapshots, &fleet.latency_ms, &self.store, + &self.open_stages, ), - None => runs_rows(&run_views, &queued, &[], &[], &self.store), + None => runs_rows(&run_views, &queued, &[], &[], &self.store, &self.open_stages), }; if let Some(mut list) = self .ui @@ -11184,6 +11451,12 @@ 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(); @@ -11295,6 +11568,43 @@ 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, @@ -11470,6 +11780,15 @@ 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 @@ -11523,6 +11842,9 @@ impl App { progress, failed, cancel: Some(RowAction::StopImport), + detail: String::new(), + expand: None, + copy: None, }); } for item in &self.import_queue.pending { @@ -11543,6 +11865,9 @@ impl App { progress: queue.progress_fraction(), failed: false, cancel: None, + detail: String::new(), + expand: None, + copy: None, }); } } @@ -11679,6 +12004,10 @@ 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 @@ -11735,6 +12064,9 @@ 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); } @@ -11815,6 +12147,7 @@ impl App { } } + fn import_server_session(&self) -> Option { let from_session = (|| { let endpoints = self.store.endpoints?; @@ -12770,10 +13103,18 @@ 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(); - log!("import: queue Kenney {pack}"); - self.enqueue_import( + self.open_kenney_donate_modal( cx, ImportJob::Kenney { pack, @@ -12783,8 +13124,30 @@ impl MatchEvent for App { ); } if self.ui.button(cx, ids!(kenney_import_all_btn)).clicked(actions) { - log!("import: queue Kenney all"); - self.enqueue_import(cx, ImportJob::KenneyAll); + 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); } if self.ui.button(cx, ids!(queue_clear_btn)).clicked(actions) { self.import_queue.clear_pending(); @@ -12837,6 +13200,36 @@ 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( @@ -12929,7 +13322,9 @@ impl MatchEvent for App { FileDialogAction::FolderCancelled => { self.music_import_page.picking = false; } - FileDialogAction::None => {} + // This screen drives folder selection only; the platform's + // file and save panels answer elsewhere. + _ => {} } if self.surface == Surface::Import { self.refresh_import_ui(cx); @@ -12985,7 +13380,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. @@ -13192,6 +13587,21 @@ 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)) @@ -13267,17 +13677,108 @@ 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 = runs_widget - .borrow::() - .and_then(|panel| panel.row_at(row_id)); + 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(); break; } } @@ -13292,6 +13793,25 @@ 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)); @@ -13351,9 +13871,6 @@ 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 }; @@ -13910,26 +14427,6 @@ 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; } } @@ -13949,7 +14446,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_asset_chat_ui::script_mod(vm); + makepad_chat_ui::script_mod(vm); self::script_mod(vm) } @@ -14060,6 +14557,11 @@ 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(); @@ -14133,7 +14635,7 @@ impl AppMain for App { } if classic_poll { log!( - "import classic: freedoom={} librequake={} duke3d={} quake2={} quake3={}", + "import classic: freedoom={} librequake={} duke3d={} cnc={} ra={} ts={} d2k={} quake2={} quake3={}", self.classic_import_page .freedoom .status_line(self.store.connected()), @@ -14143,6 +14645,10 @@ 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()), @@ -14251,7 +14757,7 @@ impl AppMain for App { } // Live elapsed timers while any run is active. if self.any_run_running() { - self.refresh_stages(cx); + self.refresh_run_ui(cx); if self.surface == Surface::Runs { self.refresh_runs_panel(cx); } @@ -14326,12 +14832,29 @@ 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, }; @@ -15157,7 +15680,6 @@ 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 698b54bca..459d50c9e 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_asset_ai::testpattern::encode_png_rgba(&rgba, self.width, self.height).ok() + makepad_ai_hub::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_asset_ai::testpattern::encode_png_rgba(&rgba, self.width, self.height).ok() + makepad_ai_hub::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 5cdab92f1..b7445a7da 100644 --- a/apps/asset-ui/src/mesh_view.rs +++ b/apps/asset-ui/src/mesh_view.rs @@ -1020,6 +1020,8 @@ 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(), @@ -1493,6 +1495,8 @@ 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(), @@ -1511,6 +1515,8 @@ 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, @@ -1914,6 +1920,8 @@ 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 28b201f8c..570da0e0d 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_asset_ai::testpattern::encode_png_rgba(&rgba, W, H).expect("studio equirect encodes") + makepad_ai_hub::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 e19489a50..a3c07e0fc 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_asset_ai::fleet`): loaded > ready > downloading > absent, +//! (`makepad_ai_hub::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_asset_ai::fleet::{self, BoxSnapshot}; -use makepad_asset_ai::protocol::{ +use makepad_ai_hub::fleet::{self, BoxSnapshot}; +use makepad_ai_hub::protocol::{ ArtifactRefJson, GenerateRequestJson, GenerateResponseJson, JobStatusJson, NamedInputJson, LoraRefJson}; use makepad_micro_serde::{DeJson, SerJson}; use makepad_widgets::*; @@ -26,44 +26,12 @@ use std::collections::{HashMap, HashSet}; // Presets // --------------------------------------------------------------------------- -/// 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; +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, +}; /// Human-facing clock label used by the duration picker and run details. pub fn format_music_duration(seconds: u32) -> String { @@ -148,6 +116,12 @@ 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 { @@ -170,6 +144,7 @@ impl Default for GenParams { enhance_upscale: 2, enhance_interpolate: 2, enhance_flow: true, + video_loop: false, } } } @@ -185,6 +160,9 @@ 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 { @@ -198,6 +176,22 @@ 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, } } @@ -212,6 +206,7 @@ impl Preset { domains, pins, fan_out_stage: Some(stage), + video_loop: false, } } } @@ -235,6 +230,18 @@ 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"; @@ -310,8 +317,8 @@ fn pick_ready_model_target( model.available && matches!( model.state.as_str(), - makepad_asset_ai::protocol::MODEL_STATE_READY - | makepad_asset_ai::protocol::MODEL_STATE_LOADED + makepad_ai_hub::protocol::MODEL_STATE_READY + | makepad_ai_hub::protocol::MODEL_STATE_LOADED ) }) }) @@ -492,6 +499,14 @@ 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. @@ -589,6 +604,8 @@ 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 @@ -645,6 +662,67 @@ 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", @@ -797,6 +875,16 @@ 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). @@ -949,6 +1037,8 @@ 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(), @@ -1092,6 +1182,17 @@ 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 @@ -1197,27 +1298,35 @@ impl Pipeline { /// identity anchor supplied on its request: `yoshi` can be elaborated, /// never replaced. fn prompt_for_stage(&self, stage: usize) -> Result { - for earlier in self.stages[..stage].iter().rev() { + for (index, earlier) in self.stages[..stage].iter().enumerate().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 Err( - "LLM prompt expansion produced no text/plain artifact; refusing terse-prompt fallback" - .to_string(), - ); + return unusable("produced no text/plain artifact"); + }; + let Ok(text) = std::str::from_utf8(bytes) else { + return unusable("artifact is not UTF-8"); }; - 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 Err( - "LLM prompt expansion was empty; refusing terse-prompt fallback".to_string(), - ); + return unusable("was empty"); } if self.is_character_pipeline() { let words = text.split_whitespace().count(); @@ -1495,7 +1604,7 @@ impl Pipeline { // Music3 can stop earlier when it emits its end-of-audio token. "music" => { let (description, lyrics) = - makepad_asset_ai::music3_backend::split_music_prompt(&prompt); + makepad_ai_hub::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 @@ -1596,6 +1705,20 @@ 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) @@ -2156,6 +2279,11 @@ 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()); @@ -2325,10 +2453,54 @@ 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)); - events - } else { - self.fail_stage(stage, error, events) + return 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. @@ -3222,7 +3394,7 @@ impl Pipeline { ); }; if let Err(error) = - makepad_asset_ai::client::verify_artifact_bytes(&bytes, &artifact) + makepad_ai_hub::client::verify_artifact_bytes(&bytes, &artifact) { return self.candidate_failed( cx, @@ -3342,7 +3514,8 @@ impl Pipeline { if Self::is_vram_admission_error(&message) { return self.wait_after_vram_rejection(stage, &message, events); } - return self.fail_stage(stage, message, events); + return self + .fail_stage_or_skip_expander(cx, stage, message, snapshots, avoid, events); } } } @@ -3352,9 +3525,12 @@ impl Pipeline { .and_then(|r| r.get_string_body()) .is_some_and(|body| body.contains("no such job")) { - return self.fail_stage( + return self.fail_stage_or_skip_expander( + cx, stage, "box lost the job (service restarted or the job expired)".to_string(), + snapshots, + avoid, events, ); } @@ -3415,9 +3591,12 @@ impl Pipeline { .filter(|r| !failed && r.status_code == 200) .and_then(|r| r.body.clone()); let Some(bytes) = bytes else { - return self.fail_stage( + return self.fail_stage_or_skip_expander( + cx, stage, format!("artifact {} fetch failed", artifact.id), + snapshots, + avoid, events, ); }; @@ -3642,6 +3821,22 @@ 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 } @@ -3752,10 +3947,10 @@ mod tests { } fn image_snapshot(url: &str, node_key: &str) -> BoxSnapshot { - use makepad_asset_ai::protocol::{HealthJson, ModelInfoJson, MODEL_STATE_LOADED}; + use makepad_ai_hub::protocol::{HealthJson, ModelInfoJson, MODEL_STATE_LOADED}; BoxSnapshot { base_url: url.to_string(), - health: Some(HealthJson { + health: Some(HealthJson { realtime: None, service: "test".to_string(), version: "1".to_string(), gpu: Some("GPU".to_string()), @@ -3797,10 +3992,10 @@ mod tests { } fn text_snapshot(url: &str, models: &[(&str, &str)]) -> BoxSnapshot { - use makepad_asset_ai::protocol::{HealthJson, ModelInfoJson, MODEL_STATE_LOADED}; + use makepad_ai_hub::protocol::{HealthJson, ModelInfoJson, MODEL_STATE_LOADED}; BoxSnapshot { base_url: url.to_string(), - health: Some(HealthJson { + health: Some(HealthJson { realtime: None, service: "test".to_string(), version: "1".to_string(), gpu: Some("24 GB GPU".to_string()), @@ -3854,7 +4049,7 @@ mod tests { #[test] fn qwen38_expand_preference_is_ready_gated_and_falls_back() { - use makepad_asset_ai::protocol::{ + use makepad_ai_hub::protocol::{ MODEL_STATE_ABSENT, MODEL_STATE_DOWNLOADING, MODEL_STATE_LOADED, MODEL_STATE_READY, }; @@ -4174,6 +4369,78 @@ 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( @@ -4633,13 +4900,13 @@ Arrangement: Pulsing bass, gated drums and widening analog pads." .contains("dropped identity anchor")); } - fn registry() -> makepad_asset_ai::registry::Registry { + fn registry() -> makepad_ai_hub::registry::Registry { let text = std::fs::read_to_string(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../libs/asset/ai/registry.json" + "/../../libs/ai/hub/registry.json" )) .expect("registry.json readable"); - makepad_asset_ai::registry::Registry::parse(&text).expect("registry parses") + makepad_ai_hub::registry::Registry::parse(&text).expect("registry parses") } #[test] @@ -4717,6 +4984,9 @@ 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 new file mode 100644 index 000000000..eda4dc55d --- /dev/null +++ b/apps/asset-ui/src/runs_chip.rs @@ -0,0 +1,531 @@ +//! 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 534c50bc2..b2c998f90 100644 --- a/apps/asset-ui/src/store_views.rs +++ b/apps/asset-ui/src/store_views.rs @@ -15,7 +15,8 @@ use crate::library::LibraryMeta; use crate::pipeline::{ format_clock, format_music_duration, stage_display_name, CandidateSet, Pipeline, StageState, }; -use makepad_asset_ai::fleet::BoxSnapshot; +use crate::runs_chip::{CardKey, RunCard}; +use makepad_ai_hub::fleet::BoxSnapshot; use makepad_asset_widgets::{AssetThumb, ThumbMedia}; use makepad_widgets::*; use std::collections::{HashMap, HashSet}; @@ -1541,6 +1542,18 @@ 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. @@ -1563,6 +1576,14 @@ 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 }, @@ -1585,6 +1606,12 @@ 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 @@ -1657,10 +1684,21 @@ impl Widget for StoreListPanel { progress, failed, cancel, + detail, + copy, + .. } => { let item = list.item(cx, item_id, id!(StageR)); - item.label(cx, ids!(stage_title)).set_text(cx, &title); + item.button(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 { @@ -1729,6 +1767,11 @@ 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); } @@ -1760,6 +1803,76 @@ 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() @@ -1769,7 +1882,32 @@ pub fn truncate(text: &str, max: usize) -> String { } } -fn stage_row(run_id: u64, pipeline: &Pipeline, index: usize) -> StoreRow { +/// 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 { let stage = &pipeline.stages[index]; let where_ = if stage.box_url.is_empty() { String::new() @@ -1828,12 +1966,22 @@ fn stage_row(run_id: u64, pipeline: &Pipeline, index: usize) -> StoreRow { .collect::>() .join(" · "); StoreRow::Stage { - title: format!("{} · {}", index + 1, stage_display_name(&stage.domain)), + // 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) + ), 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)), } } @@ -1846,6 +1994,11 @@ 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() { @@ -1865,7 +2018,8 @@ pub fn runs_rows( truncate(&run.pipeline.prompt, 72) ))); for index in 0..run.pipeline.stages.len() { - rows.push(stage_row(run.id, run.pipeline, index)); + let open = open_stages.contains(&(run.id, index)); + rows.push(stage_row(run.id, run.pipeline, index, open)); } } if !queued.is_empty() { @@ -1881,7 +2035,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-asset-ai fleet service.".into(), + "No GPU boxes on the LAN — start a makepad-ai-hub fleet service.".into(), )); } // One row per PHYSICAL host; several service instances on one box list @@ -2042,6 +2196,15 @@ 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()), @@ -2211,6 +2374,61 @@ 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(); @@ -2234,6 +2452,7 @@ 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 38f6f29dc..1677b5638 100644 --- a/apps/asset-ui/src/thumbnail_renderer.rs +++ b/apps/asset-ui/src/thumbnail_renderer.rs @@ -819,6 +819,8 @@ 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(), @@ -945,7 +947,7 @@ impl ThumbnailRenderer { } let encoded = rgba.and_then(|rgba| { - makepad_asset_ai::testpattern::encode_png_rgba(&rgba, THUMBNAIL_SIZE, THUMBNAIL_SIZE) + makepad_ai_hub::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 new file mode 100644 index 000000000..584b7bddb --- /dev/null +++ b/apps/finance/Cargo.toml @@ -0,0 +1,20 @@ +# 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 new file mode 100644 index 000000000..7c01c7c1e --- /dev/null +++ b/apps/finance/src/chart.rs @@ -0,0 +1,601 @@ +//! 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 new file mode 100644 index 000000000..245e159d6 --- /dev/null +++ b/apps/finance/src/csv.rs @@ -0,0 +1,339 @@ +//! 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 new file mode 100644 index 000000000..b10e48f08 --- /dev/null +++ b/apps/finance/src/date.rs @@ -0,0 +1,506 @@ +//! 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 new file mode 100644 index 000000000..15970f0ff --- /dev/null +++ b/apps/finance/src/db.rs @@ -0,0 +1,829 @@ +//! 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 new file mode 100644 index 000000000..9e56a00f6 --- /dev/null +++ b/apps/finance/src/import.rs @@ -0,0 +1,613 @@ +//! 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 new file mode 100644 index 000000000..b7d7f73f3 --- /dev/null +++ b/apps/finance/src/main.rs @@ -0,0 +1,67 @@ +//! 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 new file mode 100644 index 000000000..258c8c7a2 --- /dev/null +++ b/apps/finance/src/model.rs @@ -0,0 +1,994 @@ +//! 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 new file mode 100644 index 000000000..c8ed9870d --- /dev/null +++ b/apps/finance/src/money.rs @@ -0,0 +1,382 @@ +//! 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 new file mode 100644 index 000000000..58f828fbd --- /dev/null +++ b/apps/finance/src/report.rs @@ -0,0 +1,635 @@ +//! 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 new file mode 100644 index 000000000..289a5c2e3 --- /dev/null +++ b/apps/finance/src/seed.rs @@ -0,0 +1,894 @@ +//! 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 new file mode 100644 index 000000000..4e592e19b --- /dev/null +++ b/apps/finance/src/theme.rs @@ -0,0 +1,112 @@ +//! 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 new file mode 100644 index 000000000..6a1b43ebf --- /dev/null +++ b/apps/finance/src/view.rs @@ -0,0 +1,1658 @@ +//! 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 507845aa2..373bb035e 100644 --- a/apps/mixer/Cargo.toml +++ b/apps/mixer/Cargo.toml @@ -21,3 +21,4 @@ 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 f529bfc02..9ba519963 100644 --- a/apps/mixer/layouts/compact.splash +++ b/apps/mixer/layouts/compact.splash @@ -1,17 +1,18 @@ // COMPACT surface layout (splash) — the proof that the layout seam is real. // -// 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. +// 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. let ValueLabel = Label{ width: Fill align: Align{x: 0.5} text: "—" - draw_text.color: #xe6ecf4 - draw_text.text_style.font_size: 10.0 + draw_text.color: mp.fg_bright + draw_text.text_style: theme.font_code{font_size: 10.0} } let FaderSlider = Slider{ @@ -24,33 +25,35 @@ let FaderSlider = Slider{ text: "" text_input: TextInput{width: 0, height: 0} draw_bg +: { - body: uniform(#x0b0f15) - slot: uniform(#x171e29) - cap: uniform(#x9aa3ad) - cap_dark: uniform(#x4a5158) + 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) pixel: fn() { let sdf = Sdf2d.viewport(self.pos * self.rect_size) let h = self.rect_size.y let w = self.rect_size.x - sdf.box(0.0, 0.0, w, h, 3.0) + sdf.rect(0.0, 0.0, w, h) sdf.fill(self.body) let rel = self.pos.y let grid = abs(fract(rel * 8.0 + 0.5) - 0.5) * (h / 8.0) - let tick = 1.0 - smoothstep(0.4, 0.9, grid) + let mark = 1.0 - smoothstep(0.4, 0.9, grid) let px = self.pos.x * w if px < 7.0 { - 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(0.0, rel * h - 0.5, 6.0, 1.0) + sdf.fill(self.tick * mark) } - sdf.box(w * 0.5 - 2.0, 3.0, 4.0, h - 6.0, 2.0) + sdf.rect(w * 0.5 - 1.0, 3.0, 2.0, h - 6.0) sdf.fill(self.slot) let cap_h = 26.0 let cy = (1.0 - self.slide_pos) * (h - cap_h) - 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) + 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) return sdf.result } } @@ -60,6 +63,12 @@ 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() { @@ -68,24 +77,21 @@ 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 - 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)) + 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)) if frac < norm { - 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) } + col = self.trough.mix(ladder, seg) } if abs(frac - peakn) < 1.5 / h && peakn > 0.01 { - col = vec3(0.9, 1.0, 0.9) + col = self.tick } let x = self.pos.x * w if x < 1.0 || x > w - 1.0 { - col = vec3(0.015, 0.02, 0.03) + col = self.gutter } - return vec4(col, 1.0) + return col } } } @@ -97,20 +103,26 @@ 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.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)) + 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) return sdf.result } } Label{ text: "MUTE" - draw_text.color: #xf3d9d6 - draw_text.text_style.font_size: 8.5 + draw_text.color: mp.fg_bright + draw_text.text_style: theme.font_code{font_size: 8.5} } } @@ -121,22 +133,22 @@ let NamePlate = SolidView{ new_batch: true align: Align{x: 0.5, y: 0.5} draw_bg +: { - plate_rgb: instance(vec3(0.5, 0.5, 0.5)) + 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_filled: instance(0.0) pixel: fn() { let sdf = Sdf2d.viewport(self.pos * self.rect_size) - 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) + 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) return sdf.result } } name_lbl := Label{ text: "—" - draw_text.color: #xe8edf4 - draw_text.text_style.font_size: 9.0 + draw_text.color: mp.fg + draw_text.text_style: theme.font_code{font_size: 9.0} } } @@ -149,12 +161,14 @@ 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(#x0e1218) + sdf.fill(self.fill) sdf.rect(self.rect_size.x - 1.0, 0.0, 1.0, self.rect_size.y) - sdf.fill(#x1c232e) + sdf.fill(self.edge) return sdf.result } } @@ -164,8 +178,8 @@ let Strip = View{ width: Fill align: Align{x: 0.5} text: "" - draw_text.color: #x8d99aa - draw_text.text_style.font_size: 8.0 + draw_text.color: mp.fg_dim + draw_text.text_style: theme.font_code{font_size: 8.0} } fader_db := ValueLabel{} fader_block := View{ @@ -186,14 +200,14 @@ View{ flow: Down show_bg: true new_batch: true - draw_bg.color: #x080a0e + draw_bg.color: mp.bg_dark surface_note := Label{ width: Fill align: Align{x: 0.5} text: "" - draw_text.color: #x77828f - draw_text.text_style.font_size: 8.0 + draw_text.color: mp.fg_dim + draw_text.text_style: theme.font_code{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 95cf582a3..293cd37e9 100644 --- a/apps/mixer/layouts/lr_mix.splash +++ b/apps/mixer/layouts/lr_mix.splash @@ -5,6 +5,25 @@ // 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 @@ -42,8 +61,8 @@ let ValueLabel = Label{ flow: Right align: Align{x: 0.5} text: "—" - draw_text.color: #xcfd8e3 - draw_text.text_style.font_size: 8.0 + draw_text.color: mp.fg + draw_text.text_style: theme.font_code{font_size: 8.0} } let RowSlider = Slider{ @@ -55,20 +74,22 @@ let RowSlider = Slider{ text: "" text_input: TextInput{width: 0, height: 0} draw_bg +: { - track: uniform(#x2b3546) - fill: uniform(#x2f6fe0) - knob: uniform(#xf2f5fa) + track: uniform(mp.muted) + fill: uniform(mp.accent) + knob: uniform(mp.fg_bright) 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.box(x0, cy - 1.5, x1 - x0, 3.0, 1.5) + sdf.rect(x0, cy - 1.0, x1 - x0, 2.0) sdf.fill(self.track) let px = x0 + (x1 - x0) * self.slide_pos - sdf.box(x0, cy - 1.5, max(px - x0, 1.0), 3.0, 1.5) + sdf.rect(x0, cy - 1.0, max(px - x0, 1.0), 2.0) sdf.fill(self.fill) - sdf.circle(px, cy, 3.6) + // 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.fill(self.knob) return sdf.result } @@ -77,22 +98,23 @@ let RowSlider = Slider{ let EqSlider = RowSlider{ draw_bg +: { - fill: uniform(#xb98c2a) + fill: uniform(mp.yellow) + detent: uniform(mp.fg_dim) 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.box(x0, cy - 1.5, x1 - x0, 3.0, 1.5) + sdf.rect(x0, cy - 1.0, x1 - x0, 2.0) 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.box(min(px, mid), cy - 1.5, max(abs(px - mid), 1.0), 3.0, 1.5) + sdf.rect(min(px, mid), cy - 1.0, max(abs(px - mid), 1.0), 2.0) sdf.fill(self.fill) - sdf.box(mid - 0.5, cy - 3.5, 1.0, 7.0, 0.5) - sdf.fill(#x55606e) - sdf.circle(px, cy, 3.6) + 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.fill(self.knob) return sdf.result } @@ -101,7 +123,7 @@ let EqSlider = RowSlider{ let PanSlider = RowSlider{ draw_bg +: { - fill: uniform(#x3a6ea8) + fill: uniform(mp.cyan) } } @@ -110,6 +132,12 @@ 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)) @@ -145,16 +173,18 @@ 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)) - 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. + // 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 dx = abs(x - self.sel_f) * w - 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) + 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 } } } @@ -163,6 +193,10 @@ 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) @@ -182,22 +216,20 @@ 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)) - 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 + var col = self.c_panel.mix(self.c_ink, curve) + // gate region: a flat wash 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 + vec3(0.25, 0.20, 0.06) + col = col.mix(self.c_gate, 0.22) } } // 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 = vec3(1.0, 0.45, 0.15) + col = self.c_gr } - return vec4(col, 1.0) + return col } } } @@ -206,6 +238,12 @@ 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() { @@ -214,27 +252,25 @@ 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 - var col = vec3(0.03, 0.09, 0.045) + // 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)) // segment shading so the bar reads "LED-ish" - let seg = 0.85 + 0.15 * smoothstep(0.35, 0.5, abs(fract(frac * 30.0) - 0.5)) + 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)) if frac < norm { - 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) } + col = self.trough.mix(ladder, seg) } // peak-hold tick if abs(frac - peakn) < 1.5 / h && peakn > 0.01 { - col = vec3(0.9, 1.0, 0.9) + col = self.tick } let x = self.pos.x * w if x < 1.0 || x > w - 1.0 { - col = vec3(0.015, 0.02, 0.03) + col = self.gutter } - return vec4(col, 1.0) + return col } } } @@ -249,37 +285,40 @@ let FaderSlider = Slider{ text: "" text_input: TextInput{width: 0, height: 0} draw_bg +: { - body: uniform(#x0b0f15) - slot: uniform(#x171e29) - cap: uniform(#x9aa3ad) - cap_dark: uniform(#x4a5158) + 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) pixel: fn() { let sdf = Sdf2d.viewport(self.pos * self.rect_size) let h = self.rect_size.y let w = self.rect_size.x - sdf.box(0.0, 0.0, w, h, 2.0) + sdf.rect(0.0, 0.0, w, h) 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 tick = 1.0 - smoothstep(0.4, 0.9, grid) + let mark = 1.0 - smoothstep(0.4, 0.9, grid) let px = self.pos.x * w if px < 6.0 { - 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) + sdf.rect(0.0, rel * h - 0.5, 5.0, 1.0) + sdf.fill(self.tick * mark) } // centre slot - sdf.box(w * 0.5 - 1.5, 3.0, 3.0, h - 6.0, 1.5) + sdf.rect(w * 0.5 - 1.0, 3.0, 2.0, h - 6.0) sdf.fill(self.slot) - // handle: wide ribbed block with a white centre line + // handle: a flat square cap, edged in the accent, with the + // pointer line across it let cap_h = 22.0 let cy = (1.0 - self.slide_pos) * (h - cap_h) - 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) + 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) return sdf.result } } @@ -290,8 +329,8 @@ let ScaleMark = Label{ height: Fill flow: Right align: Align{x: 1.0} - draw_text.color: #x6b7686 - draw_text.text_style.font_size: 7.0 + draw_text.color: mp.fg_dim + draw_text.text_style: theme.font_code{font_size: 7.0} text: "" } @@ -301,7 +340,7 @@ let ScaleCol = View{ flow: Down ScaleMark{text: "10"} ScaleMark{text: "5"} - ScaleMark{text: "0" draw_text.color: #xa8b4c4} + ScaleMark{text: "0" draw_text.color: mp.fg} ScaleMark{text: "-5"} ScaleMark{text: "-10"} ScaleMark{text: "-20"} @@ -316,20 +355,28 @@ 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.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)) + 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) return sdf.result } } Label{ text: "MUTE" - draw_text.color: #xf3d9d6 - draw_text.text_style.font_size: 7.0 + draw_text.color: mp.fg_bright + draw_text.text_style: theme.font_code{font_size: 7.0} } } @@ -340,23 +387,24 @@ let NamePlate = SolidView{ new_batch: true align: Align{x: 0.5, y: 0.5} draw_bg +: { - plate_rgb: instance(vec3(0.5, 0.5, 0.5)) + 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_filled: instance(0.0) pixel: fn() { let sdf = Sdf2d.viewport(self.pos * self.rect_size) - 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) + 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) return sdf.result } } name_lbl := Label{ flow: Right text: "—" - draw_text.color: #xe8edf4 - draw_text.text_style.font_size: 7.5 + draw_text.color: mp.fg + draw_text.text_style: theme.font_code{font_size: 7.5} } } @@ -369,12 +417,14 @@ 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(#x10141b) + sdf.fill(self.fill) sdf.rect(self.rect_size.x - 1.0, 0.0, 1.0, self.rect_size.y) - sdf.fill(#x1c232e) + sdf.fill(self.edge) return sdf.result } } @@ -405,8 +455,8 @@ let Strip = View{ flow: Right align: Align{x: 0.5} text: "—" - draw_text.color: #xe6ecf4 - draw_text.text_style.font_size: 9.0 + draw_text.color: mp.fg_bright + draw_text.text_style: theme.font_code{font_size: 9.0} } fader_block := View{ width: Fill @@ -424,8 +474,8 @@ let Strip = View{ flow: Right align: Align{x: 0.5} text: "" - draw_text.color: #x8d99aa - draw_text.text_style.font_size: 7.5 + draw_text.color: mp.fg_dim + draw_text.text_style: theme.font_code{font_size: 7.5} } mute := MutePlate{} name_plate := NamePlate{} @@ -437,7 +487,7 @@ View{ flow: Down show_bg: true new_batch: true - draw_bg.color: #x080a0e + draw_bg.color: mp.bg_dark surface_note := Label{ visible: false @@ -445,8 +495,8 @@ View{ height: Fit align: Align{x: 0.5} text: "" - draw_text.color: #x77828f - draw_text.text_style.font_size: 8.0 + draw_text.color: mp.fg_dim + draw_text.text_style: theme.font_code{font_size: 8.0} } strip_row := View{ width: Fill diff --git a/apps/mixer/src/main.rs b/apps/mixer/src/main.rs index b4c64848f..2e9ec536d 100644 --- a/apps/mixer/src/main.rs +++ b/apps/mixer/src/main.rs @@ -38,7 +38,9 @@ use std::net::SocketAddr; use std::sync::Arc; mod surface; +mod theme; use surface::SurfaceBinder; +use theme::Palette; app_main!(App); @@ -77,11 +79,18 @@ script_mod! { main_window := Window{ window.title: "Mixer" window.inner_size: vec2(1268, 716) - pass.clear_color: #x07090d + // 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 body +: { width: Fill height: Fill flow: Down + show_bg: true + draw_bg.color: mod.mpm.bg_dark searching := View{ width: Fill @@ -92,17 +101,20 @@ script_mod! { Label{ text: "Searching for your mixer" - draw_text.color: #xe8eef6 - draw_text.text_style: theme.font_bold{font_size: 17.0} + draw_text.color: mod.mpm.fg_bright + draw_text.text_style: theme.font_code{font_size: 15.0} } search_note := Label{ text: "listening for a console on the local network" - draw_text.color: #x8d99aa - draw_text.text_style.font_size: 9.0 + draw_text.color: mod.mpm.fg_dim + draw_text.text_style: theme.font_code{font_size: 9.0} } LoadingSpinner{ width: 34 height: 34 + draw_bg +: { + color: uniform(mod.mpm.accent) + } } } @@ -154,7 +166,15 @@ impl App { fn load_layout(&mut self, cx: &mut Cx) { let (_label, file) = LAYOUTS[self.layout_idx]; - let body = load_layout_body(file); + // 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 splash = self.splash_ref(cx); splash.set_text(cx, &body); self.binder.rebind(cx, &splash); @@ -356,6 +376,8 @@ 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 be25bd60e..4ee852162 100644 --- a/apps/mixer/src/surface.rs +++ b/apps/mixer/src/surface.rs @@ -256,7 +256,14 @@ 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]); - let text_rgb = if filled > 0.5 { [0.04, 0.05, 0.07] } else { rgb }; + // 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 + }; set_label_color(cx, &slot.name_lbl, text_rgb); } diff --git a/apps/mixer/src/theme.rs b/apps/mixer/src/theme.rs new file mode 100644 index 000000000..51b2fdece --- /dev/null +++ b/apps/mixer/src/theme.rs @@ -0,0 +1,238 @@ +//! 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 new file mode 100644 index 000000000..3b19c63c2 --- /dev/null +++ b/apps/mpbrowser/Cargo.toml @@ -0,0 +1,25 @@ +# 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 new file mode 100644 index 000000000..e185208d6 --- /dev/null +++ b/apps/mpbrowser/resources/icons/back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mpbrowser/resources/icons/close.svg b/apps/mpbrowser/resources/icons/close.svg new file mode 100644 index 000000000..cdcf15d9d --- /dev/null +++ b/apps/mpbrowser/resources/icons/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mpbrowser/resources/icons/forward.svg b/apps/mpbrowser/resources/icons/forward.svg new file mode 100644 index 000000000..75660f353 --- /dev/null +++ b/apps/mpbrowser/resources/icons/forward.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mpbrowser/resources/icons/globe.svg b/apps/mpbrowser/resources/icons/globe.svg new file mode 100644 index 000000000..d78530043 --- /dev/null +++ b/apps/mpbrowser/resources/icons/globe.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/mpbrowser/resources/icons/menu.svg b/apps/mpbrowser/resources/icons/menu.svg new file mode 100644 index 000000000..9af124140 --- /dev/null +++ b/apps/mpbrowser/resources/icons/menu.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/mpbrowser/resources/icons/plus.svg b/apps/mpbrowser/resources/icons/plus.svg new file mode 100644 index 000000000..db29bdbdd --- /dev/null +++ b/apps/mpbrowser/resources/icons/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mpbrowser/resources/icons/reload.svg b/apps/mpbrowser/resources/icons/reload.svg new file mode 100644 index 000000000..000dcad61 --- /dev/null +++ b/apps/mpbrowser/resources/icons/reload.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mpbrowser/resources/icons/search.svg b/apps/mpbrowser/resources/icons/search.svg new file mode 100644 index 000000000..21bf86f54 --- /dev/null +++ b/apps/mpbrowser/resources/icons/search.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mpbrowser/resources/icons/star.svg b/apps/mpbrowser/resources/icons/star.svg new file mode 100644 index 000000000..b2bf6d58d --- /dev/null +++ b/apps/mpbrowser/resources/icons/star.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mpbrowser/src/chrome.rs b/apps/mpbrowser/src/chrome.rs new file mode 100644 index 000000000..b457defc0 --- /dev/null +++ b/apps/mpbrowser/src/chrome.rs @@ -0,0 +1,512 @@ +//! 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 new file mode 100644 index 000000000..1b587687f --- /dev/null +++ b/apps/mpbrowser/src/main.rs @@ -0,0 +1,626 @@ +//! 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 new file mode 100644 index 000000000..3af0b1a94 --- /dev/null +++ b/apps/mpbrowser/src/tabs.rs @@ -0,0 +1,321 @@ +//! 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 new file mode 100644 index 000000000..1483559bd --- /dev/null +++ b/apps/mpbrowser/src/theme.rs @@ -0,0 +1,221 @@ +//! 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 new file mode 100644 index 000000000..eff0b6166 --- /dev/null +++ b/apps/mpbrowser/src/webview.rs @@ -0,0 +1,966 @@ +//! 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 new file mode 100644 index 000000000..83b2c464a --- /dev/null +++ b/apps/mpfiles/Cargo.toml @@ -0,0 +1,12 @@ +[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 new file mode 100644 index 000000000..fe331e3a2 --- /dev/null +++ b/apps/mpfiles/resources/icons/archive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/mpfiles/resources/icons/audio.svg b/apps/mpfiles/resources/icons/audio.svg new file mode 100644 index 000000000..7f3500eb4 --- /dev/null +++ b/apps/mpfiles/resources/icons/audio.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mpfiles/resources/icons/back.svg b/apps/mpfiles/resources/icons/back.svg new file mode 100644 index 000000000..2ba808fa0 --- /dev/null +++ b/apps/mpfiles/resources/icons/back.svg @@ -0,0 +1 @@ + diff --git a/apps/mpfiles/resources/icons/bookmark.svg b/apps/mpfiles/resources/icons/bookmark.svg new file mode 100644 index 000000000..5894fde2e --- /dev/null +++ b/apps/mpfiles/resources/icons/bookmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/chat.svg b/apps/mpfiles/resources/icons/chat.svg new file mode 100644 index 000000000..fcc7e61ac --- /dev/null +++ b/apps/mpfiles/resources/icons/chat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/check.svg b/apps/mpfiles/resources/icons/check.svg new file mode 100644 index 000000000..d6c1aff8c --- /dev/null +++ b/apps/mpfiles/resources/icons/check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/clock.svg b/apps/mpfiles/resources/icons/clock.svg new file mode 100644 index 000000000..33fb727cf --- /dev/null +++ b/apps/mpfiles/resources/icons/clock.svg @@ -0,0 +1 @@ + diff --git a/apps/mpfiles/resources/icons/close.svg b/apps/mpfiles/resources/icons/close.svg new file mode 100644 index 000000000..9b51a468b --- /dev/null +++ b/apps/mpfiles/resources/icons/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/code.svg b/apps/mpfiles/resources/icons/code.svg new file mode 100644 index 000000000..9189eb290 --- /dev/null +++ b/apps/mpfiles/resources/icons/code.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mpfiles/resources/icons/compact.svg b/apps/mpfiles/resources/icons/compact.svg new file mode 100644 index 000000000..6bf963e0c --- /dev/null +++ b/apps/mpfiles/resources/icons/compact.svg @@ -0,0 +1 @@ + diff --git a/apps/mpfiles/resources/icons/delete-forever.svg b/apps/mpfiles/resources/icons/delete-forever.svg new file mode 100644 index 000000000..8cf648bfe --- /dev/null +++ b/apps/mpfiles/resources/icons/delete-forever.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/eye.svg b/apps/mpfiles/resources/icons/eye.svg new file mode 100644 index 000000000..eb961549d --- /dev/null +++ b/apps/mpfiles/resources/icons/eye.svg @@ -0,0 +1 @@ + diff --git a/apps/mpfiles/resources/icons/file.svg b/apps/mpfiles/resources/icons/file.svg new file mode 100644 index 000000000..8c27588cd --- /dev/null +++ b/apps/mpfiles/resources/icons/file.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mpfiles/resources/icons/filter.svg b/apps/mpfiles/resources/icons/filter.svg new file mode 100644 index 000000000..10d7e97df --- /dev/null +++ b/apps/mpfiles/resources/icons/filter.svg @@ -0,0 +1 @@ + diff --git a/apps/mpfiles/resources/icons/folder.svg b/apps/mpfiles/resources/icons/folder.svg new file mode 100644 index 000000000..9303502a3 --- /dev/null +++ b/apps/mpfiles/resources/icons/folder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mpfiles/resources/icons/forward.svg b/apps/mpfiles/resources/icons/forward.svg new file mode 100644 index 000000000..c008067e9 --- /dev/null +++ b/apps/mpfiles/resources/icons/forward.svg @@ -0,0 +1 @@ + diff --git a/apps/mpfiles/resources/icons/grid.svg b/apps/mpfiles/resources/icons/grid.svg new file mode 100644 index 000000000..8947e6a5f --- /dev/null +++ b/apps/mpfiles/resources/icons/grid.svg @@ -0,0 +1 @@ + diff --git a/apps/mpfiles/resources/icons/home.svg b/apps/mpfiles/resources/icons/home.svg new file mode 100644 index 000000000..479e7b416 --- /dev/null +++ b/apps/mpfiles/resources/icons/home.svg @@ -0,0 +1 @@ + diff --git a/apps/mpfiles/resources/icons/image.svg b/apps/mpfiles/resources/icons/image.svg new file mode 100644 index 000000000..69007888a --- /dev/null +++ b/apps/mpfiles/resources/icons/image.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/mpfiles/resources/icons/info.svg b/apps/mpfiles/resources/icons/info.svg new file mode 100644 index 000000000..94fe1c9c2 --- /dev/null +++ b/apps/mpfiles/resources/icons/info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/list.svg b/apps/mpfiles/resources/icons/list.svg new file mode 100644 index 000000000..b5aa58753 --- /dev/null +++ b/apps/mpfiles/resources/icons/list.svg @@ -0,0 +1 @@ + diff --git a/apps/mpfiles/resources/icons/menu-dots.svg b/apps/mpfiles/resources/icons/menu-dots.svg new file mode 100644 index 000000000..4bb7d6708 --- /dev/null +++ b/apps/mpfiles/resources/icons/menu-dots.svg @@ -0,0 +1 @@ + diff --git a/apps/mpfiles/resources/icons/network.svg b/apps/mpfiles/resources/icons/network.svg new file mode 100644 index 000000000..404f9fa9f --- /dev/null +++ b/apps/mpfiles/resources/icons/network.svg @@ -0,0 +1 @@ + diff --git a/apps/mpfiles/resources/icons/newfolder.svg b/apps/mpfiles/resources/icons/newfolder.svg new file mode 100644 index 000000000..34cbc7ef8 --- /dev/null +++ b/apps/mpfiles/resources/icons/newfolder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/pdf.svg b/apps/mpfiles/resources/icons/pdf.svg new file mode 100644 index 000000000..2bc3e7c64 --- /dev/null +++ b/apps/mpfiles/resources/icons/pdf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mpfiles/resources/icons/reload.svg b/apps/mpfiles/resources/icons/reload.svg new file mode 100644 index 000000000..f499bb741 --- /dev/null +++ b/apps/mpfiles/resources/icons/reload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/search.svg b/apps/mpfiles/resources/icons/search.svg new file mode 100644 index 000000000..8a5655580 --- /dev/null +++ b/apps/mpfiles/resources/icons/search.svg @@ -0,0 +1 @@ + diff --git a/apps/mpfiles/resources/icons/star.svg b/apps/mpfiles/resources/icons/star.svg new file mode 100644 index 000000000..9f2a2d9e1 --- /dev/null +++ b/apps/mpfiles/resources/icons/star.svg @@ -0,0 +1 @@ + diff --git a/apps/mpfiles/resources/icons/terminal.svg b/apps/mpfiles/resources/icons/terminal.svg new file mode 100644 index 000000000..d9be6b5c9 --- /dev/null +++ b/apps/mpfiles/resources/icons/terminal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/text.svg b/apps/mpfiles/resources/icons/text.svg new file mode 100644 index 000000000..3799d7e3d --- /dev/null +++ b/apps/mpfiles/resources/icons/text.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mpfiles/resources/icons/trash.svg b/apps/mpfiles/resources/icons/trash.svg new file mode 100644 index 000000000..5a464e198 --- /dev/null +++ b/apps/mpfiles/resources/icons/trash.svg @@ -0,0 +1 @@ + diff --git a/apps/mpfiles/resources/icons/treemap.svg b/apps/mpfiles/resources/icons/treemap.svg new file mode 100644 index 000000000..4090a09e2 --- /dev/null +++ b/apps/mpfiles/resources/icons/treemap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/treemap25.svg b/apps/mpfiles/resources/icons/treemap25.svg new file mode 100644 index 000000000..e1f27c8c1 --- /dev/null +++ b/apps/mpfiles/resources/icons/treemap25.svg @@ -0,0 +1 @@ + diff --git a/apps/mpfiles/resources/icons/treemap3d.svg b/apps/mpfiles/resources/icons/treemap3d.svg new file mode 100644 index 000000000..62b274c03 --- /dev/null +++ b/apps/mpfiles/resources/icons/treemap3d.svg @@ -0,0 +1 @@ + diff --git a/apps/mpfiles/resources/icons/twist-down.svg b/apps/mpfiles/resources/icons/twist-down.svg new file mode 100644 index 000000000..20a67ecad --- /dev/null +++ b/apps/mpfiles/resources/icons/twist-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/twist-right.svg b/apps/mpfiles/resources/icons/twist-right.svg new file mode 100644 index 000000000..31607accf --- /dev/null +++ b/apps/mpfiles/resources/icons/twist-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mpfiles/resources/icons/video.svg b/apps/mpfiles/resources/icons/video.svg new file mode 100644 index 000000000..6f7f6d383 --- /dev/null +++ b/apps/mpfiles/resources/icons/video.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/mpfiles/src/bookmarks.rs b/apps/mpfiles/src/bookmarks.rs new file mode 100644 index 000000000..46a43c2e5 --- /dev/null +++ b/apps/mpfiles/src/bookmarks.rs @@ -0,0 +1,185 @@ +//! 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 new file mode 100644 index 000000000..911e06423 --- /dev/null +++ b/apps/mpfiles/src/chat_agent.rs @@ -0,0 +1,85 @@ +//! 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 new file mode 100644 index 000000000..36a412d06 --- /dev/null +++ b/apps/mpfiles/src/chat_panel.rs @@ -0,0 +1,336 @@ +//! 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 new file mode 100644 index 000000000..ac072de6d --- /dev/null +++ b/apps/mpfiles/src/chat_tools.rs @@ -0,0 +1,607 @@ +//! 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 new file mode 100644 index 000000000..45f551d9b --- /dev/null +++ b/apps/mpfiles/src/contents.rs @@ -0,0 +1,1671 @@ +//! 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 new file mode 100644 index 000000000..5aab0576b --- /dev/null +++ b/apps/mpfiles/src/demo.rs @@ -0,0 +1,1359 @@ +//! 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 new file mode 100644 index 000000000..f6b4cdfb4 --- /dev/null +++ b/apps/mpfiles/src/main.rs @@ -0,0 +1,5232 @@ +//! 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 new file mode 100644 index 000000000..158b3171e --- /dev/null +++ b/apps/mpfiles/src/menu.rs @@ -0,0 +1,313 @@ +//! 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 new file mode 100644 index 000000000..46d22d974 --- /dev/null +++ b/apps/mpfiles/src/model.rs @@ -0,0 +1,1036 @@ +//! 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 new file mode 100644 index 000000000..42cebe7a0 --- /dev/null +++ b/apps/mpfiles/src/ops.rs @@ -0,0 +1,1437 @@ +//! 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 new file mode 100644 index 000000000..4496ba01e --- /dev/null +++ b/apps/mpfiles/src/preview.rs @@ -0,0 +1,294 @@ +//! 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 new file mode 100644 index 000000000..6d6b7d009 --- /dev/null +++ b/apps/mpfiles/src/rename.rs @@ -0,0 +1,274 @@ +//! 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 new file mode 100644 index 000000000..502c62182 --- /dev/null +++ b/apps/mpfiles/src/sizecache.rs @@ -0,0 +1,375 @@ +//! 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 new file mode 100644 index 000000000..919ce1218 --- /dev/null +++ b/apps/mpfiles/src/theme.rs @@ -0,0 +1,317 @@ +//! 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 new file mode 100644 index 000000000..0d223ffe8 --- /dev/null +++ b/apps/mpfiles/src/thumbs.rs @@ -0,0 +1,432 @@ +//! 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 new file mode 100644 index 000000000..5c58412b6 --- /dev/null +++ b/apps/mpfiles/src/treemap.rs @@ -0,0 +1,2774 @@ +//! 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 new file mode 100644 index 000000000..3a207d2a3 --- /dev/null +++ b/apps/mpfiles/src/treemap_view.rs @@ -0,0 +1,3836 @@ +//! 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