diff --git a/AGENTS.md b/AGENTS.md index f16cbcf..41624a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ NotesCraft is a native, Obsidian-compatible notes app written in **Rust only** ( - One crate per concern in `crates/`, package `notescraft-`, `[lints] workspace = true`, registered in the root `[workspace.dependencies]` and in `xtask/src/layers.rs`. - **Layering is enforced** by `cargo xtask layers`: a crate may depend only on strictly lower layers; nothing below L5 may use egui, eframe, winit or rfd. Layers: L0 `vault` `markdown`; L1 `index` `canvas` `diagrams` `whiteboard`; L2 `backlinks` `graph` `templates`; L3 `plugins` `themes`; L4 `engine`; L5 `editor` `mcp` `i18n`; L6 `ui-egui`. - Shared crates come from craft-libs (`craft-atomic`, `craft-settings`, `craft-ui`, `craft-quit`, `craft-print`), pinned by git rev in the root `Cargo.toml`. Do not change their API; ask first. -- A new third-party dependency needs a written reason and owner approval. Already approved: `notify`, `pulldown-cmark`, `toml`, `wasmi`, `wat` (dev), `thiserror`, `serde`, `serde_json`, and for sync (2026-10-19): `sha2`, `axum`, `tokio`, `ureq` (rustls), `rusqlite`. +- A new third-party dependency needs a written reason and owner approval. Already approved: `notify`, `pulldown-cmark`, `toml`, `wasmi`, `wat` (dev), `thiserror`, `serde`, `serde_json`, and for sync (2026-10-19): `sha2`, `axum`, `tokio`, `ureq` (rustls), `rusqlite`, and for paste (2026-10-20): `arboard` (image-data), `image` (png feature only). ## Never crash (outranks features) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index d254a91..0f65082 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,6 +4,7 @@ |---|---|---|---|---| | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, `assets/fonts/OFL-Inter.txt` | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono (monospace) | The JetBrains Mono Project Authors | | SIL OFL 1.1, `assets/fonts/OFL-JetBrainsMono.txt` | +| `assets/fonts/JetBrainsMono-NoLigatures.ttf` | JetBrains Mono, modified copy used for code (the `calt` feature is emptied so `!=` and `->` stay as typed; renamed `JetBrains Mono NoLigatures`) | The JetBrains Mono Project Authors | | SIL OFL 1.1, `assets/fonts/OFL-JetBrainsMono.txt` | | `assets/fonts/STIXTwoMath-Regular.ttf` | STIX Two Math 2.13, subset to the math Unicode blocks (math type) | STIX Fonts Project | (tag 2.13) | SIL OFL 1.1, `assets/fonts/OFL-STIXTwoMath.txt` | | `assets/icons/*.svg` | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`) | ISC, `assets/icons/LICENSE-lucide.txt` | diff --git a/Cargo.lock b/Cargo.lock index 59b3bcb..99fbc37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -752,6 +752,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.8" @@ -1671,6 +1677,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -2139,10 +2155,25 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", + "image-webp", "moxcms", "num-traits", "png 0.18.1", "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] [[package]] @@ -2990,6 +3021,7 @@ dependencies = [ name = "notescraft-ui-egui" version = "0.1.0" dependencies = [ + "arboard", "axum", "craft-ui", "egui", diff --git a/Cargo.toml b/Cargo.toml index 2ae2dcc..0fda399 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,8 @@ muda = { version = "=0.21.2", default-features = false } egui = { version = "0.36", default-features = false } unicode-bidi = { version = "0.3.18", default-features = false, features = ["std", "hardcoded-data"] } egui_extras = { version = "0.36", default-features = false, features = ["svg"] } +# Clipboard images and file lists for paste (owner-approved 2026-10-20; already in the lockfile through egui-winit). image-data only: no default features. +arboard = { version = "3.6", default-features = false, features = ["image-data"] } eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" log = "0.4" diff --git a/assets/fonts/JetBrainsMono-NoLigatures.ttf b/assets/fonts/JetBrainsMono-NoLigatures.ttf new file mode 100644 index 0000000..4f945a7 Binary files /dev/null and b/assets/fonts/JetBrainsMono-NoLigatures.ttf differ diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 983e47a..108e4e6 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -17,6 +17,9 @@ workspace = true craft-ui = { workspace = true } egui = { workspace = true } egui_extras = { workspace = true, features = ["image"] } +arboard = { workspace = true } +# Encodes a pasted clipboard image to PNG and decodes the picture formats the lightbox opens (png, jpeg, gif, webp, bmp; owner asked for jpeg and the other formats on 2026-10-21). +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] } log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -55,4 +58,3 @@ notescraft-sync-server = { path = "../../apps/notescraft-sync-server" } axum = { workspace = true } tokio = { workspace = true } ureq = { workspace = true } -image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/ui-egui/src/design/fonts/mod.rs b/crates/ui-egui/src/design/fonts/mod.rs new file mode 100644 index 0000000..9ed1ed2 --- /dev/null +++ b/crates/ui-egui/src/design/fonts/mod.rs @@ -0,0 +1,217 @@ +//! Fonts: `Inter` for the UI, `JetBrains Mono` for code and `STIX Two Math` for math, +//! shipped in `assets/fonts` like the other Craft apps (SIL OFL 1.1, see `ATTRIBUTION.md`). +//! +//! Every other script (Indic, Thai, CJK, ...) is loaded lazily from the system fonts: [`ensure_scripts`] scans +//! text and appends the font of each script it finds, once. egui does no OpenType shaping, so conjuncts and +//! reordering marks (Bengali, Devanagari, Tamil, Thai, Khmer, ...) draw as unjoined letters. + +mod scripts; +mod system; +#[cfg(test)] +mod tests; + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use egui::{FontData, FontDefinitions, FontFamily}; + +const INTER_REGULAR: &[u8] = include_bytes!("../../../../../assets/fonts/Inter-Regular.ttf"); +const INTER_MEDIUM: &[u8] = include_bytes!("../../../../../assets/fonts/Inter-Medium.ttf"); +const INTER_SEMIBOLD: &[u8] = include_bytes!("../../../../../assets/fonts/Inter-SemiBold.ttf"); +const JETBRAINS_MONO: &[u8] = include_bytes!("../../../../../assets/fonts/JetBrainsMono-NoLigatures.ttf"); +const STIX_MATH: &[u8] = include_bytes!("../../../../../assets/fonts/STIXTwoMath-Regular.ttf"); + +/// Fonts the system ships with Hebrew and Arabic letters, read when present: the bundled fonts have none, and a +/// line of right-to-left text would otherwise show empty boxes. +const SYSTEM_FALLBACKS: [&str; 7] = [ + "/System/Library/Fonts/SFHebrew.ttf", + "/System/Library/Fonts/SFArabic.ttf", + "C:\\Windows\\Fonts\\segoeui.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf", + "/usr/share/fonts/noto/NotoSansHebrew-Regular.ttf", +]; +/// Largest system font read, in bytes. +const MAX_FALLBACK_BYTES: u64 = 24 * 1024 * 1024; + +/// The bytes of the system fallback fonts that exist here. +fn system_fallbacks() -> Vec> { + SYSTEM_FALLBACKS + .iter() + .filter(|path| std::fs::metadata(path).is_ok_and(|meta| meta.len() <= MAX_FALLBACK_BYTES)) + .filter_map(|path| std::fs::read(path).ok()) + .collect() +} + +/// Named family for medium-weight text. +pub const MEDIUM: &str = "inter-medium"; +/// Named family for semibold text (titles, tab labels). +pub const SEMIBOLD: &str = "inter-semibold"; +/// Named family for math glyphs (`SPEC` item 30). +pub const MATH: &str = "stix-math"; + +/// The math family: STIX Two Math with the UI fonts as glyph fallbacks. +#[must_use] +pub fn math() -> FontFamily { + FontFamily::Name(MATH.into()) +} + +/// The medium-weight family. +#[must_use] +pub fn medium() -> FontFamily { + FontFamily::Name(MEDIUM.into()) +} + +/// The semibold family. +#[must_use] +pub fn semibold() -> FontFamily { + FontFamily::Name(SEMIBOLD.into()) +} + +/// Installs `Inter` as the proportional font, `JetBrains Mono` as the monospace font and the two +/// named weight families. egui's bundled fonts stay behind them as glyph fallbacks. +pub fn install(ctx: &egui::Context) { + let mut fonts = FontDefinitions::default(); + for (name, bytes) in [("inter", INTER_REGULAR), (MEDIUM, INTER_MEDIUM), (SEMIBOLD, INTER_SEMIBOLD), ("jetbrains-mono", JETBRAINS_MONO), (MATH, STIX_MATH)] { + fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes))); + } + let proportional = fonts.families.entry(FontFamily::Proportional).or_default(); + proportional.insert(0, "inter".to_owned()); + for (index, bytes) in system_fallbacks().into_iter().enumerate() { + let name = format!("system-fallback-{index}"); + fonts.font_data.insert(name.clone(), Arc::new(FontData::from_owned(bytes))); + if let Some(chain) = fonts.families.get_mut(&FontFamily::Proportional) { + chain.push(name); + } + } + // STIX Two Math is the last resort of every text family, so symbols, arrows and math operators never show boxes. + if let Some(chain) = fonts.families.get_mut(&FontFamily::Proportional) { + chain.push(MATH.to_owned()); + } + let monospace = fonts.families.entry(FontFamily::Monospace).or_default(); + monospace.insert(0, "jetbrains-mono".to_owned()); + monospace.push(MATH.to_owned()); + for (family, face) in [(MEDIUM, MEDIUM), (SEMIBOLD, SEMIBOLD)] { + let mut chain = vec![face.to_owned(), "inter".to_owned()]; + if let Some(base) = fonts.families.get(&FontFamily::Proportional) { + chain.extend(base.iter().filter(|n| n.as_str() != "inter").cloned()); + } + fonts.families.insert(FontFamily::Name(family.into()), chain); + } // Math family first, then the UI fonts as glyph fallbacks for symbols STIX lacks. + let mut math_chain = vec![MATH.to_owned(), "inter".to_owned()]; + if let Some(base) = fonts.families.get(&FontFamily::Proportional) { + math_chain.extend(base.iter().filter(|n| n.as_str() != "inter").cloned()); + } + math_chain.push("jetbrains-mono".to_owned()); + fonts.families.insert(FontFamily::Name(MATH.into()), math_chain); + let state = State { defs: fonts.clone(), loaded: 0, missing: 0 }; + ctx.data_mut(|data| data.insert_temp(egui::Id::new(STATE_KEY), Arc::new(Mutex::new(state)))); + ctx.set_fonts(fonts); +} + +/// Where the installed definitions live in the context. +const STATE_KEY: &str = "notescraft-font-state"; +/// Most bytes of a text scanned for scripts. +const MAX_SCAN_BYTES: usize = 64 * 1024; +/// Most characters of a text scanned for scripts. +const MAX_SCAN_CHARS: usize = 20_000; +/// Scripts seen by [`note_text`] and [`note_names`] and not yet applied by [`apply_pending`]. +static PENDING: AtomicU64 = AtomicU64::new(0); + +/// What [`install`] set and the scripts added since. +struct State { + defs: FontDefinitions, + /// Bit `i` set: `SCRIPTS[i]` has its font in `defs`. + loaded: u64, + /// Bit `i` set: no installed font for `SCRIPTS[i]`; not tried again. + missing: u64, +} + +/// Whether `code` is in a range of `script`. +fn covers(script: &scripts::Script, code: u32) -> bool { + script.ranges.iter().any(|&(lo, hi)| (lo..=hi).contains(&code)) +} + +/// The set of scripts in `text` (bit `i` is `SCRIPTS[i]`); looks at the first 64 KiB and 20 000 characters only. +fn detect(text: &str) -> u64 { + let mut found = 0_u64; + let mut bytes = 0_usize; + let mut last = 0_usize; + for c in text.chars().take(MAX_SCAN_CHARS) { + bytes += c.len_utf8(); + if bytes > MAX_SCAN_BYTES { + break; + } + let code = u32::from(c); + // Runs of one script are the common case: test the previous hit first. + if code < scripts::FIRST_SCRIPT_CHAR || scripts::SCRIPTS.get(last).is_some_and(|script| covers(script, code)) { + continue; + } + if let Some(index) = scripts::SCRIPTS.iter().position(|script| covers(script, code)) { + found |= 1 << index; + last = index; + } + } + found +} + +/// Remembers the scripts of `text` for [`apply_pending`]; needs no `Context`. +pub fn note_text(text: &str) { + let found = detect(text); + if found != 0 { + PENDING.fetch_or(found, Ordering::Relaxed); + } +} + +/// Like [`note_text`] for a list of file names, each scanned in full. +pub fn note_names<'a>(names: impl IntoIterator) { + for name in names { + note_text(name); + } +} + +/// Loads the fonts [`note_text`] asked for; call once per frame, cheap when nothing is pending. `true` when fonts changed. +pub fn apply_pending(ctx: &egui::Context) -> bool { + let wanted = PENDING.swap(0, Ordering::Relaxed); + wanted != 0 && add_scripts(ctx, wanted, system::file_size, |path| std::fs::read(path).ok()) +} + +/// Loads the system font of every script in `text` that has none yet, with one `set_fonts` call. +/// `true` when fonts changed (they apply from the next frame, so a repaint is requested). +pub fn ensure_scripts(ctx: &egui::Context, text: &str) -> bool { + let wanted = detect(text); + wanted != 0 && add_scripts(ctx, wanted, system::file_size, |path| std::fs::read(path).ok()) +} + +/// [`ensure_scripts`] over injected file access. +fn add_scripts(ctx: &egui::Context, wanted: u64, size_of: impl Fn(&str) -> Option, read: impl Fn(&str) -> Option>) -> bool { + let Some(state) = ctx.data(|data| data.get_temp::>>(egui::Id::new(STATE_KEY))) else { return false }; + let Ok(mut state) = state.lock() else { return false }; + let todo = wanted & !(state.loaded | state.missing); + let mut changed = false; + for (bit, script) in scripts::SCRIPTS.iter().enumerate().filter(|(bit, _)| todo & (1 << bit) != 0) { + let font = system::pick(script, &size_of).and_then(|pick| read(&pick.path).map(|bytes| (pick, bytes))); + let Some((pick, bytes)) = font else { + state.missing |= 1 << bit; + log::warn!("no system font for the {} script; its text shows empty boxes", script.name); + continue; + }; + state.loaded |= 1 << bit; + let name = format!("script-{}", script.name); + let mut data = FontData::from_owned(bytes); + data.index = pick.index; + state.defs.font_data.insert(name.clone(), Arc::new(data)); + for chain in state.defs.families.values_mut() { + // Script fonts go before the math fallback, which stays last (it leads only the math family). + let at = chain.iter().rposition(|n| n == MATH).filter(|at| *at > 0).unwrap_or(chain.len()); + chain.insert(at, name.clone()); + } + changed = true; + } + if changed { + ctx.set_fonts(state.defs.clone()); + ctx.request_repaint(); + } + changed +} diff --git a/crates/ui-egui/src/design/fonts/scripts.rs b/crates/ui-egui/src/design/fonts/scripts.rs new file mode 100644 index 0000000..9005cf6 --- /dev/null +++ b/crates/ui-egui/src/design/fonts/scripts.rs @@ -0,0 +1,76 @@ +//! The script table: Unicode ranges that need a system font, and where each OS keeps one. + +/// One face to try: file path and the face index inside a `.ttc` (`0` for a plain font). +pub(super) type Face = (&'static str, u32); + +/// A writing system the bundled fonts cannot draw. +pub(super) struct Script { + /// Short name, used for the egui font name and the log line. + pub(super) name: &'static str, + /// Inclusive code point ranges that trigger the script. + pub(super) ranges: &'static [(u32, u32)], + /// macOS and Windows files, best first. + pub(super) files: &'static [Face], + /// Linux Noto file names, probed in every [`NOTO_DIRS`] directory. + pub(super) noto: &'static [Face], +} + +/// Directories the Linux distributions put Noto fonts in. +pub(super) const NOTO_DIRS: [&str; 6] = [ + "/usr/share/fonts/truetype/noto/", + "/usr/share/fonts/noto/", + "/usr/share/fonts/opentype/noto/", + "/usr/share/fonts/noto-cjk/", + "/usr/share/fonts/google-noto/", + "/usr/share/fonts/google-noto-cjk/", +]; + +/// Face indexes of `NotoSansCJK-Regular.ttc`. +const CJK_JP: u32 = 0; +const CJK_KR: u32 = 1; +const CJK_SC: u32 = 2; + +/// The first code point any table range starts at: smaller characters never need a lookup. +pub(super) const FIRST_SCRIPT_CHAR: u32 = 0x0530; + +macro_rules! face { + ($dir:expr, $name:literal) => { + (concat!($dir, $name), 0) + }; + ($dir:expr, $name:literal, $index:expr) => { + (concat!($dir, $name), $index) + }; +} + +/// Every lazily loaded script; the position is the bit in the loaded set (at most 64 entries). +#[rustfmt::skip] +pub(super) const SCRIPTS: &[Script] = &[ + Script { name: "armenian", ranges: &[(0x0530, 0x058F), (0xFB13, 0xFB17)], files: &[face!("/System/Library/Fonts/", "SFArmenian.ttf"), face!("/System/Library/Fonts/Supplemental/", "Mshtakan.ttc"), face!("C:\\Windows\\Fonts\\", "segoeui.ttf")], noto: &[("NotoSansArmenian-Regular.ttf", 0)] }, + Script { name: "georgian", ranges: &[(0x10A0, 0x10FF), (0x1C90, 0x1CBF), (0x2D00, 0x2D2F)], files: &[face!("/System/Library/Fonts/", "SFGeorgian.ttf"), face!("C:\\Windows\\Fonts\\", "segoeui.ttf")], noto: &[("NotoSansGeorgian-Regular.ttf", 0)] }, + Script { name: "devanagari", ranges: &[(0x0900, 0x097F), (0xA8E0, 0xA8FF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Devanagari Sangam MN.ttc"), face!("/System/Library/Fonts/Supplemental/", "DevanagariMT.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansDevanagari-Regular.ttf", 0)] }, + Script { name: "bengali", ranges: &[(0x0980, 0x09FF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Bangla Sangam MN.ttc"), face!("/System/Library/Fonts/", "KohinoorBangla.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansBengali-Regular.ttf", 0)] }, + Script { name: "gurmukhi", ranges: &[(0x0A00, 0x0A7F)], files: &[face!("/System/Library/Fonts/Supplemental/", "Gurmukhi Sangam MN.ttc"), face!("/System/Library/Fonts/Supplemental/", "Gurmukhi MN.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansGurmukhi-Regular.ttf", 0)] }, + Script { name: "gujarati", ranges: &[(0x0A80, 0x0AFF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Gujarati Sangam MN.ttc"), face!("/System/Library/Fonts/", "KohinoorGujarati.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansGujarati-Regular.ttf", 0)] }, + Script { name: "oriya", ranges: &[(0x0B00, 0x0B7F)], files: &[face!("/System/Library/Fonts/Supplemental/", "Oriya Sangam MN.ttc"), face!("/System/Library/Fonts/", "NotoSansOriya.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansOriya-Regular.ttf", 0)] }, + Script { name: "tamil", ranges: &[(0x0B80, 0x0BFF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Tamil Sangam MN.ttc"), face!("/System/Library/Fonts/Supplemental/", "Tamil MN.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansTamil-Regular.ttf", 0)] }, + Script { name: "telugu", ranges: &[(0x0C00, 0x0C7F)], files: &[face!("/System/Library/Fonts/Supplemental/", "Telugu Sangam MN.ttc"), face!("/System/Library/Fonts/", "KohinoorTelugu.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansTelugu-Regular.ttf", 0)] }, + Script { name: "kannada", ranges: &[(0x0C80, 0x0CFF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Kannada Sangam MN.ttc"), face!("/System/Library/Fonts/", "NotoSansKannada.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansKannada-Regular.ttf", 0)] }, + Script { name: "malayalam", ranges: &[(0x0D00, 0x0D7F)], files: &[face!("/System/Library/Fonts/Supplemental/", "Malayalam Sangam MN.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansMalayalam-Regular.ttf", 0)] }, + Script { name: "sinhala", ranges: &[(0x0D80, 0x0DFF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Sinhala Sangam MN.ttc"), face!("/System/Library/Fonts/Supplemental/", "Sinhala MN.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansSinhala-Regular.ttf", 0)] }, + Script { name: "thai", ranges: &[(0x0E00, 0x0E7F)], files: &[face!("/System/Library/Fonts/Supplemental/", "Thonburi.ttc"), face!("/System/Library/Fonts/Supplemental/", "Ayuthaya.ttf"), face!("/System/Library/Fonts/Supplemental/", "Sathu.ttf"), face!("/System/Library/Fonts/", "ThonburiUI.ttc"), face!("C:\\Windows\\Fonts\\", "leelawui.ttf"), face!("C:\\Windows\\Fonts\\", "tahoma.ttf")], noto: &[("NotoSansThai-Regular.ttf", 0)] }, + Script { name: "lao", ranges: &[(0x0E80, 0x0EFF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Lao Sangam MN.ttf"), face!("/System/Library/Fonts/Supplemental/", "Lao MN.ttc"), face!("C:\\Windows\\Fonts\\", "leelawui.ttf")], noto: &[("NotoSansLao-Regular.ttf", 0)] }, + Script { name: "tibetan", ranges: &[(0x0F00, 0x0FFF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Kailasa.ttc"), face!("C:\\Windows\\Fonts\\", "himalaya.ttf")], noto: &[("NotoSansTibetan-Regular.ttf", 0)] }, + Script { name: "myanmar", ranges: &[(0x1000, 0x109F), (0xA9E0, 0xA9FF), (0xAA60, 0xAA7F)], files: &[face!("/System/Library/Fonts/Supplemental/", "Myanmar Sangam MN.ttc"), face!("/System/Library/Fonts/", "NotoSansMyanmar.ttc"), face!("C:\\Windows\\Fonts\\", "mmrtext.ttf")], noto: &[("NotoSansMyanmar-Regular.ttf", 0)] }, + Script { name: "khmer", ranges: &[(0x1780, 0x17FF), (0x19E0, 0x19FF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Khmer Sangam MN.ttf"), face!("/System/Library/Fonts/Supplemental/", "Khmer MN.ttc"), face!("C:\\Windows\\Fonts\\", "leelawui.ttf")], noto: &[("NotoSansKhmer-Regular.ttf", 0)] }, + Script { name: "ethiopic", ranges: &[(0x1200, 0x139F), (0x2D80, 0x2DDF), (0xAB00, 0xAB2F)], files: &[face!("/System/Library/Fonts/Supplemental/", "KefaIII.ttf"), face!("C:\\Windows\\Fonts\\", "ebrima.ttf")], noto: &[("NotoSansEthiopic-Regular.ttf", 0)] }, + Script { name: "syriac", ranges: &[(0x0700, 0x074F)], files: &[face!("/System/Library/Fonts/Supplemental/", "NotoSansSyriac-Regular.ttf"), face!("C:\\Windows\\Fonts\\", "segoeui.ttf")], noto: &[("NotoSansSyriac-Regular.ttf", 0)] }, + Script { name: "thaana", ranges: &[(0x0780, 0x07BF)], files: &[face!("/System/Library/Fonts/Supplemental/", "NotoSansThaana-Regular.ttf"), face!("C:\\Windows\\Fonts\\", "mvboli.ttf")], noto: &[("NotoSansThaana-Regular.ttf", 0)] }, + Script { name: "nko", ranges: &[(0x07C0, 0x07FF)], files: &[face!("/System/Library/Fonts/Supplemental/", "NotoSansNKo-Regular.ttf"), face!("C:\\Windows\\Fonts\\", "ebrima.ttf")], noto: &[("NotoSansNKo-Regular.ttf", 0)] }, + Script { name: "cherokee", ranges: &[(0x13A0, 0x13FF), (0xAB70, 0xABBF)], files: &[face!("/System/Library/Fonts/Supplemental/", "PlantagenetCherokee.ttf"), face!("C:\\Windows\\Fonts\\", "plantc.ttf")], noto: &[("NotoSansCherokee-Regular.ttf", 0)] }, + Script { name: "canadian", ranges: &[(0x1400, 0x167F)], files: &[face!("/System/Library/Fonts/Supplemental/", "NotoSansCanadianAboriginal-Regular.otf"), face!("C:\\Windows\\Fonts\\", "euphemia.ttf")], noto: &[("NotoSansCanadianAboriginal-Regular.ttf", 0)] }, + Script { name: "mongolian", ranges: &[(0x1800, 0x18AF)], files: &[face!("/System/Library/Fonts/Supplemental/", "NotoSansMongolian-Regular.ttf"), face!("C:\\Windows\\Fonts\\", "monbaiti.ttf")], noto: &[("NotoSansMongolian-Regular.ttf", 0)] }, + Script { name: "tifinagh", ranges: &[(0x2D30, 0x2D7F)], files: &[face!("/System/Library/Fonts/Supplemental/", "NotoSansTifinagh-Regular.otf"), face!("C:\\Windows\\Fonts\\", "ebrima.ttf")], noto: &[("NotoSansTifinagh-Regular.ttf", 0)] }, + Script { name: "kana", ranges: &[(0x3040, 0x30FF), (0x31F0, 0x31FF), (0xFF66, 0xFF9F)], files: &[face!("/System/Library/Fonts/", "ヒラギノ角ゴシック W3.ttc"), face!("C:\\Windows\\Fonts\\", "YuGothM.ttc"), face!("C:\\Windows\\Fonts\\", "meiryo.ttc"), face!("C:\\Windows\\Fonts\\", "msgothic.ttc")], noto: &[("NotoSansCJK-Regular.ttc", CJK_JP), ("NotoSansCJKjp-Regular.otf", 0)] }, + Script { name: "hangul", ranges: &[(0x1100, 0x11FF), (0x3130, 0x318F), (0xA960, 0xA97F), (0xAC00, 0xD7FF)], files: &[face!("/System/Library/Fonts/Supplemental/", "AppleGothic.ttf"), face!("/System/Library/Fonts/", "AppleSDGothicNeo.ttc"), face!("C:\\Windows\\Fonts\\", "malgun.ttf")], noto: &[("NotoSansCJK-Regular.ttc", CJK_KR), ("NotoSansCJKkr-Regular.otf", 0)] }, + Script { name: "han", ranges: &[(0x2E80, 0x2FDF), (0x3000, 0x303F), (0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF), (0xFE30, 0xFE4F), (0xFF00, 0xFF65), (0xFFA0, 0xFFEF), (0x20000, 0x2FA1F)], files: &[face!("/System/Library/Fonts/", "PingFang.ttc"), face!("/System/Library/Fonts/", "Hiragino Sans GB.ttc"), face!("/System/Library/Fonts/", "STHeiti Medium.ttc"), face!("C:\\Windows\\Fonts\\", "msyh.ttc"), face!("C:\\Windows\\Fonts\\", "msjh.ttc"), face!("C:\\Windows\\Fonts\\", "simsun.ttc")], noto: &[("NotoSansCJK-Regular.ttc", CJK_SC), ("NotoSansCJKsc-Regular.otf", 0)] }, +]; diff --git a/crates/ui-egui/src/design/fonts/system.rs b/crates/ui-egui/src/design/fonts/system.rs new file mode 100644 index 0000000..752d14a --- /dev/null +++ b/crates/ui-egui/src/design/fonts/system.rs @@ -0,0 +1,28 @@ +//! Finding and reading the system font of a script; file access is injected so tests need no fonts. + +use super::scripts::{NOTO_DIRS, Script}; + +/// Largest system font file read, in bytes. +pub(super) const MAX_FONT_BYTES: u64 = 64 * 1024 * 1024; + +/// A system font chosen for a script. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct Pick { + /// File path. + pub(super) path: String, + /// Face index inside the file. + pub(super) index: u32, +} + +/// The first candidate of `script` whose `size_of` is within [`MAX_FONT_BYTES`]; `size_of` is the file size or `None` when missing. +pub(super) fn pick(script: &Script, size_of: impl Fn(&str) -> Option) -> Option { + let fits = |path: &str| size_of(path).is_some_and(|len| len <= MAX_FONT_BYTES); + let direct = script.files.iter().map(|&(path, index)| (path.to_owned(), index)); + let noto = script.noto.iter().flat_map(|&(file, index)| NOTO_DIRS.iter().map(move |dir| (format!("{dir}{file}"), index))); + direct.chain(noto).find(|(path, _)| fits(path)).map(|(path, index)| Pick { path, index }) +} + +/// The size of the file at `path`, when it exists. +pub(super) fn file_size(path: &str) -> Option { + std::fs::metadata(path).ok().map(|meta| meta.len()) +} diff --git a/crates/ui-egui/src/design/fonts/tests.rs b/crates/ui-egui/src/design/fonts/tests.rs new file mode 100644 index 0000000..6829f71 --- /dev/null +++ b/crates/ui-egui/src/design/fonts/tests.rs @@ -0,0 +1,160 @@ +//! Script detection, font choice over a fake file list, and idempotence. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic, clippy::pedantic)] + +use super::scripts::SCRIPTS; +use super::*; + +fn bit(name: &str) -> u64 { + SCRIPTS.iter().position(|script| script.name == name).map_or(0, |i| 1 << i) +} + +#[test] +fn detects_each_script() { + assert_eq!(detect("বাংলা"), bit("bengali")); + assert_eq!(detect("हिन्दी"), bit("devanagari")); + assert_eq!(detect("தமிழ்"), bit("tamil")); + assert_eq!(detect("ภาษาไทย"), bit("thai")); + assert_eq!(detect("中文"), bit("han")); + assert_eq!(detect("ひらがな"), bit("kana")); + assert_eq!(detect("한국어"), bit("hangul")); + assert_eq!(detect("\u{20000}"), bit("han")); +} + +#[test] +fn bundled_and_startup_scripts_need_nothing() { + assert_eq!(detect("Hello Привет Γειά"), 0); + assert_eq!(detect("עברית العربية"), 0); + assert_eq!(detect("😀 ✓"), 0); + assert_eq!(detect(""), 0); +} + +#[test] +fn mixed_text_sets_every_script() { + assert_eq!(detect("a বাংলা b 日本語のテキスト 한"), bit("bengali") | bit("han") | bit("kana") | bit("hangul")); +} + +#[test] +fn scan_stops_at_the_cap() { + let filler = "a".repeat(MAX_SCAN_CHARS - 1); + assert_eq!(detect(&format!("{filler}ব")), bit("bengali")); + assert_eq!(detect(&format!("{filler}aব")), 0); + let huge = "a".repeat(10 * MAX_SCAN_BYTES); + assert_eq!(detect(&format!("{huge}ব")), 0); + let wide = "漢".repeat(MAX_SCAN_BYTES); + assert_eq!(detect(&wide), bit("han")); +} + +#[test] +fn table_is_sound() { + assert!(SCRIPTS.len() <= 64); + for (i, a) in SCRIPTS.iter().enumerate() { + assert!(!a.files.is_empty() && !a.noto.is_empty(), "{}", a.name); + for (lo, hi) in a.ranges { + assert!(lo >= &scripts::FIRST_SCRIPT_CHAR && lo <= hi, "{}", a.name); + for b in SCRIPTS.iter().skip(i + 1) { + assert!(!b.ranges.iter().any(|(l, h)| l <= hi && lo <= h), "{} overlaps {}", a.name, b.name); + } + } + } +} + +fn script(name: &str) -> &'static scripts::Script { + SCRIPTS.iter().find(|script| script.name == name).expect("known script") +} + +#[test] +fn pick_takes_the_first_existing_candidate() { + let bengali = script("bengali"); + let first = bengali.files.first().map(|f| f.0).unwrap_or_default(); + let second = bengali.files.get(1).map(|f| f.0).unwrap_or_default(); + let both = |path: &str| [first, second].contains(&path).then_some(1000); + assert_eq!(system::pick(bengali, both).map(|p| p.path), Some(first.to_owned())); + let only_second = |path: &str| (path == second).then_some(1000); + assert_eq!(system::pick(bengali, only_second).map(|p| p.path), Some(second.to_owned())); + assert_eq!(system::pick(bengali, |_| None), None); +} + +#[test] +fn pick_skips_oversized_files_and_finds_noto_on_linux() { + let han = script("han"); + let huge = |path: &str| han.files.iter().any(|f| f.0 == path).then_some(system::MAX_FONT_BYTES + 1); + assert_eq!(system::pick(han, huge), None); + let linux = |path: &str| (path == "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc").then_some(1000); + let pick = system::pick(han, linux).expect("noto cjk"); + assert_eq!(pick.index, 2); + let hangul = system::pick(script("hangul"), linux).expect("noto cjk"); + assert_eq!(hangul.index, 1); +} + +fn installed() -> egui::Context { + let ctx = egui::Context::default(); + install(&ctx); + ctx +} + +fn font_count(ctx: &egui::Context) -> usize { + let state = ctx.data(|d| d.get_temp::>>(egui::Id::new(STATE_KEY))); + state.and_then(|s| s.lock().ok().map(|s| s.defs.font_data.len())).unwrap_or_default() +} + +#[test] +fn second_call_is_a_no_op() { + let ctx = installed(); + let before = font_count(&ctx); + let wanted = bit("bengali") | bit("han"); + let size = |_: &str| Some(10); + let read = |_: &str| Some(vec![0_u8; 4]); + assert!(add_scripts(&ctx, wanted, size, read)); + assert_eq!(font_count(&ctx), before + 2); + assert!(!add_scripts(&ctx, wanted, size, read)); + assert_eq!(font_count(&ctx), before + 2); +} + +#[test] +fn missing_script_is_remembered() { + let ctx = installed(); + let before = font_count(&ctx); + let wanted = bit("thai"); + assert!(!add_scripts(&ctx, wanted, |_| None, |_| None)); + assert!(!add_scripts(&ctx, wanted, |_| Some(10), |_| Some(vec![0_u8; 4]))); + assert_eq!(font_count(&ctx), before); +} + +#[test] +fn without_install_nothing_happens() { + assert!(!ensure_scripts(&egui::Context::default(), "বাংলা")); +} + +/// Whether the family chain of `family` resolves `ch` to a real glyph. +fn resolves(ctx: &egui::Context, family: FontFamily, ch: char) -> bool { + ctx.fonts_mut(|fonts| fonts.has_glyph(&egui::FontId::new(14.0, family), ch)) +} + +#[test] +fn symbols_never_show_boxes_in_any_text_family() { + let ctx = egui::Context::default(); + install(&ctx); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + for family in [FontFamily::Proportional, FontFamily::Monospace, medium(), semibold()] { + for ch in ['\u{2A33}', '\u{2192}', '\u{2211}', '\u{221E}'] { + assert!(resolves(&ctx, family.clone(), ch), "{family:?} lacks U+{:04X}", u32::from(ch)); + } + } +} + +#[test] +fn math_stays_last_when_a_script_font_is_added() { + let ctx = egui::Context::default(); + install(&ctx); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + let bit = bit("bengali"); + assert!(add_scripts(&ctx, bit, |_| Some(1), |_| Some(JETBRAINS_MONO.to_vec()))); + let state = ctx.data(|data| data.get_temp::>>(egui::Id::new(STATE_KEY))).expect("state"); + let state = state.lock().expect("lock"); + for family in [FontFamily::Proportional, FontFamily::Monospace, medium(), semibold()] { + let chain = state.defs.families.get(&family).expect("chain"); + assert_eq!(chain.last().map(String::as_str), Some(MATH), "{family:?}"); + assert!(chain.iter().any(|n| n == "script-bengali")); + } +} diff --git a/crates/ui-egui/src/shell/attachment_drop/big.rs b/crates/ui-egui/src/shell/attachment_drop/big.rs new file mode 100644 index 0000000..0a01b73 --- /dev/null +++ b/crates/ui-egui/src/shell/attachment_drop/big.rs @@ -0,0 +1,40 @@ +//! Pictures larger than the texture limit of the device: read their size from the header, and shrink them off +//! the paint path before they become a texture (a texture above the limit panics inside egui). + +use std::io::Cursor; + +use egui::ColorImage; +use image::imageops::FilterType; + +/// Most pixels a picture may hold to be shrunk; a bigger claim is refused before any decoding. +pub(super) const MAX_PIXELS: u64 = 100_000_000; + +/// The pixel size of a picture, from the header of its bytes alone. +pub(super) fn header_size(bytes: &[u8]) -> Option<[u32; 2]> { + let reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?; + let (width, height) = reader.into_dimensions().ok()?; + Some([width, height]) +} + +/// Whether a picture of this size cannot be a texture when the limit is `max_side`. +pub(super) fn exceeds(size: [u32; 2], max_side: u32) -> bool { + size[0].max(size[1]) > max_side +} + +/// Whether a picture of this size is too big to decode at all. +pub(super) fn too_many_pixels(size: [u32; 2]) -> bool { + u64::from(size[0]).saturating_mul(u64::from(size[1])) > MAX_PIXELS +} + +#[expect(clippy::cast_precision_loss, reason = "a size in points; pixel counts above 2^24 do not need exactness")] +/// Decodes `bytes` and shrinks the picture, keeping its aspect ratio, until both sides fit `max_side`. +pub(super) fn shrunk(bytes: &[u8], max_side: u32) -> Option { + let decoded = image::load_from_memory(bytes).ok()?; + let original = egui::vec2(decoded.width() as f32, decoded.height() as f32); + let fitted = decoded.resize(max_side.max(1), max_side.max(1), FilterType::Triangle).to_rgba8(); + let size = [usize::try_from(fitted.width()).ok()?, usize::try_from(fitted.height()).ok()?]; + let mut shrunk = ColorImage::from_rgba_unmultiplied(size, fitted.as_raw()); + // The picture keeps the size of the original in the layout; only its pixels are fewer. + shrunk.source_size = original; + Some(shrunk) +} diff --git a/crates/ui-egui/src/shell/attachment_drop/clipboard.rs b/crates/ui-egui/src/shell/attachment_drop/clipboard.rs new file mode 100644 index 0000000..d4533f9 --- /dev/null +++ b/crates/ui-egui/src/shell/attachment_drop/clipboard.rs @@ -0,0 +1,241 @@ +//! The clipboard of the operating system: the image or the files a paste brings in (SPEC 9). +//! +//! The window layer turns a paste into a text paste and eats the key when the +//! clipboard holds no text, so an image or a copied file never reaches the app +//! as text. The clipboard is therefore read once per paste, behind the +//! [`ClipboardSource`] trait, so the tests can hand the app an image, a file +//! list or nothing without a real clipboard. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::mpsc::{Receiver, Sender}; + +use image::ImageEncoder; +use serde_json::json; + +use super::{Request, encoding, extension, is_image_extension, path_request}; +use crate::text::paste as strings; + +/// The largest image side the paste path accepts, in pixels. +pub const MAX_IMAGE_SIDE: usize = 16_384; + +/// One clipboard image: the pixels, red, green, blue and alpha, four bytes each. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClipboardImage { + /// The width in pixels. + pub width: usize, + /// The height in pixels. + pub height: usize, + /// The pixels, four bytes each. + pub bytes: Vec, +} + +/// What the clipboard holds. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum Clipboard { + /// Nothing to paste: the clipboard holds text, or is empty. + #[default] + Empty, + /// An image: a screenshot, or a picture copied from another app. + Image(ClipboardImage), + /// Files copied in the file manager. + Files(Vec), +} + +/// Reads the clipboard once. +pub trait ClipboardSource: Send + Sync { + /// Reads the clipboard. + /// + /// # Errors + /// + /// The error text when the clipboard could not be read at all; the caller + /// shows it as a toast. + fn read(&self) -> Result; +} + +/// The clipboard of the operating system. +#[derive(Debug, Default, Clone, Copy)] +pub struct SystemClipboard; + +impl ClipboardSource for SystemClipboard { + fn read(&self) -> Result { + let mut clipboard = arboard::Clipboard::new().map_err(|_| strings::clipboard_unreadable().to_owned())?; + let files = clipboard.get().file_list().unwrap_or_default(); + if !files.is_empty() { + return Ok(Clipboard::Files(files)); + } + match clipboard.get().image() { + Ok(image) => Ok(Clipboard::Image(ClipboardImage { width: image.width, height: image.height, bytes: image.bytes.into_owned() })), + Err(_) => Ok(Clipboard::Empty), + } + } +} + +/// The answer of one clipboard read: what it holds, or why it could not be read. +pub type Answer = Result; + +/// Starts the read of the clipboard on a worker thread. +/// +/// The read stays off the paint path: the platform hands a screenshot over as +/// a picture that costs tens of milliseconds to decode. The answer comes +/// through the channel, one or more frames later. +#[must_use] +pub fn spawn(source: Arc) -> Receiver { + let (sender, answer): (Sender, Receiver) = std::sync::mpsc::channel(); + let worker = sender.clone(); + let fallback = Arc::clone(&source); + let spawned = std::thread::Builder::new().name("notescraft-clipboard".into()).spawn(move || { + let _ = worker.send(source.read()); + }); + if spawned.is_err() { + // The thread could not start: the answer comes from here. + let _ = sender.send(fallback.read()); + } + answer +} + +/// What one clipboard read produced. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Pasted { + /// The requests to run: the files to copy into the vault and the links to insert. + pub requests: Vec, + /// The names of the files `NotesCraft` cannot attach. + pub unsupported: Vec, +} + +/// The requests for one clipboard read. +/// +/// A file inside the vault is linked, never copied; a file outside is copied +/// into the attachment folder by the engine. An image becomes a PNG the +/// engine saves under the `Pasted image` name. +/// +/// # Errors +/// +/// The error text when the clipboard holds an image that cannot be attached; +/// the caller shows it as a toast. +pub fn requests(content: Clipboard, vault: &Path, max_bytes: usize) -> Result { + match content { + Clipboard::Empty => Ok(Pasted::default()), + Clipboard::Image(image) => { + let png = encode_png(&image, max_bytes)?; + let request = Request::Command { command_id: "attachment.save", params: json!({ "ext": "png", "data_base64": encoding::encode(&png) }) }; + Ok(Pasted { requests: vec![request], unsupported: Vec::new() }) + } + Clipboard::Files(files) => Ok(file_requests(&files, vault)), + } +} + +/// The request for one pasted file: a link when the file lies inside the +/// vault, a command that copies it in when it does not. +/// +/// # Errors +/// +/// The error text when the file cannot be attached: a type the engine does +/// not accept, a path that cannot be linked or a path that is not valid UTF-8. +pub fn file_request(file: &Path, vault: &Path) -> Result { + let Some(extension) = extension(file) else { + return Err(strings::unsupported_type().to_owned()); + }; + match place(vault, file) { + Place::Inside(relative) => Ok(Request::Link { text: link_text(&relative, &extension), name: file_name(file) }), + Place::Unlinkable => Err(strings::invalid_path().to_owned()), + Place::Outside => path_request(file), + } +} + +/// The requests for the files on the clipboard. +fn file_requests(files: &[PathBuf], vault: &Path) -> Pasted { + let mut pasted = Pasted::default(); + for file in files { + match file_request(file, vault) { + Ok(request) => pasted.requests.push(request), + Err(_) => pasted.unsupported.push(file_name(file)), + } + } + pasted +} + +/// Where a pasted file sits relative to the vault. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Place { + /// Inside the vault, with the vault-relative path to link. + Inside(String), + /// Inside the vault, but the path cannot be linked. + Unlinkable, + /// Outside the vault: the engine copies the file in. + Outside, +} + +/// The place of a file relative to the vault folder. +fn place(root: &Path, path: &Path) -> Place { + if let Some(relative) = relative_of(root, path) { + return Place::Inside(relative); + } + if path.strip_prefix(root).is_ok() { + return Place::Unlinkable; + } + // The vault root is canonical and the clipboard path is not: a vault + // under `/tmp` is opened as `/private/tmp`. The second try resolves the + // path before it compares. + match path.canonicalize().ok().and_then(|real| relative_of(root, &real)) { + Some(relative) => Place::Inside(relative), + None => Place::Outside, + } +} + +/// The parts of a path under `root`, when it lies there and can be linked. +fn relative_of(root: &Path, path: &Path) -> Option { + let relative = path.strip_prefix(root).ok()?; + let mut parts = Vec::new(); + for component in relative.components() { + let std::path::Component::Normal(part) = component else { return None }; + let text = part.to_str()?; + if text.is_empty() || text.contains(['\n', '\r', '\0']) { + return None; + } + parts.push(text); + } + if parts.is_empty() { + return None; + } + Some(parts.join("/")) +} + +/// The link to a file inside the vault: an embed for an image, a wikilink for the rest. +fn link_text(relative: &str, extension: &str) -> String { + if is_image_extension(extension) { format!("![[{relative}]]") } else { format!("[[{relative}]]") } +} + +/// The name of a file, for the toast. +fn file_name(file: &Path) -> String { + file.file_name().map(|part| part.to_string_lossy().into_owned()).unwrap_or_default() +} + +/// The PNG bytes of a clipboard image. +/// +/// # Errors +/// +/// The error text when the image is over the cap, over the largest side, or +/// does not hold the pixels its size promises. +fn encode_png(image: &ClipboardImage, max_bytes: usize) -> Result, String> { + if image.width == 0 || image.height == 0 || image.width > MAX_IMAGE_SIDE || image.height > MAX_IMAGE_SIDE { + return Err(strings::over_limit(&max_bytes.to_string())); + } + let pixels = image.width.checked_mul(image.height).and_then(|count| count.checked_mul(4)).ok_or_else(|| strings::over_limit(&max_bytes.to_string()))?; + if pixels > max_bytes { + return Err(strings::over_limit(&max_bytes.to_string())); + } + if image.bytes.len() != pixels { + return Err(strings::image_invalid().to_owned()); + } + let width = u32::try_from(image.width).map_err(|_| strings::image_invalid().to_owned())?; + let height = u32::try_from(image.height).map_err(|_| strings::image_invalid().to_owned())?; + let mut png = Vec::new(); + image::codecs::png::PngEncoder::new(&mut png) + .write_image(&image.bytes, width, height, image::ExtendedColorType::Rgba8) + .map_err(|_| strings::image_invalid().to_owned())?; + Ok(png) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/shell/attachment_drop/clipboard/tests.rs b/crates/ui-egui/src/shell/attachment_drop/clipboard/tests.rs new file mode 100644 index 0000000..7889381 --- /dev/null +++ b/crates/ui-egui/src/shell/attachment_drop/clipboard/tests.rs @@ -0,0 +1,373 @@ +//! The clipboard of a paste: an image, a file list, a clipboard that holds nothing. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use egui::{Context, Event, Key, Modifiers, RawInput}; + +use super::*; +use crate::shell::attachment_drop::{Request, State}; +use crate::text::paste as strings; + +/// A clipboard the test hands to the app. +#[derive(Debug)] +struct FakeClipboard { + /// What the clipboard holds. + content: Clipboard, + /// The error the read fails with, when the clipboard cannot be read. + failure: Option, + /// How many times the clipboard was read. + reads: Arc, +} + +impl FakeClipboard { + /// A clipboard holding `content`. + fn new(content: Clipboard) -> Self { + Self { content, failure: None, reads: Arc::new(AtomicUsize::new(0)) } + } + + /// A clipboard that cannot be read. + fn failing() -> Self { + Self { content: Clipboard::Empty, failure: Some("the clipboard could not be read".to_owned()), reads: Arc::new(AtomicUsize::new(0)) } + } + + /// The counter the reads are counted in. + fn counter(&self) -> Arc { + Arc::clone(&self.reads) + } +} + +impl ClipboardSource for FakeClipboard { + fn read(&self) -> Result { + self.reads.fetch_add(1, Ordering::Relaxed); + match &self.failure { + Some(error) => Err(error.clone()), + None => Ok(self.content.clone()), + } + } +} + +/// The vault folder the tests pretend to have. +fn vault() -> PathBuf { + PathBuf::from("/tmp/notescraft-paste-vault") +} + +/// A clipboard image of `width` x `height` pixels. +fn image(width: usize, height: usize) -> ClipboardImage { + let mut bytes = Vec::with_capacity(width * height * 4); + for _ in 0..width * height { + bytes.extend_from_slice(&[10, 20, 30, 255]); + } + ClipboardImage { width, height, bytes } +} + +/// Where a paste lands. +#[derive(Debug, Clone, Copy)] +struct Target { + /// The note is in Source mode. + source: bool, + /// The editor has the focus. + focused: bool, + /// The vault is read-only. + read_only: bool, +} + +/// The target of a paste in a writable note. +fn note() -> Target { + Target { source: true, focused: true, read_only: false } +} + +/// One frame with one `V` key event, and the requests it produced. +fn key_frame(state: &mut State, target: Target, pressed: bool, modifiers: Modifiers) -> Vec { + let ctx = Context::default(); + let input = RawInput { events: vec![Event::Key { key: Key::V, physical_key: None, pressed, repeat: false, modifiers }], ..Default::default() }; + let mut requests = Vec::new(); + let mut output = ctx.run_ui(input, |ui| requests = state.collect(ui.ctx(), target.source, target.focused, target.read_only, &vault())); + output.textures_delta.clear(); + requests +} + +/// One frame with the paste shortcut: it asks the clipboard. +fn paste(state: &mut State, target: Target) -> Vec { + key_frame(state, target, true, Modifiers::COMMAND) +} + +/// One frame with the `V` key released without a press: the trace a paste +/// leaves when the window layer ate the key. +fn swallowed(state: &mut State, target: Target) -> Vec { + key_frame(state, target, false, Modifiers::COMMAND) +} + +/// One frame with nothing in it, and the requests it produced. +fn quiet_frame(state: &mut State, target: Target) -> Vec { + let ctx = Context::default(); + let mut requests = Vec::new(); + let mut output = ctx.run_ui(RawInput::default(), |ui| requests = state.collect(ui.ctx(), target.source, target.focused, target.read_only, &vault())); + output.textures_delta.clear(); + requests +} + +/// The frames that carry the answer of the clipboard read, and the requests it brought. +fn settle(state: &mut State, target: Target) -> Vec { + let mut requests = Vec::new(); + for _ in 0..500 { + std::thread::sleep(Duration::from_millis(1)); + requests = quiet_frame(state, target); + if !requests.is_empty() || state.reading.is_none() { + return requests; + } + } + requests +} + +/// The requests of a paste, once the answer of the clipboard arrives. +fn pasted(state: &mut State, target: Target) -> Vec { + let _ = paste(state, target); + settle(state, target) +} + +/// The bytes of a base64 string, for the tests that look at the PNG. +fn decode_base64(text: &str) -> Vec { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = Vec::new(); + let mut buffer = 0_u32; + let mut bits = 0_u32; + for byte in text.bytes() { + if byte == b'=' { + break; + } + let Some(index) = ALPHABET.iter().position(|known| *known == byte) else { continue }; + buffer = (buffer << 6) | u32::from(u8::try_from(index).unwrap_or_default()); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push(u8::try_from((buffer >> bits) & 0xFF).unwrap_or_default()); + buffer &= (1 << bits) - 1; + } + } + out +} + +#[test] +fn a_file_inside_the_vault_is_linked_and_never_copied() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Files(vec![vault().join("Attachments/pic.png")])))); + let requests = pasted(&mut state, note()); + assert_eq!(requests.len(), 1); + assert_eq!(requests.first(), Some(&Request::Link { text: "![[Attachments/pic.png]]".to_owned(), name: "pic.png".to_owned() })); + assert!(state.error.is_none()); +} + +#[test] +fn a_file_that_is_not_an_image_is_linked_without_the_bang() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Files(vec![vault().join("notes/paper.pdf")])))); + let requests = pasted(&mut state, note()); + assert_eq!(requests.first(), Some(&Request::Link { text: "[[notes/paper.pdf]]".to_owned(), name: "paper.pdf".to_owned() })); +} + +#[test] +fn a_file_outside_the_vault_is_copied_by_the_engine() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Files(vec![PathBuf::from("/tmp/outside/paper.pdf")])))); + let requests = pasted(&mut state, note()); + assert_eq!(requests.len(), 1); + let Some(Request::Command { command_id, params }) = requests.first() else { return }; + assert_eq!(*command_id, "attachment.save_path"); + assert_eq!(params.get("source_path").and_then(serde_json::Value::as_str), Some("/tmp/outside/paper.pdf")); +} + +#[test] +fn a_clipboard_image_becomes_a_png_for_the_engine() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Image(image(2, 3))))); + let requests = pasted(&mut state, note()); + assert_eq!(requests.len(), 1); + let Some(Request::Command { command_id, params }) = requests.first() else { return }; + assert_eq!(*command_id, "attachment.save"); + assert_eq!(params.get("ext").and_then(serde_json::Value::as_str), Some("png")); + let encoded = params.get("data_base64").and_then(serde_json::Value::as_str).unwrap_or_default(); + let bytes = decode_base64(encoded); + assert_eq!(bytes.get(..4), Some(&[0x89, b'P', b'N', b'G'][..])); + let decoded = image::load_from_memory(&bytes); + assert!(decoded.is_ok(), "the payload is not a PNG"); + let Ok(decoded) = decoded else { return }; + assert_eq!((decoded.width(), decoded.height()), (2, 3)); +} + +#[test] +fn the_engine_saves_a_pasted_image_under_the_pasted_image_name() { + let vault = crate::testsupport::temp_vault("attachment-paste-image"); + let mut config = notescraft_engine::SessionConfig::default(); + config.files.max_attachment_mib = 1; + let opened = notescraft_engine::Session::open_with(&vault, config); + assert!(opened.is_ok(), "{opened:?}"); + let Ok(mut session) = opened else { return }; + let mut registry = notescraft_engine::Registry::new(); + assert!(notescraft_engine::register_core(&mut registry).is_ok()); + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Image(image(2, 2))))); + let requests = pasted(&mut state, note()); + let Some(Request::Command { command_id, params }) = requests.first() else { return }; + let result = registry.run(command_id, &mut session, params); + assert!(result.is_ok(), "{result:?}"); + let path = result.ok().and_then(|value| value.get("path").and_then(serde_json::Value::as_str).map(str::to_owned)); + let Some(path) = path else { return }; + assert!(path.starts_with("Attachments/Pasted image "), "{path}"); + assert!(Path::new(&path).extension().is_some_and(|extension| extension.eq_ignore_ascii_case("png")), "{path}"); + let bytes = session.vault().read(&path).unwrap_or_default(); + assert_eq!(bytes.get(..4), Some(&[0x89, b'P', b'N', b'G'][..])); +} + +#[test] +fn a_clipboard_that_holds_nothing_produces_no_request() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Empty))); + let requests = pasted(&mut state, note()); + assert_eq!(requests, []); + assert!(state.error.is_none()); +} + +#[test] +fn a_clipboard_that_cannot_be_read_is_reported_once() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::failing())); + let requests = pasted(&mut state, note()); + assert_eq!(requests, []); + assert_eq!(state.error.as_deref(), Some("the clipboard could not be read")); +} + +#[test] +fn the_visual_mode_accepts_a_clipboard_paste() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Files(vec![vault().join("Attachments/pic.png")])))); + let requests = pasted(&mut state, Target { source: false, focused: false, read_only: false }); + assert_eq!(requests.len(), 1); + assert!(matches!(requests.first(), Some(Request::Link { .. }))); + assert!(state.error.is_none(), "{state:?}"); +} + +#[test] +fn a_read_only_vault_refuses_the_paste() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Files(vec![PathBuf::from("/tmp/outside/pic.png")])))); + let requests = pasted(&mut state, Target { source: true, focused: true, read_only: true }); + assert_eq!(requests, []); + assert_eq!(state.error.as_deref(), Some(strings::read_only())); +} + +#[test] +fn an_oversized_image_is_refused_before_it_is_encoded() { + let mut state = State { max_bytes: 8, ..Default::default() }; + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Image(image(2, 2))))); + let requests = pasted(&mut state, note()); + assert_eq!(requests, []); + assert!(state.error.as_deref().is_some_and(|error| error.contains("8 byte")), "{state:?}"); +} + +#[test] +fn an_unsupported_file_is_reported_and_never_copied() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Files(vec![PathBuf::from("/tmp/outside/tool.exe")])))); + let requests = pasted(&mut state, note()); + assert_eq!(requests, []); + assert!(state.error.as_deref().is_some_and(|error| error.contains("tool.exe")), "{state:?}"); +} + +#[test] +fn a_pasted_path_inside_the_vault_is_linked_and_never_copied() { + let mut state = State::default(); + let path = vault().join("Attachments/pic.png"); + let ctx = Context::default(); + let input = RawInput { events: vec![Event::Paste(path.to_string_lossy().into_owned())], ..Default::default() }; + let mut requests = Vec::new(); + let mut output = ctx.run_ui(input, |ui| requests = state.collect(ui.ctx(), true, true, false, &vault())); + output.textures_delta.clear(); + assert_eq!(requests, vec![Request::Link { text: "![[Attachments/pic.png]]".to_owned(), name: "pic.png".to_owned() }]); + assert!(state.error.is_none(), "{state:?}"); +} + +#[test] +fn a_file_pasted_as_text_and_as_a_list_is_attached_once() { + let mut state = State::default(); + let path = vault().join("Attachments/pic.png"); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Files(vec![path.clone()])))); + let ctx = Context::default(); + let input = RawInput { + events: vec![ + Event::Key { key: Key::V, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }, + Event::Paste(path.to_string_lossy().into_owned()), + ], + ..Default::default() + }; + let mut requests = Vec::new(); + let mut output = ctx.run_ui(input, |ui| requests = state.collect(ui.ctx(), true, true, false, &vault())); + output.textures_delta.clear(); + requests.append(&mut settle(&mut state, note())); + assert_eq!(requests, vec![Request::Link { text: "![[Attachments/pic.png]]".to_owned(), name: "pic.png".to_owned() }], "{requests:?}"); +} + +#[test] +fn the_visual_mode_accepts_a_paste_over_the_note() { + let mut state = State::default(); + state.set_clipboard(Arc::new(FakeClipboard::new(Clipboard::Files(vec![vault().join("Attachments/pic.png")])))); + let ctx = Context::default(); + // The note is painted: the area of the note is the drop target. + let mut output = ctx.run_ui(RawInput::default(), |ui| { + let Some(theme) = crate::testsupport::theme("catppuccin-mocha") else { return }; + let tokens = crate::theme::Tokens::new(theme); + state.show(ui, &tokens, egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 400.0)), false, false); + }); + output.textures_delta.clear(); + // The pointer is over the note and another field holds the focus. + let input = RawInput { + events: vec![ + Event::PointerMoved(egui::pos2(10.0, 10.0)), + Event::Key { key: Key::V, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }, + ], + ..Default::default() + }; + let mut requests = Vec::new(); + let mut output = ctx.run_ui(input, |ui| { + ui.memory_mut(|memory| memory.request_focus(egui::Id::new("property-field"))); + requests = state.collect(ui.ctx(), false, false, false, &vault()); + }); + output.textures_delta.clear(); + requests.append(&mut settle(&mut state, Target { source: false, focused: false, read_only: false })); + assert_eq!(requests.len(), 1, "{requests:?}"); + assert!(matches!(requests.first(), Some(Request::Link { .. })), "{requests:?}"); +} + +#[test] +fn the_clipboard_is_read_once_per_paste() { + let mut state = State::default(); + let clipboard = FakeClipboard::new(Clipboard::Empty); + let reads = clipboard.counter(); + state.set_clipboard(Arc::new(clipboard)); + let _ = pasted(&mut state, note()); + assert_eq!(reads.load(Ordering::Relaxed), 1); +} + +#[test] +fn a_paste_the_window_layer_swallowed_is_still_read() { + let mut state = State::default(); + let clipboard = FakeClipboard::new(Clipboard::Empty); + let reads = clipboard.counter(); + state.set_clipboard(Arc::new(clipboard)); + let _ = swallowed(&mut state, note()); + let _ = settle(&mut state, note()); + assert_eq!(reads.load(Ordering::Relaxed), 1); +} + +#[test] +fn typing_a_v_does_not_read_the_clipboard() { + let mut state = State::default(); + let clipboard = FakeClipboard::new(Clipboard::Empty); + let reads = clipboard.counter(); + state.set_clipboard(Arc::new(clipboard)); + let _ = key_frame(&mut state, note(), true, Modifiers::NONE); + let _ = key_frame(&mut state, note(), false, Modifiers::NONE); + assert_eq!(reads.load(Ordering::Relaxed), 0); +} diff --git a/crates/ui-egui/src/shell/attachment_drop/images.rs b/crates/ui-egui/src/shell/attachment_drop/images.rs index 6f10214..8a01251 100644 --- a/crates/ui-egui/src/shell/attachment_drop/images.rs +++ b/crates/ui-egui/src/shell/attachment_drop/images.rs @@ -1,14 +1,15 @@ //! Preloaded vault image embeds: disk reads happen before note painting. use std::collections::BTreeMap; -use std::sync::Arc; +use std::sync::{Arc, Mutex, mpsc}; -use crate::theme::{Tokens, color}; +use crate::lightbox::{self, Kind}; +use crate::theme::Tokens; use egui::{Context, Id, Sense, Ui, Vec2}; use notescraft_engine::Session; use notescraft_markdown::{Document, ItemKind}; -use super::{DEFAULT_MAX_BYTES, EXTENSIONS, IMAGE_EXTENSIONS, extension}; +use super::{DEFAULT_MAX_BYTES, EXTENSIONS, IMAGE_EXTENSIONS, big, extension}; /// Maximum decoded image dimension requested by an embed size hint. const MAX_DIMENSION: f32 = 8192.0; @@ -16,16 +17,35 @@ const MAX_DIMENSION: f32 = 8192.0; const MAX_IMAGES: usize = 64; const TOO_MANY: &str = "Note exceeds the {{limit}} image limit"; const TOO_LARGE: &str = "Note images exceed the {{limit}} byte limit"; -const LIGHTBOX_SELECTED: &str = "notescraft-image-lightbox-target"; -const LIGHTBOX_FRACTION: f32 = 0.85; -const LIGHTBOX_BACKDROP: f32 = 0.88; -const CLOSE_LABEL: &str = "Close preview"; -const CLOSE_SIZE: f32 = 24.0; #[derive(Debug, Clone)] struct Image { uri: String, source: Source, + /// Vault path of the file, for the lightbox. + path: String, + kind: Kind, + /// For a PDF, the page the raster shows. + page: u32, + /// Size of the file in bytes. + len: u64, + /// The SVG file, so the lightbox can rasterise it again at the size it shows it at. + svg: Option>, + /// Pixel size from the header of a bitmap, to tell a picture that exceeds the texture limit. + size: Option<[u32; 2]>, + /// The bitmap shrunk to the texture limit, once it was asked for. + shrink: Arc>, +} + +/// The shrinking of a bitmap that exceeds the texture limit of the device. +#[derive(Debug, Default)] +enum Shrink { + #[default] + Idle, + /// A worker thread decodes and shrinks the picture. + Working(mpsc::Receiver>), + /// The shrunk picture, or `None` when it cannot be read. + Done(Option>), } #[derive(Debug, Clone)] @@ -46,19 +66,62 @@ impl std::fmt::Debug for Raster { } impl Image { - fn widget(&self, ctx: &Context) -> egui::Image<'static> { - match &self.source { + /// The image to paint; `None` while a big picture is being shrunk or when it cannot be read. + fn widget(&self, ctx: &Context) -> Option> { + let limit = u32::try_from(ctx.input(|input| input.max_texture_side)).unwrap_or(u32::MAX); + if let Some(size) = self.size.filter(|size| big::exceeds(*size, limit)) { + return self.shrunk(ctx, size, limit).map(|raster| self.raster_widget(ctx, &raster)); + } + Some(match &self.source { Source::Bytes(bytes) => egui::Image::from_bytes(self.uri.clone(), bytes.clone()), - Source::Raster(raster) => { - let load = || (ctx.clone(), ctx.load_texture(&self.uri, raster.image.clone(), egui::TextureOptions::LINEAR)); - let mut cache = raster.texture.lock(); - let (owner, texture) = cache.get_or_insert_with(load); - if owner != ctx { - (*owner, *texture) = load(); + Source::Raster(raster) => self.raster_widget(ctx, raster), + }) + } + + fn raster_widget(&self, ctx: &Context, raster: &Raster) -> egui::Image<'static> { + let load = || (ctx.clone(), ctx.load_texture(&self.uri, raster.image.clone(), egui::TextureOptions::LINEAR)); + let mut cache = raster.texture.lock(); + let (owner, texture) = cache.get_or_insert_with(load); + if owner != ctx { + (*owner, *texture) = load(); + } + egui::Image::from_texture((texture.id(), raster.image.source_size)) + } + + /// The picture shrunk to `limit`, started on a worker thread by the first call. + fn shrunk(&self, ctx: &Context, size: [u32; 2], limit: u32) -> Option> { + let Ok(mut state) = self.shrink.lock() else { return None }; + if matches!(*state, Shrink::Idle) { + *state = match &self.source { + Source::Bytes(bytes) if !big::too_many_pixels(size) => { + let (sender, receiver) = mpsc::channel(); + let (bytes, repaint) = (bytes.clone(), ctx.clone()); + std::thread::spawn(move || { + // A closed channel means the note was left: nobody waits for the picture. + let _ = sender.send(big::shrunk(&bytes, limit)); + repaint.request_repaint(); + }); + Shrink::Working(receiver) } - egui::Image::from_texture((texture.id(), raster.image.source_size)) + _ => Shrink::Done(None), + }; + } + if let Shrink::Working(receiver) = &*state { + match receiver.try_recv() { + Ok(image) => *state = Shrink::Done(image.map(|image| Arc::new(Raster { image, texture: egui::mutex::Mutex::default() }))), + Err(mpsc::TryRecvError::Disconnected) => *state = Shrink::Done(None), + Err(mpsc::TryRecvError::Empty) => {} } } + match &*state { + Shrink::Done(raster) => raster.clone(), + Shrink::Idle | Shrink::Working(_) => None, + } + } + + /// Whether the picture is still being shrunk (as opposed to unreadable). + fn is_working(&self) -> bool { + self.shrink.lock().is_ok_and(|state| matches!(*state, Shrink::Working(_))) } } @@ -71,7 +134,10 @@ fn pdf_page(subpath: Option<&str>) -> u32 { /// Rebuild when the note or its attachments change; `show` performs no disk IO. #[derive(Debug, Default, Clone)] pub struct Images { - images: BTreeMap, + entries: BTreeMap, + /// Targets of the pictures, in the order of the note. + order: Vec, + root: std::path::PathBuf, } impl Images { @@ -82,11 +148,17 @@ impl Images { /// Returns a readable error when a resolved file cannot be read or the /// note's image count/total byte limit is exceeded. pub fn load(session: &Session, from: &str, doc: &Document) -> Result { - let mut result = Self::default(); + let mut result = Self { root: session.vault().root().to_path_buf(), ..Self::default() }; let mut total = 0_usize; - for item in &doc.items { - let ItemKind::Embed { target, subpath, .. } = &item.kind else { continue }; - if result.images.contains_key(target) { + let embeds = doc.items.iter().filter_map(|item| match &item.kind { + ItemKind::Embed { target, subpath, .. } => Some((target.clone(), subpath.clone())), + _ => None, + }); + // Markdown images and `` tags are loaded like `![[..]]` embeds, under the path they were written with. + let written = crate::note_view::image_targets(doc).into_iter().map(|target| (target, None)); + for (target, subpath) in embeds.chain(written) { + let (target, subpath) = (&target, &subpath); + if result.entries.contains_key(target) { continue; } let resolution = session.index().resolve(from, target); @@ -95,32 +167,51 @@ impl Images { if ext == "pdf" { // A PDF embed shows its page as an image when the system can render it; otherwise the note view // draws the file card. - if result.images.len() < MAX_IMAGES + if result.entries.len() < MAX_IMAGES && let Ok(bytes) = session.pdf_preview(path, pdf_page(subpath.as_deref())) { total = total.saturating_add(bytes.len()); let uri = format!("bytes://notescraft/embed/pdf/{}/{path}", fingerprint(&bytes)); - result.images.insert(target.clone(), Image { uri, source: Source::Bytes(bytes.into()) }); + let (path, len) = (path.to_owned(), bytes.len() as u64); + let page = pdf_page(subpath.as_deref()); + result.entries.insert( + target.clone(), + Image { uri, source: Source::Bytes(bytes.into()), path, kind: Kind::Pdf, page, len, svg: None, size: None, shrink: Arc::default() }, + ); } continue; } if matches!(ext.as_str(), "mp4" | "mov" | "mkv" | "webm" | "ogv" | "avi") { // A video shows a still from the file; the note view draws the card around it. let key = format!("poster:{path}"); - if result.images.len() < MAX_IMAGES - && !result.images.contains_key(&key) + if result.entries.len() < MAX_IMAGES + && !result.entries.contains_key(&key) && let Ok(bytes) = session.video_poster(path) { total = total.saturating_add(bytes.len()); let uri = format!("bytes://notescraft/embed/poster/{}/{path}", fingerprint(&bytes)); - result.images.insert(key, Image { uri, source: Source::Bytes(bytes.into()) }); + let (path, len) = (path.to_owned(), bytes.len() as u64); + result.entries.insert( + key, + Image { + uri, + source: Source::Bytes(bytes.into()), + path, + kind: Kind::Video, + page: 1, + len, + svg: None, + size: None, + shrink: Arc::default(), + }, + ); } continue; } if !EXTENSIONS.iter().take(IMAGE_EXTENSIONS).any(|allowed| *allowed == ext) { continue; } - if result.images.len() >= MAX_IMAGES { + if result.entries.len() >= MAX_IMAGES { return Err(TOO_MANY.replace("{{limit}}", &MAX_IMAGES.to_string())); } let source = session.vault().resolve(path).map_err(|error| error.to_string())?; @@ -132,6 +223,9 @@ impl Images { let bytes = session.vault().read(path).map_err(|error| error.to_string())?; total = total.saturating_add(bytes.len()); let fingerprint = fingerprint(&bytes); + let len = bytes.len() as u64; + let svg = (ext == "svg").then(|| Arc::<[u8]>::from(bytes.as_slice())); + let size = if ext == "svg" { None } else { big::header_size(&bytes) }; let source = if ext == "svg" { let raster = super::svg::decode(&bytes)?; total = total.saturating_add(raster.pixels.len().saturating_mul(std::mem::size_of::())); @@ -143,23 +237,41 @@ impl Images { return Err(TOO_LARGE.replace("{{limit}}", &DEFAULT_MAX_BYTES.to_string())); } let uri = format!("bytes://notescraft/embed/{fingerprint}/{path}"); - result.images.insert(target.clone(), Image { uri, source }); + result.order.push(target.clone()); + result + .entries + .insert(target.clone(), Image { uri, source, path: path.to_owned(), kind: Kind::Image, page: 1, len, svg, size, shrink: Arc::default() }); } Ok(result) } + /// Whether the picture of `target` is still being shrunk on a worker thread. + #[cfg(test)] + pub(super) fn is_working(&self, target: &str) -> bool { + self.entries.get(target).is_some_and(Image::is_working) + } + /// Whether `target` resolved to a preloaded image. #[must_use] pub fn contains(&self, target: &str) -> bool { - self.images.contains_key(target) + self.entries.contains_key(target) } /// Paints a preloaded image, applying an optional Obsidian `width[xheight]` /// hint. Returns false for missing images or invalid hints, so the caller /// can draw its ordinary embed fallback. Never accesses the filesystem. pub fn show(&self, ui: &mut Ui, target: &str, size: Option<&str>) -> bool { - let Some(image) = self.images.get(target) else { return false }; - let mut widget = image.widget(ui.ctx()).max_width(ui.available_width()); + let Some(image) = self.entries.get(target) else { return false }; + let Some(widget) = image.widget(ui.ctx()) else { + if image.is_working() { + ui.add(egui::Spinner::new()); + ui.ctx().request_repaint_after(WORKING_POLL); + } else { + failed(ui, target); + } + return true; + }; + let mut widget = widget.max_width(ui.available_width()); if image.uri.starts_with("bytes://notescraft/embed/pdf/") { // A rendered page has a transparent background: put it on paper. widget = widget.bg_fill(egui::Color32::WHITE); @@ -172,67 +284,83 @@ impl Images { }; } if widget.load_for_size(ui.ctx(), ui.available_size()).is_err() { - let text = crate::text::note_view::image_failed().replace("{{target}}", target); - ui.label(egui::RichText::new(text).small().color(ui.visuals().weak_text_color())); + failed(ui, target); return true; } let response = ui.add(widget.sense(Sense::click())).on_hover_cursor(egui::CursorIcon::ZoomIn); if response.clicked() { - ui.ctx().data_mut(|data| data.insert_temp(Id::new(LIGHTBOX_SELECTED), target.to_owned())); + lightbox::select(ui.ctx(), target, response.rect); } true } - /// Shows the clicked image in a raised, rounded lightbox. Escape, its close - /// control, or clicking outside closes it. Returns whether it was visible. - /// Uses only the already-preloaded bytes and never accesses the filesystem. + /// Shows the clicked image, PDF page, video or file in the lightbox (`views/lightbox`): it + /// flies out of the thumbnail, and Escape, its close control, or clicking outside closes it. + /// Returns whether it was visible. Uses only the already-preloaded bytes. + #[expect(clippy::must_use_candidate, reason = "the caller may only want the lightbox drawn")] pub fn show_lightbox(&self, ctx: &Context, tokens: &Tokens) -> bool { - let selected = Id::new(LIGHTBOX_SELECTED); - let closing_id = Id::new((LIGHTBOX_SELECTED, "closing")); + let selected = Id::new(lightbox::SELECTED); let Some(target) = ctx.data(|data| data.get_temp::(selected)) else { return false }; - let Some(image) = self.images.get(&target) else { - ctx.data_mut(|data| data.remove::(selected)); - return false; + let loose = lightbox::file_path(&target); + let Some(image) = self.entries.get(&target).filter(|_| loose.is_none()) else { + let Some(path) = loose else { + lightbox::clear(ctx); + return false; + }; + let view = lightbox::View { + target: &target, + path, + kind: Kind::File, + image: None, + bytes: None, + vector: None, + len: 0, + page: 1, + root: &self.root, + siblings: &[], + }; + return lightbox::show(ctx, tokens, &view); }; - let closing = ctx.data(|data| data.get_temp::(closing_id)).unwrap_or(false); - // Closing fades the lightbox out instead of dropping it. - let presence = crate::motion::progress(ctx, "image-lightbox-visible", !closing, crate::motion::FAST, crate::motion::ease_out_cubic); - if closing && presence <= 0.0 { - ctx.data_mut(|data| { - data.remove::(selected); - data.remove::(closing_id); - }); - return false; - } - let fade = crate::motion::appear(ctx, "image-lightbox-appear", crate::motion::FAST, crate::motion::ease_out_cubic) * presence; - let size = ctx.content_rect().size() * LIGHTBOX_FRACTION; - let mut close = false; - let response = egui::Modal::new(Id::new("notescraft-image-lightbox")) - .frame(crate::context_menu::frame(tokens)) - .backdrop_color(color(tokens.theme.colors.canvas).gamma_multiply(LIGHTBOX_BACKDROP * fade)) - .show(ctx, |ui| { - ui.multiply_opacity(fade); - if closing { - ui.disable(); - } - ui.set_max_width(size.x); - ui.horizontal(|ui| { - ui.colored_label(color(tokens.theme.colors.text_muted), &target); - let (rect, response) = ui.allocate_exact_size(Vec2::splat(CLOSE_SIZE), Sense::click()); - crate::lottie_icon::paint_hover(ui, response.id, "x", rect, color(tokens.theme.colors.text_muted), response.hovered()); - let response = response.on_hover_text(CLOSE_LABEL); - close = response.clicked(); - }); - ui.add(image.widget(ctx).fit_to_exact_size(size).max_size(size)); - }); - if !closing && (close || response.should_close()) { - ctx.data_mut(|data| data.insert_temp(closing_id, true)); - ctx.request_repaint(); - } - true + let (bytes, vector) = match (&image.source, &image.svg) { + (Source::Bytes(bytes), _) => (Some(bytes.clone()), None), + (Source::Raster(raster), Some(svg)) => { + let size = raster.image.source_size; + ( + None, + Some(lightbox::Vector { + uri: format!("bytes://notescraft/lightbox/{}", image.uri.trim_start_matches("bytes://notescraft/embed/")), + bytes: svg.clone(), + size, + }), + ) + } + (Source::Raster(_), None) => (None, None), + }; + let view = lightbox::View { + target: &target, + path: &image.path, + kind: image.kind, + image: image.widget(ctx), + bytes, + vector, + len: image.len, + page: image.page, + root: &self.root, + siblings: &self.order, + }; + lightbox::show(ctx, tokens, &view) } } +/// How often a picture that is being shrunk is looked at again, as a repaint delay. +const WORKING_POLL: std::time::Duration = std::time::Duration::from_millis(50); + +/// The notice of a picture that cannot be read. +fn failed(ui: &mut Ui, target: &str) { + let text = crate::text::note_view::image_failed().replace("{{target}}", target); + ui.label(egui::RichText::new(text).small().color(ui.visuals().weak_text_color())); +} + fn dimensions(size: &str) -> Option<(f32, Option)> { let (width, height) = size.split_once('x').map_or((size, None), |(width, height)| (width, Some(height))); let parse = |value: &str| value.parse::().ok().filter(|value| value.is_finite() && *value > 0.0 && *value <= MAX_DIMENSION); diff --git a/crates/ui-egui/src/shell/attachment_drop/mod.rs b/crates/ui-egui/src/shell/attachment_drop/mod.rs index f864903..80b7aec 100644 --- a/crates/ui-egui/src/shell/attachment_drop/mod.rs +++ b/crates/ui-egui/src/shell/attachment_drop/mod.rs @@ -1,14 +1,21 @@ //! Attachment input collection and a paint-only editor drop affordance (SPEC 9). -use egui::{Context, Event, Rect}; +use egui::{Context, Event, Key, Rect}; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::mpsc::{Receiver, TryRecvError}; +use crate::text::paste as strings; + +mod big; +mod clipboard; mod encoding; mod images; mod overlay; mod svg; +pub use clipboard::{Clipboard, ClipboardImage, ClipboardSource, SystemClipboard}; pub use images::Images; /// The default attachment input cap, matching the engine's 64 MiB setting. @@ -21,78 +28,194 @@ const MAX_PATH_BYTES: usize = 16 * 1024; const IMAGE_EXTENSIONS: usize = 7; /// All file types accepted by the engine's attachment commands. const EXTENSIONS: [&str; 15] = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "pdf", "mp3", "wav", "ogg", "m4a", "mp4", "webm", "mov"]; -const TOO_LARGE: &str = "Attachment exceeds the {{limit}} byte limit"; -const UNSUPPORTED: &str = "Unsupported attachment file type"; -const INVALID_PATH: &str = "Attachment path is not valid UTF-8"; -const SOURCE_HINT: &str = "Switch to Source to paste images"; -const READ_ONLY_HINT: &str = "Attachments are unavailable in a read-only vault"; -/// One engine command to dispatch after collecting input, outside painting. +/// One thing to do with a pasted or dropped file. #[derive(Debug, Clone, PartialEq)] -pub struct Request { - /// Registered attachment command id. - pub command_id: &'static str, - /// Validated transport parameters; engine remains responsible for saving. - pub params: Value, +pub enum Request { + /// Save the attachment with an engine command and insert the link it returns. + Command { + /// The registered attachment command id. + command_id: &'static str, + /// Validated transport parameters; engine remains responsible for saving. + params: Value, + }, + /// Insert a link to a file that already sits inside the vault. + Link { + /// The link text: `![[path]]` for an image, `[[path]]` for anything else. + text: String, + /// The file name, for the toast. + name: String, + }, } /// Attachment input state, shared between event collection and overlay painting. -#[derive(Debug, Clone, PartialEq)] pub struct State { /// Maximum in-memory payload, normally set from session attachment settings. pub max_bytes: usize, /// Last input failure, consumed by the app's existing dismissible toast. pub error: Option, + /// The clipboard a paste reads; the system clipboard is read by default. + clipboard: Arc, + /// The read in flight, when one is. + reading: Option>, + /// The requests already made for the paste in flight: the text form and + /// the file list of one file are attached once. + pasted: Vec, + /// Whether the `V` key was seen going down, to spot a paste the window layer swallowed. + paste_key_down: bool, target: Option, } +impl std::fmt::Debug for State { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.debug_struct("State").field("max_bytes", &self.max_bytes).field("error", &self.error).finish_non_exhaustive() + } +} + impl Default for State { fn default() -> Self { - Self { max_bytes: DEFAULT_MAX_BYTES, error: None, target: None } + Self { + max_bytes: DEFAULT_MAX_BYTES, + error: None, + clipboard: Arc::new(SystemClipboard), + reading: None, + pasted: Vec::new(), + paste_key_down: false, + target: None, + } } } impl State { - /// Consumes writable Source attachment events before the source widget sees + /// Sets the clipboard a paste reads; the system clipboard is read by default. + pub fn set_clipboard(&mut self, source: Arc) { + self.clipboard = source; + } + + /// Consumes writable attachment events before the source widget sees /// them. Ordinary text paste stays in the event stream. No vault IO occurs. /// Native paths go straight to the engine; virtual handles are read here, /// outside painting, then size-checked before base64 allocation. - pub fn collect(&mut self, ctx: &Context, source: bool, focused: bool, read_only: bool) -> Vec { - let over_target = ctx.input(|input| self.target.map_or(focused, |rect| input.pointer.hover_pos().map_or(focused, |pos| rect.contains(pos)))); - if !source || read_only { - let paste_target = focused || (!source && ctx.memory(|memory| memory.focused().is_none())); - let attempted = ctx.input_mut(|input| { - let mut attempted = over_target && !input.raw.dropped_files.is_empty(); - if paste_target { - input.events.retain(|event| { - let image = matches!(event, Event::Paste(text) if pasted_image_path(text).is_some()); - attempted |= image; - !image - }); - } - attempted - }); - if attempted { - self.error = Some(if read_only { READ_ONLY_HINT } else { SOURCE_HINT }.to_owned()); - } - return Vec::new(); - } + /// A paste reads the clipboard once, on a worker thread, for the image or + /// the file list the platform holds; the requests go back to the app, + /// which runs them. + pub fn collect(&mut self, ctx: &Context, source: bool, focused: bool, read_only: bool, vault: &Path) -> Vec { + let mut requests = self.clipboard_requests(ctx, source, focused, read_only, vault); + self.pasted_text(ctx, source, focused, read_only, vault, &mut requests); + self.dropped_files(ctx, source, focused, read_only, &mut requests); + requests + } + + /// The requests of the clipboard: the answer of the read in flight, and a + /// new read when the user asks to paste. + fn clipboard_requests(&mut self, ctx: &Context, source: bool, focused: bool, read_only: bool, vault: &Path) -> Vec { let mut requests = Vec::new(); - if focused { + if let Some(answer) = self.take_answer() { + match answer { + Ok(content) => match clipboard::requests(content, vault, self.max_bytes) { + Ok(pasted) => { + for request in pasted.requests { + self.push_request(&mut requests, request); + } + if !pasted.unsupported.is_empty() { + self.error = Some(strings::cannot_attach(&pasted.unsupported.join(", "))); + } + } + Err(error) => self.error = Some(error), + }, + Err(error) => self.error = Some(error), + } + } + if self.paste_asked(ctx) && self.note_wants_paste(ctx, source, focused) { + if read_only { + self.error = Some(strings::read_only().to_owned()); + } else if self.reading.is_none() { + self.pasted.clear(); + self.reading = Some(clipboard::spawn(Arc::clone(&self.clipboard))); + } + } + requests + } + + /// Whether the paste belongs to the note: its editor has the focus, the + /// pointer sits over it, the visual view holds the caret in a block, or no + /// other field took the focus. + fn note_wants_paste(&self, ctx: &Context, source: bool, focused: bool) -> bool { + if focused || self.pointer_over_note(ctx) { + return true; + } + if !source && crate::note_view::grip::focus(ctx).is_some() { + return true; + } + ctx.memory(|memory| memory.focused().is_none()) + } + + /// Whether the pointer sits over the note the app paints. + fn pointer_over_note(&self, ctx: &Context) -> bool { + let Some(rect) = self.target else { return false }; + ctx.input(|input| input.pointer.hover_pos().is_some_and(|pos| rect.contains(pos))) + } + + /// Adds a request, unless the same one was already made for this paste: + /// the clipboard can hold a file both as a path and as a file list. + fn push_request(&mut self, requests: &mut Vec, request: Request) { + if self.pasted.contains(&request) { + return; + } + self.pasted.push(request.clone()); + requests.push(request); + } + + /// The pasted text that is a local path: an attachment, in Source mode only. + fn pasted_text(&mut self, ctx: &Context, source: bool, focused: bool, read_only: bool, vault: &Path, requests: &mut Vec) { + if source && !read_only { + if !focused { + return; + } ctx.input_mut(|input| { input.events.retain(|event| { let Event::Paste(text) = event else { return true }; let Some(path) = pasted_image_path(text) else { return true }; - match path_request(&path) { - Ok(request) => requests.push(request), + match clipboard::file_request(&path, vault) { + Ok(request) => self.push_request(requests, request), Err(error) => self.error = Some(error), } false }); }); + return; + } + let paste_target = focused || (!source && ctx.memory(|memory| memory.focused().is_none())); + if !paste_target { + return; + } + let mut attempted = false; + ctx.input_mut(|input| { + input.events.retain(|event| { + let image = matches!(event, Event::Paste(text) if pasted_image_path(text).is_some()); + attempted |= image; + !image + }); + }); + if attempted { + let hint = if read_only { strings::read_only() } else { strings::source_hint() }; + self.error = Some(hint.to_owned()); + } + } + + /// The dropped files: an attachment each, in Source mode only. + fn dropped_files(&mut self, ctx: &Context, source: bool, focused: bool, read_only: bool, requests: &mut Vec) { + let over_target = ctx.input(|input| self.target.map_or(focused, |rect| input.pointer.hover_pos().map_or(focused, |pos| rect.contains(pos)))); + if !source || read_only { + let attempted = ctx.input(|input| over_target && !input.raw.dropped_files.is_empty()); + if attempted { + let hint = if read_only { strings::read_only() } else { strings::source_hint() }; + self.error = Some(hint.to_owned()); + } + return; } if !over_target { - return requests; + return; } let dropped = ctx.input_mut(|input| std::mem::take(&mut input.raw.dropped_files)); for file in dropped { @@ -105,11 +228,11 @@ impl State { continue; } let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else { - self.error = Some(INVALID_PATH.to_owned()); + self.error = Some(strings::invalid_path().to_owned()); continue; }; let Some(extension) = extension(path) else { - self.error = Some(UNSUPPORTED.to_owned()); + self.error = Some(strings::unsupported_type().to_owned()); continue; }; match file.bytes().and_then(|bytes| self.bytes_request(name, &extension, &bytes)) { @@ -117,28 +240,82 @@ impl State { Err(error) => self.error = Some(error), } } - requests + } + + /// Whether the user asked to paste in this frame. + /// + /// The window layer turns the paste shortcut into a text paste and eats + /// the key when the clipboard holds no text: the release of the `V` key + /// without a press is the trace it leaves. The shortcut is also read + /// from the key event, for the platforms that report it. + fn paste_asked(&mut self, ctx: &Context) -> bool { + let mut down = false; + let mut up = false; + let mut shortcut = false; + ctx.input(|input| { + for event in &input.events { + if let Event::Key { key: Key::V, pressed, modifiers, .. } = event { + if *pressed { + down = true; + if modifiers.command || modifiers.ctrl { + shortcut = true; + } + } else { + up = true; + } + } + } + }); + if down { + self.paste_key_down = true; + } + let swallowed = up && !self.paste_key_down; + if up { + self.paste_key_down = false; + } + shortcut || swallowed + } + + /// Takes the answer of the clipboard read in flight, when it is there. + fn take_answer(&mut self) -> Option { + let receiver = self.reading.take()?; + match receiver.try_recv() { + Ok(answer) => Some(answer), + Err(TryRecvError::Empty) => { + self.reading = Some(receiver); + None + } + // The worker died without an answer: read that as a failure. + Err(TryRecvError::Disconnected) => Some(Err(strings::clipboard_unreadable().to_owned())), + } } fn bytes_request(&self, name: &str, extension: &str, bytes: &[u8]) -> Result { let cap = self.max_bytes.min(MAX_ENCODE_BYTES); if bytes.len() > cap { - return Err(TOO_LARGE.replace("{{limit}}", &cap.to_string())); + return Err(strings::over_limit(&cap.to_string())); } - Ok(Request { command_id: "attachment.save", params: json!({ "ext": extension, "data_base64": encoding::encode(bytes), "name": name }) }) + Ok(Request::Command { command_id: "attachment.save", params: json!({ "ext": extension, "data_base64": encoding::encode(bytes), "name": name }) }) } } +/// The request that copies a file from outside the vault into it. fn path_request(path: &Path) -> Result { - let path = path.to_str().ok_or_else(|| INVALID_PATH.to_owned())?; - Ok(Request { command_id: "attachment.save_path", params: json!({ "source_path": path }) }) + let path = path.to_str().ok_or_else(|| strings::invalid_path().to_owned())?; + Ok(Request::Command { command_id: "attachment.save_path", params: json!({ "source_path": path }) }) } +/// The extension of a path, when the engine accepts that kind of file. fn extension(path: &Path) -> Option { let extension = path.extension()?.to_str()?.to_ascii_lowercase(); EXTENSIONS.contains(&extension.as_str()).then_some(extension) } +/// Whether an extension names an image. +fn is_image_extension(extension: &str) -> bool { + EXTENSIONS.iter().take(IMAGE_EXTENSIONS).any(|allowed| *allowed == extension) +} + fn pasted_image_path(text: &str) -> Option { let text = text.trim(); if text.len() > MAX_PATH_BYTES || text.contains(['\n', '\r', '\0']) { @@ -160,7 +337,7 @@ fn pasted_image_path(text: &str) -> Option { return None; } let ext = extension(path)?; - EXTENSIONS.iter().take(IMAGE_EXTENSIONS).any(|allowed| *allowed == ext).then(|| path.to_owned()) + is_image_extension(&ext).then(|| path.to_owned()) } fn decode_url_path(path: &str) -> Option { diff --git a/crates/ui-egui/src/shell/attachment_drop/overlay.rs b/crates/ui-egui/src/shell/attachment_drop/overlay.rs index 333caa2..479f583 100644 --- a/crates/ui-egui/src/shell/attachment_drop/overlay.rs +++ b/crates/ui-egui/src/shell/attachment_drop/overlay.rs @@ -2,14 +2,14 @@ use egui::{Align2, FontId, Rect, Ui, Vec2}; +use crate::text::paste as strings; use crate::theme::{Tokens, color}; -use super::{READ_ONLY_HINT, SOURCE_HINT, State}; +use super::State; const WASH_OPACITY: f32 = 0.12; const ICON_SIZE: f32 = 32.0; const LABEL_OFFSET: f32 = 30.0; -const DROP_LABEL: &str = "Drop to attach"; impl State { /// Draws a rounded accent wash over `rect` while files hover over the editor. @@ -25,10 +25,16 @@ impl State { painter.rect_filled(rect, tokens.radius_medium(), tint.gamma_multiply(WASH_OPACITY)); let icon_rect = Rect::from_center_size(rect.center() - Vec2::new(0.0, LABEL_OFFSET / 2.0), Vec2::splat(ICON_SIZE)); crate::icons::paint(ui, "image", icon_rect, tint); - painter.text(rect.center() + Vec2::new(0.0, LABEL_OFFSET), Align2::CENTER_CENTER, DROP_LABEL, FontId::proportional(crate::style::TEXT_BODY), tint); + painter.text( + rect.center() + Vec2::new(0.0, LABEL_OFFSET), + Align2::CENTER_CENTER, + strings::drop_label(), + FontId::proportional(crate::style::TEXT_BODY), + tint, + ); } if hovering && !active { - let hint = if read_only { READ_ONLY_HINT } else { SOURCE_HINT }; + let hint = if read_only { strings::read_only() } else { strings::source_hint() }; egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), ui.id().with("attachment-drop-hint"), rect.center()).show(|ui| { ui.label(hint); }); diff --git a/crates/ui-egui/src/shell/attachment_drop/tests.rs b/crates/ui-egui/src/shell/attachment_drop/tests.rs index 1a94ef0..74efaf2 100644 --- a/crates/ui-egui/src/shell/attachment_drop/tests.rs +++ b/crates/ui-egui/src/shell/attachment_drop/tests.rs @@ -5,8 +5,10 @@ use std::sync::Arc; use egui::{Context, Event, RawInput}; use notescraft_engine::{Registry, Session, register_core}; +use serde_json::Value; use super::*; +use crate::text::paste as strings; #[derive(Debug)] struct MemoryFile { @@ -24,10 +26,31 @@ impl egui::DroppedFile for MemoryFile { } } +/// The vault folder the tests pretend to have. +fn vault() -> PathBuf { + PathBuf::from("/tmp/notescraft-attachment-vault") +} + +/// The command id of a request, when it runs a command. +fn command_id(request: &Request) -> Option<&'static str> { + match request { + Request::Command { command_id, .. } => Some(command_id), + Request::Link { .. } => None, + } +} + +/// The parameters of a request, when it runs a command. +fn params(request: &Request) -> Option<&Value> { + match request { + Request::Command { params, .. } => Some(params), + Request::Link { .. } => None, + } +} + fn frame(state: &mut State, input: RawInput, source: bool, focused: bool, read_only: bool) -> Vec { let ctx = Context::default(); let mut requests = Vec::new(); - let mut output = ctx.run_ui(input, |ui| requests = state.collect(ui.ctx(), source, focused, read_only)); + let mut output = ctx.run_ui(input, |ui| requests = state.collect(ui.ctx(), source, focused, read_only, &vault())); output.textures_delta.clear(); requests } @@ -44,13 +67,13 @@ fn pasted_local_image_url_is_consumed_but_plain_text_is_preserved() { let mut requests = Vec::new(); let mut remaining = Vec::new(); let mut output = ctx.run_ui(input, |ui| { - requests = state.collect(ui.ctx(), true, true, false); + requests = state.collect(ui.ctx(), true, true, false, &vault()); remaining = ui.input(|i| i.events.clone()); }); output.textures_delta.clear(); assert_eq!(requests.len(), 1); - assert_eq!(requests.first().map(|r| r.command_id), Some("attachment.save_path")); - assert_eq!(requests.first().and_then(|r| r.params.get("source_path")).and_then(serde_json::Value::as_str), Some("/tmp/my photo.PNG")); + assert_eq!(requests.first().and_then(command_id), Some("attachment.save_path")); + assert_eq!(requests.first().and_then(params).and_then(|values| values.get("source_path")).and_then(serde_json::Value::as_str), Some("/tmp/my photo.PNG")); assert_eq!(remaining, vec![Event::Paste("ordinary text".into())]); } @@ -66,8 +89,8 @@ fn visual_unfocused_and_read_only_inputs_never_request_writes() { fn memory_drops_encode_padding_for_all_tail_lengths() { for (bytes, encoded) in [(b"a".as_slice(), "YQ=="), (b"ab", "YWI="), (b"abc", "YWJj"), (&[255, 255], "//8=")] { let requests = frame(&mut State::default(), dropped("pic.png", bytes), true, true, false); - assert_eq!(requests.first().map(|r| r.command_id), Some("attachment.save")); - assert_eq!(requests.first().and_then(|r| r.params.get("data_base64")).and_then(serde_json::Value::as_str), Some(encoded)); + assert_eq!(requests.first().and_then(command_id), Some("attachment.save")); + assert_eq!(requests.first().and_then(params).and_then(|values| values.get("data_base64")).and_then(serde_json::Value::as_str), Some(encoded)); } } @@ -84,7 +107,7 @@ fn audio_video_and_pdf_drop_requests_use_engine_extension_contract() { let requests = frame(&mut State::default(), dropped(name, b"bytes"), true, true, false); assert_eq!(requests.len(), 1, "{name}"); assert_eq!( - requests.first().and_then(|r| r.params.get("ext")).and_then(serde_json::Value::as_str), + requests.first().and_then(params).and_then(|values| values.get("ext")).and_then(serde_json::Value::as_str), Path::new(name).extension().and_then(std::ffi::OsStr::to_str) ); } @@ -104,7 +127,8 @@ fn drop_commands_save_collisions_and_enforce_session_cap() { let requests = frame(&mut State::default(), dropped("pic.png", b"abcd"), true, true, false); assert_eq!(requests.len(), 1); let Some(request) = requests.first() else { return }; - let result = registry.run(request.command_id, &mut session, &request.params); + let Some((command_id, params)) = command_id(request).zip(params(request)) else { return }; + let result = registry.run(command_id, &mut session, params); assert!(result.is_ok(), "{result:?}"); assert_eq!(result.ok().and_then(|value| value.get("path").and_then(serde_json::Value::as_str).map(str::to_owned)), Some(expected.into())); assert_eq!(session.vault().read(expected).ok(), Some(b"abcd".to_vec())); @@ -112,7 +136,8 @@ fn drop_commands_save_collisions_and_enforce_session_cap() { let requests = frame(&mut State::default(), dropped("large.png", &vec![0; 1024 * 1024 + 1]), true, true, false); assert_eq!(requests.len(), 1); let Some(request) = requests.first() else { return }; - let result = registry.run(request.command_id, &mut session, &request.params); + let Some((command_id, params)) = command_id(request).zip(params(request)) else { return }; + let result = registry.run(command_id, &mut session, params); assert!(result.is_err()); assert!(!vault.join("Attachments/large.png").exists()); } @@ -120,7 +145,7 @@ fn drop_commands_save_collisions_and_enforce_session_cap() { #[test] fn native_drop_uses_path_without_reading_the_handle() { let requests = frame(&mut State::default(), dropped("/missing/not-read.png", b"ignored"), true, true, false); - assert_eq!(requests.first().map(|r| r.command_id), Some("attachment.save_path")); + assert_eq!(requests.first().and_then(command_id), Some("attachment.save_path")); } #[test] @@ -139,7 +164,7 @@ fn unsupported_virtual_drop_is_rejected_before_reading_its_handle() { let input = RawInput { dropped_files: vec![Arc::new(Unreadable)], ..Default::default() }; let mut state = State::default(); assert_eq!(frame(&mut state, input, true, true, false), []); - assert_eq!(state.error.as_deref(), Some(UNSUPPORTED)); + assert_eq!(state.error.as_deref(), Some(strings::unsupported_type())); } #[cfg(unix)] @@ -150,7 +175,7 @@ fn non_utf8_virtual_name_is_rejected_before_command_dispatch() { let input = RawInput { dropped_files: vec![Arc::new(MemoryFile { name, data: b"image".to_vec() })], ..Default::default() }; let mut state = State::default(); assert_eq!(frame(&mut state, input, true, true, false), []); - assert_eq!(state.error.as_deref(), Some(INVALID_PATH)); + assert_eq!(state.error.as_deref(), Some(strings::invalid_path())); } #[cfg(unix)] @@ -196,8 +221,8 @@ fn visual_image_paste_without_source_focus_shows_hint_and_is_consumed() { let mut state = State::default(); let input = RawInput { events: vec![Event::Paste("/tmp/pic.png".into()), Event::Paste("ordinary text".into())], ..Default::default() }; let mut output = ctx.run_ui(input, |ui| { - assert_eq!(state.collect(ui.ctx(), false, false, false), []); - assert_eq!(state.error.as_deref(), Some(SOURCE_HINT)); + assert_eq!(state.collect(ui.ctx(), false, false, false, &vault()), []); + assert_eq!(state.error.as_deref(), Some(strings::source_hint())); assert_eq!(ui.input(|input| input.events.clone()), vec![Event::Paste("ordinary text".into())]); }); output.textures_delta.clear(); @@ -210,7 +235,7 @@ fn visual_image_path_paste_into_another_field_stays_plain_text() { let input = RawInput { events: vec![Event::Paste("/tmp/pic.png".into())], ..Default::default() }; let mut output = ctx.run_ui(input, |ui| { ui.memory_mut(|memory| memory.request_focus(egui::Id::new("property-field"))); - assert_eq!(state.collect(ui.ctx(), false, false, false), []); + assert_eq!(state.collect(ui.ctx(), false, false, false, &vault()), []); assert!(state.error.is_none()); assert!(ui.input(|input| input.events.iter().any(|event| matches!(event, Event::Paste(_))))); }); @@ -223,8 +248,8 @@ fn read_only_image_paste_is_consumed_before_source_editor() { let mut state = State::default(); let input = RawInput { events: vec![Event::Paste("/tmp/pic.png".into())], ..Default::default() }; let mut output = ctx.run_ui(input, |ui| { - assert_eq!(state.collect(ui.ctx(), true, true, true), []); - assert_eq!(state.error.as_deref(), Some(READ_ONLY_HINT)); + assert_eq!(state.collect(ui.ctx(), true, true, true, &vault()), []); + assert_eq!(state.error.as_deref(), Some(strings::read_only())); assert!(!ui.input(|input| input.events.iter().any(|event| matches!(event, Event::Paste(_))))); }); output.textures_delta.clear(); @@ -345,3 +370,81 @@ fn lightbox_escape_closes_without_vault_io() { output.textures_delta.clear(); assert!(ctx.data(|data| data.get_temp::(selected)).is_none()); } + +/// A PNG of the given size in the vault as an attachment named `name`, and the images of a note embedding it. +fn big_picture(label: &str, name: &str, size: [u32; 2]) -> Option { + let vault = crate::testsupport::temp_vault(label); + let mut session = Session::open(&vault).ok()?; + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbaImage::from_pixel(size[0], size[1], image::Rgba([200, 30, 30, 255])).write_to(&mut png, image::ImageFormat::Png).ok()?; + session.save_attachment(Some(name), "png", png.get_ref()).ok()?; + Images::load(&session, "note.md", ¬escraft_markdown::parse(&format!("![[{name}.png]]"))).ok() +} + +/// Paints the embed until the worker has shrunk it and returns the rect it takes (width 600). +fn shown_size(images: &Images, target: &str) -> Option { + let ctx = Context::default(); + egui_extras::install_image_loaders(&ctx); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while std::time::Instant::now() < deadline { + let mut rect = None; + let mut output = ctx.run_ui(RawInput::default(), |ui| { + ui.set_max_width(600.0); + let response = ui.scope(|ui| assert!(images.show(ui, target, None))); + rect = Some(response.response.rect); + }); + output.textures_delta.clear(); + if rect.is_some_and(|rect| rect.height() > 30.0 || rect.width() > 30.0) && !images.is_working(target) { + return rect; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + None +} + +#[test] +fn a_picture_wider_than_the_texture_limit_is_shrunk_instead_of_panicking() { + let images_opt = big_picture("attachment-big-square", "square", [4096, 4096]); + assert!(images_opt.is_some(), "vault"); + let Some(images) = images_opt else { return }; + let rect = shown_size(&images, "square.png"); + let rect_opt = rect; + assert!(rect_opt.is_some(), "never shown"); + let Some(rect) = rect_opt else { return }; + assert!((rect.width() - rect.height()).abs() < 1.0, "{rect:?}"); + assert!(rect.width() <= 600.0 + 0.01, "{rect:?}"); +} + +#[test] +fn a_tall_strip_keeps_its_aspect_ratio() { + let images_opt = big_picture("attachment-big-strip", "strip", [100, 10000]); + assert!(images_opt.is_some(), "vault"); + let Some(images) = images_opt else { return }; + let rect_opt = shown_size(&images, "strip.png"); + assert!(rect_opt.is_some(), "never shown"); + let Some(rect) = rect_opt else { return }; + assert!((rect.height() / rect.width() - 100.0).abs() < 2.0, "{rect:?}"); +} + +#[test] +fn the_shrunk_pixels_fit_the_texture_limit_and_keep_the_aspect() { + let mut png = std::io::Cursor::new(Vec::new()); + assert!(image::RgbaImage::new(3000, 100).write_to(&mut png, image::ImageFormat::Png).is_ok()); + let shrunk_opt = super::big::shrunk(png.get_ref(), 2048); + assert!(shrunk_opt.is_some(), "decoded"); + let Some(shrunk) = shrunk_opt else { return }; + assert_eq!(shrunk.size, [2048, 68]); + assert_eq!(shrunk.source_size, egui::vec2(3000.0, 100.0)); + assert_eq!(super::big::header_size(png.get_ref()), Some([3000, 100])); + assert!(super::big::too_many_pixels([100_000, 100_000])); +} + +#[test] +fn markdown_images_are_preloaded_like_embeds() { + let vault = crate::testsupport::temp_vault("attachment-md-images"); + let Ok(mut session) = Session::open(&vault) else { return }; + assert!(session.save_attachment(Some("tiny"), "svg", br#""#).is_ok()); + let doc = notescraft_markdown::parse("![alt](tiny.svg) ![250](https://example.org/x.png) \n"); + let images = Images::load(&session, "note.md", &doc); + assert!(images.as_ref().is_ok_and(|images| images.contains("tiny.svg") && !images.contains("https://example.org/x.png")), "{images:?}"); +} diff --git a/crates/ui-egui/src/text/features/paste.rs b/crates/ui-egui/src/text/features/paste.rs new file mode 100644 index 0000000..2f93482 --- /dev/null +++ b/crates/ui-egui/src/text/features/paste.rs @@ -0,0 +1,46 @@ +//! Copy for the paste path: the hints of the attachment input and the toast of a pasted file. + +table! { + source_hint = "ui.paste.source-hint"; + read_only = "ui.paste.read-only"; + drop_label = "ui.paste.drop-label"; + too_large = "ui.paste.too-large"; + unsupported_type = "ui.paste.unsupported"; + invalid_path = "ui.paste.invalid-path"; + clipboard_unreadable = "ui.paste.clipboard-unreadable"; + image_invalid = "ui.paste.image-invalid"; + unsupported_files = "ui.paste.unsupported-files"; + saved = "ui.paste.saved"; + linked = "ui.paste.linked"; + saved_and_linked = "ui.paste.saved-and-linked"; +} + +/// "Attachment exceeds the {{limit}} byte limit", with the limit filled in. +#[must_use] +pub fn over_limit(limit: &str) -> String { + too_large().replace("{{limit}}", limit) +} + +/// "`NotesCraft` cannot attach {{names}}", with the names filled in. +#[must_use] +pub fn cannot_attach(names: &str) -> String { + unsupported_files().replace("{{names}}", names) +} + +/// "Saved {{names}}", with the names filled in. +#[must_use] +pub fn saved_files(names: &str) -> String { + saved().replace("{{names}}", names) +} + +/// "Linked {{names}}", with the names filled in. +#[must_use] +pub fn linked_files(names: &str) -> String { + linked().replace("{{names}}", names) +} + +/// "Saved {{saved}} · Linked {{linked}}", with the names filled in. +#[must_use] +pub fn saved_and_linked_files(saved: &str, linked: &str) -> String { + saved_and_linked().replace("{{saved}}", saved).replace("{{linked}}", linked) +} diff --git a/crates/ui-egui/tests/code_font.rs b/crates/ui-egui/tests/code_font.rs new file mode 100644 index 0000000..4b4100d --- /dev/null +++ b/crates/ui-egui/tests/code_font.rs @@ -0,0 +1,26 @@ +//! The code font advances every character by the same amount (to the pixel grid): no ligature or pair +//! kerning opens a gap inside a URL or an operator. + +use egui::{Color32, Context, FontId, RawInput}; + +/// x of every glyph of `text` laid out in the monospace family, plus the end of the row. +fn xs(ctx: &Context, text: &str) -> Vec { + ctx.fonts_mut(|fonts| { + let galley = fonts.layout_no_wrap(text.to_owned(), FontId::monospace(14.0), Color32::WHITE); + let row = &galley.rows[0]; + row.glyphs.iter().map(|g| g.pos.x).chain(std::iter::once(row.size.x)).collect() + }) +} + +#[test] +fn urls_and_operators_keep_one_advance_per_character() { + let ctx = Context::default(); + notescraft_ui_egui::fonts::install(&ctx); + ctx.run_ui(RawInput::default(), |_| {}).textures_delta.clear(); + for text in ["https://publish.obsidian.md/username/about", "a != b -> c => d // e "] { + let xs = xs(&ctx, text); + let steps: Vec = xs.windows(2).map(|w| w[1] - w[0]).collect(); + let first = steps[0]; + assert!(steps.iter().all(|s| (s - first).abs() <= 1.0), "{text}: uneven advances {steps:?}"); + } +} diff --git a/crates/ui-egui/tests/paste_clipboard.rs b/crates/ui-egui/tests/paste_clipboard.rs new file mode 100644 index 0000000..0a27d30 --- /dev/null +++ b/crates/ui-egui/tests/paste_clipboard.rs @@ -0,0 +1,185 @@ +//! Paste from the clipboard into a note: the image lands in the note and in the vault, a file of the +//! vault is linked and never copied. + +use std::path::{Path, PathBuf}; + +use egui::{Context, Event, Key, Modifiers, Pos2, RawInput}; +use notescraft_engine::Session; +use notescraft_ui_egui::app::NotesApp; +use notescraft_ui_egui::attachment_drop::{Clipboard, ClipboardImage, ClipboardSource}; +use notescraft_ui_egui::center::ViewMode; + +/// A clipboard the test hands to the app. +struct FakeClipboard { + /// What the clipboard holds. + content: Clipboard, +} + +impl ClipboardSource for FakeClipboard { + fn read(&self) -> Result { + Ok(self.content.clone()) + } +} + +/// A clipboard image of `width` x `height` pixels. +fn image(width: usize, height: usize) -> ClipboardImage { + let mut bytes = Vec::with_capacity(width * height * 4); + for _ in 0..width * height { + bytes.extend_from_slice(&[10, 20, 30, 255]); + } + ClipboardImage { width, height, bytes } +} + +/// The app over a fresh vault with one note, and the vault folder. +struct Driver { + /// The app under test. + app: NotesApp, + /// The egui context the app draws in. + ctx: Context, + /// The clock of the frames. + time: f64, + /// The vault folder. + root: PathBuf, +} + +impl Driver { + /// One frame with the events it gets. + fn frame(&mut self, events: Vec) { + self.time += 0.05; + let input = RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1440.0, 900.0))), + time: Some(self.time), + events, + ..Default::default() + }; + let app = &mut self.app; + let mut output = self.ctx.run_ui(input, |ui| app.ui(ui.ctx())); + output.textures_delta.clear(); + } + + /// The frames the app needs before it draws: the first one installs the fonts. + fn settle(&mut self) { + self.frame(Vec::new()); + self.frame(Vec::new()); + } + + /// One frame with the paste shortcut, then the frames that carry the answer + /// of the clipboard read. + fn paste(&mut self) { + let event = Event::Key { key: Key::V, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }; + self.frame(vec![event]); + for _ in 0..20 { + std::thread::sleep(std::time::Duration::from_millis(5)); + self.frame(Vec::new()); + if self.text() != "Hello" { + break; + } + } + } + + /// The text of a note. + fn text(&self) -> String { + self.app.session.buffer("note.md").map(notescraft_engine::Buffer::text).unwrap_or_default().to_owned() + } + + /// The files of the attachment folder. + fn attachments(&self) -> Vec { + files(&self.root.join("Attachments")) + } +} + +/// The app over a fresh vault with one note. +fn driver(name: &str) -> Option { + let root = std::env::temp_dir().join(format!("notescraft-paste-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).ok()?; + std::fs::write(root.join("note.md"), "Hello").ok()?; + let mut session = Session::open(&root).ok()?; + session.use_settings_dir(root.join("config")); + let theme = notescraft_themes::builtin("catppuccin-mocha")?; + let mut app = NotesApp::new(session, theme); + app.state.animations = false; + app.open_note("note.md"); + Some(Driver { app, ctx: Context::default(), time: 1.0, root }) +} + +/// The files of a folder, sorted. +fn files(folder: &Path) -> Vec { + let mut names: Vec = + std::fs::read_dir(folder).map(|entries| entries.flatten().map(|entry| entry.file_name().to_string_lossy().into_owned()).collect()).unwrap_or_default(); + names.sort(); + names +} + +#[test] +fn a_pasted_image_lands_in_the_note_and_in_the_vault() { + let Some(mut driver) = driver("image") else { return }; + driver.app.attachment_input.set_clipboard(std::sync::Arc::new(FakeClipboard { content: Clipboard::Image(image(2, 2)) })); + driver.settle(); + driver.paste(); + let note = driver.text(); + assert!(note.contains("![[Pasted image "), "{note}"); + assert!(note.contains(".png]]"), "{note}"); + let saved = driver.attachments(); + assert_eq!(saved.len(), 1, "{saved:?}"); + let name = saved.first().cloned().unwrap_or_default(); + assert!(name.starts_with("Pasted image "), "{name}"); + assert!(Path::new(&name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case("png")), "{name}"); + let bytes = std::fs::read(driver.root.join("Attachments").join(name)).unwrap_or_default(); + assert_eq!(bytes.get(..4), Some(&[0x89, b'P', b'N', b'G'][..])); +} + +#[test] +fn a_pasted_image_lands_in_the_source_editor_too() { + let Some(mut driver) = driver("source") else { return }; + if let Some(edit) = driver.app.editing.tabs.get_mut("note.md") { + edit.view = ViewMode::Markdown; + } + driver.app.attachment_input.set_clipboard(std::sync::Arc::new(FakeClipboard { content: Clipboard::Image(image(2, 2)) })); + driver.settle(); + driver.paste(); + let note = driver.text(); + assert!(note.contains("![[Pasted image "), "{note}"); + assert_eq!(driver.attachments().len(), 1); +} + +#[test] +fn a_file_of_the_vault_is_linked_and_never_copied() { + let Some(mut driver) = driver("inside") else { return }; + let folder = driver.root.join("Attachments"); + std::fs::create_dir_all(&folder).ok(); + std::fs::write(folder.join("pic.png"), b"picture").ok(); + let inside = std::fs::canonicalize(&driver.root).unwrap_or_else(|_| driver.root.clone()).join("Attachments/pic.png"); + driver.app.attachment_input.set_clipboard(std::sync::Arc::new(FakeClipboard { content: Clipboard::Files(vec![inside]) })); + driver.settle(); + driver.paste(); + let note = driver.text(); + assert!(note.contains("![[Attachments/pic.png]]"), "{note}"); + assert_eq!(files(&folder), vec!["pic.png".to_owned()]); +} + +#[test] +fn a_file_outside_the_vault_is_copied_into_the_attachment_folder() { + let Some(mut driver) = driver("outside") else { return }; + let folder = std::env::temp_dir().join(format!("notescraft-paste-outside-files-{}", std::process::id())); + std::fs::create_dir_all(&folder).ok(); + let outside = folder.join("paper.png"); + std::fs::write(&outside, b"picture").ok(); + driver.app.attachment_input.set_clipboard(std::sync::Arc::new(FakeClipboard { content: Clipboard::Files(vec![outside.clone()]) })); + driver.settle(); + driver.paste(); + let note = driver.text(); + assert!(note.contains("![[paper.png]]"), "{note}"); + assert!(driver.root.join("Attachments/paper.png").exists(), "the file was not copied into the vault"); + let _ = std::fs::remove_dir_all(&folder); +} + +#[test] +fn a_text_only_clipboard_leaves_the_note_alone() { + let Some(mut driver) = driver("text") else { return }; + driver.app.attachment_input.set_clipboard(std::sync::Arc::new(FakeClipboard { content: Clipboard::Empty })); + driver.settle(); + driver.paste(); + assert_eq!(driver.text(), "Hello"); + assert!(!driver.root.join("Attachments").exists()); +}