feat: paste images and files from the clipboard, more image formats, scripts without boxes, code font without ligatures
arboard (image-data) approved for clipboard paste: image bytes become a PNG attachment, copied files inside the vault are linked without copying, outside files are copied; works in Visual and Source. image crate gains jpeg, gif, webp, bmp decoders. Lazy system-font fallbacks for 28 scripts (Bengali, Indic, Thai, CJK, Khmer...) and the math font as last resort. JetBrains Mono copy with calt emptied (SIL OFL, renamed). Big embedded images are shrunk to the texture limit off the UI thread (fixes a crash). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ NotesCraft is a native, Obsidian-compatible notes app written in **Rust only** (
|
||||
- One crate per concern in `crates/<name>`, package `notescraft-<name>`, `[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)
|
||||
|
||||
|
||||
@@ -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) | <https://github.com/rsms/inter> | SIL OFL 1.1, `assets/fonts/OFL-Inter.txt` |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono (monospace) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | 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 | <https://github.com/JetBrains/JetBrainsMono> | 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 | <https://github.com/stipub/stixfonts> (tag 2.13) | SIL OFL 1.1, `assets/fonts/OFL-STIXTwoMath.txt` |
|
||||
| `assets/icons/*.svg` | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`) | ISC, `assets/icons/LICENSE-lucide.txt` |
|
||||
|
||||
|
||||
Generated
+32
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Binary file not shown.
@@ -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"] }
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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<Item = &'a str>) {
|
||||
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<u64>, read: impl Fn(&str) -> Option<Vec<u8>>) -> bool {
|
||||
let Some(state) = ctx.data(|data| data.get_temp::<Arc<Mutex<State>>>(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
|
||||
}
|
||||
@@ -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)] },
|
||||
];
|
||||
@@ -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<u64>) -> Option<Pick> {
|
||||
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<u64> {
|
||||
std::fs::metadata(path).ok().map(|meta| meta.len())
|
||||
}
|
||||
@@ -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::<Arc<Mutex<State>>>(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::<Arc<Mutex<State>>>(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"));
|
||||
}
|
||||
}
|
||||
@@ -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<ColorImage> {
|
||||
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)
|
||||
}
|
||||
@@ -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<u8>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf>),
|
||||
}
|
||||
|
||||
/// 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<Clipboard, String>;
|
||||
}
|
||||
|
||||
/// The clipboard of the operating system.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct SystemClipboard;
|
||||
|
||||
impl ClipboardSource for SystemClipboard {
|
||||
fn read(&self) -> Result<Clipboard, String> {
|
||||
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<Clipboard, String>;
|
||||
|
||||
/// 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<dyn ClipboardSource>) -> Receiver<Answer> {
|
||||
let (sender, answer): (Sender<Answer>, Receiver<Answer>) = 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<Request>,
|
||||
/// The names of the files `NotesCraft` cannot attach.
|
||||
pub unsupported: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<Pasted, String> {
|
||||
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<Request, String> {
|
||||
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<String> {
|
||||
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<Vec<u8>, 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;
|
||||
@@ -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<String>,
|
||||
/// How many times the clipboard was read.
|
||||
reads: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
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<AtomicUsize> {
|
||||
Arc::clone(&self.reads)
|
||||
}
|
||||
}
|
||||
|
||||
impl ClipboardSource for FakeClipboard {
|
||||
fn read(&self) -> Result<Clipboard, String> {
|
||||
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<Request> {
|
||||
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<Request> {
|
||||
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<Request> {
|
||||
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<Request> {
|
||||
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<Request> {
|
||||
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<Request> {
|
||||
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<u8> {
|
||||
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);
|
||||
}
|
||||
@@ -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<Arc<[u8]>>,
|
||||
/// 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<Mutex<Shrink>>,
|
||||
}
|
||||
|
||||
/// 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<Option<egui::ColorImage>>),
|
||||
/// The shrunk picture, or `None` when it cannot be read.
|
||||
Done(Option<Arc<Raster>>),
|
||||
}
|
||||
|
||||
#[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<egui::Image<'static>> {
|
||||
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<Arc<Raster>> {
|
||||
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<String, Image>,
|
||||
entries: BTreeMap<String, Image>,
|
||||
/// Targets of the pictures, in the order of the note.
|
||||
order: Vec<String>,
|
||||
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<Self, String> {
|
||||
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 `<img>` 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::<egui::Color32>()));
|
||||
@@ -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::<String>(selected)) else { return false };
|
||||
let Some(image) = self.images.get(&target) else {
|
||||
ctx.data_mut(|data| data.remove::<String>(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::<bool>(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::<String>(selected);
|
||||
data.remove::<bool>(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<f32>)> {
|
||||
let (width, height) = size.split_once('x').map_or((size, None), |(width, height)| (width, Some(height)));
|
||||
let parse = |value: &str| value.parse::<f32>().ok().filter(|value| value.is_finite() && *value > 0.0 && *value <= MAX_DIMENSION);
|
||||
|
||||
@@ -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<String>,
|
||||
/// The clipboard a paste reads; the system clipboard is read by default.
|
||||
clipboard: Arc<dyn ClipboardSource>,
|
||||
/// The read in flight, when one is.
|
||||
reading: Option<Receiver<clipboard::Answer>>,
|
||||
/// The requests already made for the paste in flight: the text form and
|
||||
/// the file list of one file are attached once.
|
||||
pasted: Vec<Request>,
|
||||
/// Whether the `V` key was seen going down, to spot a paste the window layer swallowed.
|
||||
paste_key_down: bool,
|
||||
target: Option<Rect>,
|
||||
}
|
||||
|
||||
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<dyn ClipboardSource>) {
|
||||
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<Request> {
|
||||
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<Request> {
|
||||
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<Request> {
|
||||
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: 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<Request>) {
|
||||
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<Request>) {
|
||||
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<clipboard::Answer> {
|
||||
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<Request, String> {
|
||||
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<Request, String> {
|
||||
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<String> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<String> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<Request> {
|
||||
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::<String>(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<Images> {
|
||||
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<egui::Rect> {
|
||||
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#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>"#).is_ok());
|
||||
let doc = notescraft_markdown::parse("  <img src=\"tiny.svg\" width=\"20\">\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:?}");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<f32> {
|
||||
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 <!-- f -->"] {
|
||||
let xs = xs(&ctx, text);
|
||||
let steps: Vec<f32> = 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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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<Clipboard, String> {
|
||||
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<Event>) {
|
||||
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<String> {
|
||||
files(&self.root.join("Attachments"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The app over a fresh vault with one note.
|
||||
fn driver(name: &str) -> Option<Driver> {
|
||||
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<String> {
|
||||
let mut names: Vec<String> =
|
||||
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());
|
||||
}
|
||||
Reference in New Issue
Block a user