From 9921578ef568e8f7da1ac8c53afc0d5b28b7d723 Mon Sep 17 00:00:00 2001 From: Kevin Carlson Date: Mon, 13 Jul 2026 06:03:12 -0400 Subject: [PATCH 1/3] Fit-and-finish batch: first-load perf, ribbon/format redesign, layout + selection fixes Windows first open / loading screen: - SharedFontResources (loki-layout): the system-font scan (DirectWrite on Windows) runs on a background thread at app launch instead of synchronously inside EditorInner's first mount hook, so the loading view paints and the first document open no longer freezes. Editor consumers block only on worker threads; render paths use a non-blocking try_lock. Font-substitution warning inverted: - The status-bar chip is now the always-on indicator; clicking it opens the detail panel above the ribbon (banner removed). Ribbon restructure + color picker: - New "Format" tab (span-level formatting) hosting Font colour + Highlight as picker triggers; Write keeps Document/History/Styles/Paragraph/Inline. - Font size +/- buttons and the Write-tab alignment group removed (alignment lives in the paragraph style editor); editor_font_size/editor_alignment modules deleted. - New appthere-ui color picker (AtColorPickerTrigger + docked AtColorPickerPanel): preset swatches, per-session recent colours, custom Hex/RGB/HSL/HSV/CMYK entry (font colour only; highlight is constrained to the named HighlightColor palette - TODO(highlight-custom-color)). Docked above the ribbon: popovers inside the ribbon row are clipped by its scroll container (COMPAT note in the module docs). Window + app naming + Home: - Window titles "Loki Text"/"Loki Calc"/"Loki Slides", 1280x800 default, last session's inner size persisted (loki-app-shell window_geometry + AtWindowSizeSensor); Android labels + cargo-bundle metadata updated. - Home mounts AtViewportWidthSensor so it reflows before any document opens. Save notification: - save_message typed as SaveStatus: successes are an auto-clearing (4s, also cleared on dirty) status-bar chip; errors keep the persistent banner. Layout/editing fixes: - A continuous section break that changes the page size is promoted to a page break (Word fidelity) - the ACID doc now lays out to 19 pages and the A4 portrait section gets its own correctly-sized page. - Caret page selection at page breaks now requires the line to fully fit the page's content band (line-centre heuristic misattributed the first line after a break to the previous page). - Hanging-indent regression tests strengthened to compare first-line text vs continuation lines (wide-bullet case included); the reported Iris misalignment does not reproduce against the current engine. Selection: - Paginated selection highlights now paint: per-paragraph SelectionSpans with per-fragment transforms, clipped per page (drag + Shift+arrow selection state already worked but was invisible). Teardrop handles paint at the selection edges on Android. Android Save As: - Vendored loki-file-access into patches/ ([patch] in root Cargo.toml, docs/patches.md): pick_save no longer aborts when takePersistableUriPermission throws after ACTION_CREATE_DOCUMENT already created the file (stranding a blank file); grant is best-effort, pending JNI exception cleared, real display name queried. Also fixes two pre-existing clippy::similar_names failures (loki-ooxml, loki-odf) that blocked the workspace clippy gate. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 - Cargo.toml | 12 + android/app/src/main/AndroidManifest.xml | 4 +- .../src/components/color_picker/convert.rs | 123 + .../src/components/color_picker/custom.rs | 267 ++ .../src/components/color_picker/mod.rs | 278 +++ appthere-ui/src/components/icons.rs | 7 + appthere-ui/src/components/mod.rs | 4 + appthere-ui/src/components/status_bar.rs | 66 +- .../src/components/status_bar_chips.rs | 85 + appthere-ui/src/lib.rs | 22 +- appthere-ui/src/responsive/mod.rs | 2 + appthere-ui/src/responsive/size_sensor.rs | 75 + docs/patches.md | 28 + loki-app-shell/src/lib.rs | 1 + loki-app-shell/src/window_geometry.rs | 110 + loki-i18n/i18n/en-US/editor.ftl | 9 +- loki-i18n/i18n/en-US/ribbon.ftl | 33 +- loki-layout/src/font_handle.rs | 167 ++ loki-layout/src/layout_entry.rs | 11 +- loki-layout/src/lib.rs | 2 + loki-layout/tests/hanging_indent_tests.rs | 67 + loki-layout/tests/multi_section_tests.rs | 83 + loki-odf/src/odt/mapper/props/tests.rs | 8 +- loki-ooxml/src/docx/omml/read_structs.rs | 6 +- loki-presentation/AndroidManifest.xml | 4 +- loki-presentation/Cargo.toml | 8 +- loki-presentation/src/app.rs | 13 + loki-presentation/src/main.rs | 18 +- loki-renderer/src/page_paint_render.rs | 170 +- loki-spreadsheet/AndroidManifest.xml | 4 +- loki-spreadsheet/Cargo.toml | 8 +- loki-spreadsheet/src/app.rs | 13 + loki-spreadsheet/src/main.rs | 17 +- loki-text/AndroidManifest.xml | 4 +- loki-text/Cargo.toml | 8 +- loki-text/src/app.rs | 14 + loki-text/src/editing/page_locate.rs | 45 +- loki-text/src/editing/page_locate_tests.rs | 42 + loki-text/src/editing/state.rs | 36 +- loki-text/src/editing/state_apply.rs | 2 +- loki-text/src/lib.rs | 1 + loki-text/src/main.rs | 14 +- .../src/routes/editor/editor_alignment.rs | 38 - .../src/routes/editor/editor_color_panel.rs | 118 + loki-text/src/routes/editor/editor_dirty.rs | 13 + .../src/routes/editor/editor_font_size.rs | 100 - .../routes/editor/editor_font_size_tests.rs | 80 - .../src/routes/editor/editor_font_warning.rs | 77 +- loki-text/src/routes/editor/editor_fonts.rs | 72 + loki-text/src/routes/editor/editor_inner.rs | 173 +- .../routes/editor/editor_metadata_panel.rs | 5 +- .../src/routes/editor/editor_path_sync.rs | 10 +- loki-text/src/routes/editor/editor_publish.rs | 39 +- loki-text/src/routes/editor/editor_ribbon.rs | 28 +- .../src/routes/editor/editor_ribbon_color.rs | 311 ++- .../src/routes/editor/editor_ribbon_format.rs | 108 +- .../src/routes/editor/editor_ribbon_insert.rs | 9 +- .../editor/editor_ribbon_insert_image.rs | 24 +- .../routes/editor/editor_ribbon_publish.rs | 3 +- .../src/routes/editor/editor_ribbon_span.rs | 48 + .../src/routes/editor/editor_ribbon_table.rs | 13 +- .../editor/editor_ribbon_table_tests.rs | 14 +- .../src/routes/editor/editor_save_banner.rs | 66 +- .../routes/editor/editor_save_callbacks.rs | 34 +- loki-text/src/routes/editor/editor_spell.rs | 2 +- loki-text/src/routes/editor/editor_state.rs | 66 +- .../src/routes/editor/editor_style_catalog.rs | 15 +- .../editor/editor_style_editor/actions.rs | 8 +- .../editor/editor_style_editor/char_form.rs | 3 +- .../routes/editor/editor_style_editor/form.rs | 3 +- .../routes/editor/editor_style_editor/mod.rs | 3 +- .../editor/editor_style_editor/table_form.rs | 3 +- loki-text/src/routes/editor/mod.rs | 5 +- loki-text/src/routes/home.rs | 8 + loki-text/src/window_state.rs | 44 + loki-vello/src/lib.rs | 4 +- loki-vello/src/scene.rs | 27 +- loki-vello/src/scene_single_page.rs | 26 + patches/loki-file-access/.cargo-ok | 0 .../.github/workflows/rust.yml | 22 + patches/loki-file-access/.gitignore | 21 + patches/loki-file-access/Cargo.lock | 2186 +++++++++++++++++ patches/loki-file-access/Cargo.toml | 53 + patches/loki-file-access/LICENSE | 21 + patches/loki-file-access/README.md | 112 + .../android/FilePickerActivity.java | 141 ++ .../android/ImeInsetsListener.java | 63 + patches/loki-file-access/build.rs | 192 ++ .../docs/adr/0001-runtime-agnostic-future.md | 58 + .../docs/adr/0002-mit-license.md | 58 + patches/loki-file-access/src/api.rs | 166 ++ patches/loki-file-access/src/error.rs | 209 ++ patches/loki-file-access/src/future.rs | 182 ++ patches/loki-file-access/src/lib.rs | 81 + .../src/platform/android/jni_activity.rs | 205 ++ .../src/platform/android/jni_common.rs | 29 + .../src/platform/android/jni_fd.rs | 147 ++ .../src/platform/android/jni_ime.rs | 149 ++ .../src/platform/android/jni_insets.rs | 294 +++ .../src/platform/android/jni_intents.rs | 247 ++ .../src/platform/android/mod.rs | 384 +++ .../src/platform/desktop/filters.rs | 153 ++ .../src/platform/desktop/mod.rs | 420 ++++ .../src/platform/desktop/zenity.rs | 121 + .../src/platform/ios/bookmark.rs | 118 + .../loki-file-access/src/platform/ios/mod.rs | 132 + patches/loki-file-access/src/platform/mod.rs | 65 + patches/loki-file-access/src/platform/wasm.rs | 191 ++ patches/loki-file-access/src/token.rs | 412 ++++ patches/loki-file-access/tests/desktop.rs | 131 + 111 files changed, 9382 insertions(+), 873 deletions(-) create mode 100644 appthere-ui/src/components/color_picker/convert.rs create mode 100644 appthere-ui/src/components/color_picker/custom.rs create mode 100644 appthere-ui/src/components/color_picker/mod.rs create mode 100644 appthere-ui/src/components/status_bar_chips.rs create mode 100644 appthere-ui/src/responsive/size_sensor.rs create mode 100644 loki-app-shell/src/window_geometry.rs create mode 100644 loki-layout/src/font_handle.rs delete mode 100644 loki-text/src/routes/editor/editor_alignment.rs create mode 100644 loki-text/src/routes/editor/editor_color_panel.rs delete mode 100644 loki-text/src/routes/editor/editor_font_size.rs delete mode 100644 loki-text/src/routes/editor/editor_font_size_tests.rs create mode 100644 loki-text/src/routes/editor/editor_fonts.rs create mode 100644 loki-text/src/routes/editor/editor_ribbon_span.rs create mode 100644 loki-text/src/window_state.rs create mode 100644 patches/loki-file-access/.cargo-ok create mode 100644 patches/loki-file-access/.github/workflows/rust.yml create mode 100644 patches/loki-file-access/.gitignore create mode 100644 patches/loki-file-access/Cargo.lock create mode 100644 patches/loki-file-access/Cargo.toml create mode 100644 patches/loki-file-access/LICENSE create mode 100644 patches/loki-file-access/README.md create mode 100644 patches/loki-file-access/android/FilePickerActivity.java create mode 100644 patches/loki-file-access/android/ImeInsetsListener.java create mode 100644 patches/loki-file-access/build.rs create mode 100644 patches/loki-file-access/docs/adr/0001-runtime-agnostic-future.md create mode 100644 patches/loki-file-access/docs/adr/0002-mit-license.md create mode 100644 patches/loki-file-access/src/api.rs create mode 100644 patches/loki-file-access/src/error.rs create mode 100644 patches/loki-file-access/src/future.rs create mode 100644 patches/loki-file-access/src/lib.rs create mode 100644 patches/loki-file-access/src/platform/android/jni_activity.rs create mode 100644 patches/loki-file-access/src/platform/android/jni_common.rs create mode 100644 patches/loki-file-access/src/platform/android/jni_fd.rs create mode 100644 patches/loki-file-access/src/platform/android/jni_ime.rs create mode 100644 patches/loki-file-access/src/platform/android/jni_insets.rs create mode 100644 patches/loki-file-access/src/platform/android/jni_intents.rs create mode 100644 patches/loki-file-access/src/platform/android/mod.rs create mode 100644 patches/loki-file-access/src/platform/desktop/filters.rs create mode 100644 patches/loki-file-access/src/platform/desktop/mod.rs create mode 100644 patches/loki-file-access/src/platform/desktop/zenity.rs create mode 100644 patches/loki-file-access/src/platform/ios/bookmark.rs create mode 100644 patches/loki-file-access/src/platform/ios/mod.rs create mode 100644 patches/loki-file-access/src/platform/mod.rs create mode 100644 patches/loki-file-access/src/platform/wasm.rs create mode 100644 patches/loki-file-access/src/token.rs create mode 100644 patches/loki-file-access/tests/desktop.rs diff --git a/Cargo.lock b/Cargo.lock index 659e0695..c43260cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4510,7 +4510,6 @@ dependencies = [ [[package]] name = "loki-file-access" version = "0.1.3" -source = "git+https://github.com/appthere/loki-file-access?branch=main#d2b7bc5c0a0f1703d1df0d083a9c5d660ddcb2cb" dependencies = [ "base64", "jni 0.21.1", diff --git a/Cargo.toml b/Cargo.toml index 84465f53..7ecf7a69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,18 @@ zeroize = { version = "1", features = ["derive"] } jsonwebtoken = "9" base64 = "0.22" +# PATCH: Android Save As — `pick_save` treated a takePersistableUriPermission +# failure (SecurityException from providers that grant no persistable +# permission on ACTION_CREATE_DOCUMENT results) as fatal, AFTER the OS had +# already created the (blank) document: Save As stranded a 0-byte file and +# errored while plain Save worked. The patch makes the persistable grant +# best-effort (warn + continue; the session grant suffices for the write), +# clears the pending JNI exception, and queries the real display name (the +# user may rename in the create dialog; the name drives format detection). +# Remove when upstream loki-file-access ships the fix. See docs/patches.md. +[patch."https://github.com/appthere/loki-file-access"] +loki-file-access = { path = "patches/loki-file-access" } + [patch.crates-io] # PATCH: vendors dioxus-native-dom to avoid runtime unimplemented!() panics in # HtmlEventConverter (composition, touch, scroll, etc.) — remove when upstream diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 51ee023c..474e5198 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,14 +13,14 @@ Gradle compiles and dexes it automatically (no manual javac/d8 step). --> Option<(u8, u8, u8)> { + let hex = s.trim().trim_start_matches('#'); + if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some((r, g, b)) +} + +/// Formats RGB bytes as an uppercase `#RRGGBB` string. +pub fn rgb_to_hex(r: u8, g: u8, b: u8) -> String { + format!("#{r:02X}{g:02X}{b:02X}") +} + +fn channel(v: f32) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +/// Converts HSL (hue 0–360, saturation/lightness 0–100) to RGB bytes. +pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) { + let h = h.rem_euclid(360.0); + let s = (s / 100.0).clamp(0.0, 1.0); + let l = (l / 100.0).clamp(0.0, 1.0); + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let (r1, g1, b1) = hue_sector(h, c); + let m = l - c / 2.0; + (channel(r1 + m), channel(g1 + m), channel(b1 + m)) +} + +/// Converts HSV (hue 0–360, saturation/value 0–100) to RGB bytes. +pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) { + let h = h.rem_euclid(360.0); + let s = (s / 100.0).clamp(0.0, 1.0); + let v = (v / 100.0).clamp(0.0, 1.0); + let c = v * s; + let (r1, g1, b1) = hue_sector(h, c); + let m = v - c; + (channel(r1 + m), channel(g1 + m), channel(b1 + m)) +} + +/// The shared hue-sector step of the HSL/HSV formulas: chroma distributed over +/// the two dominant channels for `h`'s 60° sector. +fn hue_sector(h: f32, c: f32) -> (f32, f32, f32) { + let x = c * (1.0 - ((h / 60.0).rem_euclid(2.0) - 1.0).abs()); + match (h / 60.0) as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + } +} + +/// Converts CMYK (each 0–100) to RGB bytes via the naive complement formula. +pub fn cmyk_to_rgb(c: f32, m: f32, y: f32, k: f32) -> (u8, u8, u8) { + let c = (c / 100.0).clamp(0.0, 1.0); + let m = (m / 100.0).clamp(0.0, 1.0); + let y = (y / 100.0).clamp(0.0, 1.0); + let k = (k / 100.0).clamp(0.0, 1.0); + ( + channel((1.0 - c) * (1.0 - k)), + channel((1.0 - m) * (1.0 - k)), + channel((1.0 - y) * (1.0 - k)), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_round_trip_and_forgiving_parse() { + assert_eq!(parse_hex("#C0392B"), Some((0xC0, 0x39, 0x2B))); + assert_eq!(parse_hex("c0392b"), Some((0xC0, 0x39, 0x2B))); + assert_eq!(parse_hex(" #c0392b "), Some((0xC0, 0x39, 0x2B))); + assert_eq!(parse_hex("#c0392"), None); + assert_eq!(parse_hex("#c0392g"), None); + assert_eq!(rgb_to_hex(0xC0, 0x39, 0x2B), "#C0392B"); + } + + #[test] + fn hsl_primaries_and_greys() { + assert_eq!(hsl_to_rgb(0.0, 100.0, 50.0), (255, 0, 0)); + assert_eq!(hsl_to_rgb(120.0, 100.0, 50.0), (0, 255, 0)); + assert_eq!(hsl_to_rgb(240.0, 100.0, 50.0), (0, 0, 255)); + assert_eq!(hsl_to_rgb(0.0, 0.0, 100.0), (255, 255, 255)); + assert_eq!(hsl_to_rgb(0.0, 0.0, 0.0), (0, 0, 0)); + // 360 wraps to 0; out-of-range saturation clamps. + assert_eq!(hsl_to_rgb(360.0, 150.0, 50.0), (255, 0, 0)); + } + + #[test] + fn hsv_primaries_and_value_scale() { + assert_eq!(hsv_to_rgb(0.0, 100.0, 100.0), (255, 0, 0)); + assert_eq!(hsv_to_rgb(120.0, 100.0, 100.0), (0, 255, 0)); + assert_eq!(hsv_to_rgb(240.0, 100.0, 100.0), (0, 0, 255)); + assert_eq!(hsv_to_rgb(0.0, 0.0, 100.0), (255, 255, 255)); + assert_eq!(hsv_to_rgb(60.0, 100.0, 50.0), (128, 128, 0)); + } + + #[test] + fn cmyk_complements() { + assert_eq!(cmyk_to_rgb(0.0, 0.0, 0.0, 0.0), (255, 255, 255)); + assert_eq!(cmyk_to_rgb(0.0, 0.0, 0.0, 100.0), (0, 0, 0)); + assert_eq!(cmyk_to_rgb(100.0, 0.0, 0.0, 0.0), (0, 255, 255)); + assert_eq!(cmyk_to_rgb(0.0, 100.0, 100.0, 0.0), (255, 0, 0)); + } +} diff --git a/appthere-ui/src/components/color_picker/custom.rs b/appthere-ui/src/components/color_picker/custom.rs new file mode 100644 index 00000000..b5afcee8 --- /dev/null +++ b/appthere-ui/src/components/color_picker/custom.rs @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Custom-colour entry section of [`AtColorPicker`](super::AtColorPicker): +//! a colour-model selector (Hex / RGB / HSL / HSV / CMYK), the model's input +//! fields, a live preview swatch, and an Apply button. +//! +//! The model names are universal technical abbreviations, kept as named +//! constants rather than translated strings (per the design-system string +//! conventions); every prose label arrives via props. + +use dioxus::prelude::*; + +use super::convert::{cmyk_to_rgb, hsl_to_rgb, hsv_to_rgb, parse_hex, rgb_to_hex}; +use crate::tokens; + +/// The supported colour-entry models. Labels are technical abbreviations. +#[derive(Clone, Copy, PartialEq)] +enum Mode { + Hex, + Rgb, + Hsl, + Hsv, + Cmyk, +} + +const MODES: &[(Mode, &str)] = &[ + (Mode::Hex, "Hex"), + (Mode::Rgb, "RGB"), + (Mode::Hsl, "HSL"), + (Mode::Hsv, "HSV"), + (Mode::Cmyk, "CMYK"), +]; + +/// `(field labels, default values)` for a mode. At most four fields are used; +/// unused entries are empty. +fn fields_for(mode: Mode) -> &'static [&'static str] { + match mode { + Mode::Hex => &["#"], + Mode::Rgb => &["R", "G", "B"], + Mode::Hsl => &["H", "S", "L"], + Mode::Hsv => &["H", "S", "V"], + Mode::Cmyk => &["C", "M", "Y", "K"], + } +} + +/// Parses the current field values under `mode` into RGB bytes. +fn resolve(mode: Mode, f: &[String; 4]) -> Option<(u8, u8, u8)> { + let num = |s: &String| s.trim().parse::().ok().filter(|v| v.is_finite()); + match mode { + Mode::Hex => parse_hex(&f[0]), + Mode::Rgb => { + let (r, g, b) = (num(&f[0])?, num(&f[1])?, num(&f[2])?); + let in_range = |v: f32| (0.0..=255.0).contains(&v); + (in_range(r) && in_range(g) && in_range(b)) + .then(|| (r.round() as u8, g.round() as u8, b.round() as u8)) + } + Mode::Hsl => Some(hsl_to_rgb(num(&f[0])?, num(&f[1])?, num(&f[2])?)), + Mode::Hsv => Some(hsv_to_rgb(num(&f[0])?, num(&f[1])?, num(&f[2])?)), + Mode::Cmyk => Some(cmyk_to_rgb( + num(&f[0])?, + num(&f[1])?, + num(&f[2])?, + num(&f[3])?, + )), + } +} + +fn input_style(width_px: f32) -> String { + format!( + "width: {width_px}px; padding: {pv}px {ph}px; background: {bg}; \ + color: {fg}; border: 1px solid {border}; border-radius: {r}px; \ + font-family: {ff}; font-size: {fs}px;", + pv = tokens::SPACE_1, + ph = tokens::SPACE_1, + bg = tokens::COLOR_SURFACE_3, + fg = tokens::COLOR_TEXT_ON_CHROME, + border = tokens::COLOR_BORDER_CHROME, + r = tokens::RADIUS_SM, + ff = tokens::FONT_FAMILY_UI, + fs = tokens::FONT_SIZE_LABEL, + ) +} + +/// The custom-colour entry section. +/// +/// # Touch target +/// +/// The mode buttons and Apply button are text buttons with padded hit areas; +/// the section sits inside the picker popover whose row heights keep each +/// interactive element within a ≥44×44 logical-pixel touch target (WCAG 2.5.8). +#[component] +pub(super) fn CustomColorSection( + /// Section heading (translated), e.g. "Custom". + heading: String, + /// Apply button label (translated). + apply_label: String, + /// Called with the resolved `#RRGGBB` when Apply is pressed. + on_apply: EventHandler, +) -> Element { + let mut mode = use_signal(|| Mode::Hex); + let mut fields = use_signal(|| [const { String::new() }; 4]); + + let resolved = resolve(mode(), &fields.read()); + let preview = resolved.map(|(r, g, b)| rgb_to_hex(r, g, b)); + + let heading_style = format!( + "font-size: {fs}px; color: {fg};", + fs = tokens::FONT_SIZE_XS, + fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY, + ); + let mode_btn = |active: bool| { + format!( + "padding: {pv}px {ph}px; background: {bg}; color: {fg}; cursor: pointer; \ + border: 1px solid {border}; border-radius: {r}px; \ + font-family: {ff}; font-size: {fs}px;", + pv = tokens::SPACE_1, + ph = tokens::SPACE_1, + bg = if active { + tokens::COLOR_TAB_ACTIVE_BG + } else { + "transparent" + }, + fg = if active { + tokens::COLOR_TEXT_ACCENT + } else { + tokens::COLOR_TEXT_ON_CHROME + }, + border = if active { + tokens::COLOR_TAB_ACTIVE_INDICATOR + } else { + tokens::COLOR_BORDER_CHROME + }, + r = tokens::RADIUS_SM, + ff = tokens::FONT_FAMILY_UI, + fs = tokens::FONT_SIZE_XS, + ) + }; + + rsx! { + div { + style: format!("display: flex; flex-direction: column; gap: {}px;", tokens::SPACE_2), + span { style: "{heading_style}", "{heading}" } + + // Colour-model selector. + div { + style: format!("display: flex; flex-direction: row; gap: {}px;", tokens::SPACE_1), + for (m, label) in MODES.iter().copied() { + button { + key: "{label}", + style: mode_btn(mode() == m), + aria_pressed: if mode() == m { "true" } else { "false" }, + onclick: move |_| { + if *mode.peek() != m { + mode.set(m); + fields.set([const { String::new() }; 4]); + } + }, + "{label}" + } + } + } + + // The active model's input fields. + div { + style: format!( + "display: flex; flex-direction: row; align-items: center; gap: {}px;", + tokens::SPACE_1, + ), + for (i, label) in fields_for(mode()).iter().copied().enumerate() { + div { + key: "{label}", + style: format!( + "display: flex; flex-direction: row; align-items: center; gap: {}px;", + tokens::SPACE_1, + ), + span { style: "{heading_style}", "{label}" } + input { + r#type: "text", + value: "{fields.read()[i]}", + oninput: move |evt| { fields.write()[i] = evt.value(); }, + style: input_style(if mode() == Mode::Hex { 96.0 } else { 40.0 }), + } + } + } + } + + // Live preview + Apply. + div { + style: format!( + "display: flex; flex-direction: row; align-items: center; gap: {}px;", + tokens::SPACE_2, + ), + div { + style: format!( + "width: 22px; height: 22px; border-radius: {r}px; background: {bg}; \ + border: 1px solid {border};", + r = tokens::RADIUS_SM, + bg = preview.clone().unwrap_or_else(|| "transparent".to_string()), + border = tokens::COLOR_BORDER_CHROME, + ), + } + button { + style: format!( + "padding: {pv}px {ph}px; background: {bg}; color: {fg}; \ + border: 1px solid {border}; border-radius: {r}px; cursor: pointer; \ + font-family: {ff}; font-size: {fs}px;", + pv = tokens::SPACE_1, + ph = tokens::SPACE_3, + bg = tokens::COLOR_SURFACE_3, + fg = if preview.is_some() { + tokens::COLOR_TEXT_ON_CHROME + } else { + tokens::COLOR_ICON_DISABLED + }, + border = tokens::COLOR_BORDER_CHROME, + r = tokens::RADIUS_SM, + ff = tokens::FONT_FAMILY_UI, + fs = tokens::FONT_SIZE_LABEL, + ), + disabled: preview.is_none(), + onclick: move |_| { + if let Some((r, g, b)) = resolve(*mode.peek(), &fields.peek()) { + on_apply.call(rgb_to_hex(r, g, b)); + } + }, + "{apply_label}" + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn f(vals: [&str; 4]) -> [String; 4] { + vals.map(str::to_string) + } + + #[test] + fn resolve_per_mode() { + assert_eq!( + resolve(Mode::Hex, &f(["#00FF00", "", "", ""])), + Some((0, 255, 0)) + ); + assert_eq!( + resolve(Mode::Rgb, &f(["255", "0", "0", ""])), + Some((255, 0, 0)) + ); + assert_eq!(resolve(Mode::Rgb, &f(["300", "0", "0", ""])), None); + assert_eq!( + resolve(Mode::Hsl, &f(["240", "100", "50", ""])), + Some((0, 0, 255)) + ); + assert_eq!( + resolve(Mode::Hsv, &f(["0", "0", "100", ""])), + Some((255, 255, 255)) + ); + assert_eq!( + resolve(Mode::Cmyk, &f(["0", "100", "100", "0"])), + Some((255, 0, 0)) + ); + assert_eq!(resolve(Mode::Rgb, &f(["", "0", "0", ""])), None); + assert_eq!(resolve(Mode::Hex, &f(["not-a-color", "", "", ""])), None); + } +} diff --git a/appthere-ui/src/components/color_picker/mod.rs b/appthere-ui/src/components/color_picker/mod.rs new file mode 100644 index 00000000..d5a7a8ea --- /dev/null +++ b/appthere-ui/src/components/color_picker/mod.rs @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Colour-picking controls: [`AtColorPickerTrigger`] (a ribbon button with a +//! live colour-indicator bar) and [`AtColorPickerPanel`] (preset swatches, the +//! caller's recent colours, and an optional custom-colour entry — Hex / RGB / +//! HSL / HSV / CMYK). +//! +//! The two are separate components because the ribbon content row is a scroll +//! container (`overflow-x: auto; overflow-y: hidden`). +//! COMPAT(dioxus-native): an absolutely-positioned popover inside that row is +//! clipped and never paints, so the panel must be hosted *outside* the ribbon +//! — apps dock it in the flex column above the ribbon, the same posture as +//! their other panels — while the trigger stays in the ribbon group and +//! toggles the panel's open state. +//! +//! The picker is value-agnostic: swatch `value`s are opaque strings the caller +//! interprets (hex for text colour, named variants for highlight), and "recent +//! colours" are supplied — and recorded — by the caller via `on_pick`. + +use dioxus::prelude::*; + +use super::ribbon::AtRibbonIconButton; +use crate::tokens; + +pub mod convert; +mod custom; + +use custom::CustomColorSection; + +/// Swatches per panel row (6 × 44 px buttons keeps the grid compact). +const SWATCHES_PER_ROW: usize = 6; + +/// One selectable colour: the opaque `value` reported on pick, the CSS `fill` +/// shown in the swatch square, and its accessible name. +#[derive(Clone, PartialEq)] +pub struct AtColorSwatch { + /// Opaque value reported to `on_pick` (e.g. a hex string or variant name). + pub value: String, + /// CSS colour painted in the swatch square. + pub fill: String, + /// Accessible name of the swatch button. + pub aria_label: String, +} + +/// Translated prose labels for the panel's sections and actions. +#[derive(Clone, PartialEq)] +pub struct AtColorPickerLabels { + /// Panel heading (e.g. "Font colour"). + pub title: String, + /// Accessible name of the panel's close button. + pub close: String, + /// The "clear / automatic / none" action label. + pub clear: String, + /// Heading of the recent-colours section. + pub recent_heading: String, + /// Heading of the custom-colour section. + pub custom_heading: String, + /// Apply button label of the custom-colour section. + pub apply: String, +} + +/// A small filled square used inside swatch buttons. +fn square(fill: &str) -> Element { + rsx! { + div { + style: format!( + "width: 18px; height: 18px; border-radius: {r}px; \ + background: {fill}; border: 1px solid rgba(0,0,0,0.25);", + r = tokens::RADIUS_SM, + ), + } + } +} + +/// Ribbon trigger button for a colour picker: the caller's glyph stacked above +/// an indicator bar showing the currently applied colour. +/// +/// # Touch target +/// +/// An [`AtRibbonIconButton`] — 44×44 logical pixels (WCAG 2.5.8). +#[component] +pub fn AtColorPickerTrigger( + /// Accessible name of the trigger button. + aria_label: String, + /// CSS colour shown in the indicator bar; `None` renders the "no override" + /// outline. + current_fill: Option, + /// Whether the associated panel is open (renders the button active). + is_open: bool, + /// Fired on click; the caller toggles its panel-open state. + on_toggle: EventHandler<()>, + /// Trigger glyph (e.g. an icon), stacked above the indicator bar. + children: Element, +) -> Element { + let indicator = match ¤t_fill { + Some(fill) => { + format!("width: 18px; height: 4px; border-radius: 1px; background: {fill};") + } + None => format!( + "width: 18px; height: 4px; border-radius: 1px; background: transparent; \ + border: 1px solid {};", + tokens::COLOR_TEXT_ON_CHROME_SECONDARY, + ), + }; + rsx! { + AtRibbonIconButton { + aria_label: aria_label, + is_active: is_open, + is_disabled: false, + on_click: move |_| on_toggle.call(()), + div { + style: "display: flex; flex-direction: column; align-items: center; gap: 2px;", + {children} + div { style: "{indicator}" } + } + } + } +} + +/// The colour-picker panel: clear action + preset swatch grid, the caller's +/// recent colours, and (optionally) the custom-colour entry. In-flow — the app +/// docks it above the ribbon. +/// +/// # Touch target +/// +/// Every swatch is an [`AtRibbonIconButton`] (44×44 logical pixels); the clear +/// row and close button carry ≥44 px hit areas via `min-height`/padding +/// (WCAG 2.5.8). +#[component] +pub fn AtColorPickerPanel( + /// The currently applied value (drives the active swatch highlight). + current_value: Option, + /// Preset swatches, rendered [`SWATCHES_PER_ROW`] per row. + swatches: Vec, + /// The caller's recent colours, most recent first (section hidden if empty). + recent: Vec, + /// Whether to render the custom-colour entry section. + show_custom: bool, + /// Translated section/action labels. + labels: AtColorPickerLabels, + /// Fired with `Some(value)` for a pick and `None` for clear. Recording the + /// pick into the recent list is the caller's job. + on_pick: EventHandler>, + /// Fired by the close button. + on_close: EventHandler<()>, +) -> Element { + let heading_style = format!( + "font-size: {fs}px; color: {fg};", + fs = tokens::FONT_SIZE_XS, + fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY, + ); + let text_btn = format!( + "display: flex; flex-direction: row; align-items: center; gap: {gap}px; \ + padding: {pv}px {ph}px; min-height: {touch}px; background: transparent; \ + border: 1px solid {border}; border-radius: {r}px; cursor: pointer; \ + color: {fg}; font-family: {ff}; font-size: {fs}px;", + gap = tokens::SPACE_2, + pv = tokens::SPACE_1, + ph = tokens::SPACE_2, + touch = tokens::TOUCH_MIN, + border = tokens::COLOR_BORDER_CHROME, + r = tokens::RADIUS_SM, + fg = tokens::COLOR_TEXT_ON_CHROME, + ff = tokens::FONT_FAMILY_UI, + fs = tokens::FONT_SIZE_LABEL, + ); + + rsx! { + div { + // In-flow band above the ribbon, styled like the app's other panels. + style: format!( + "display: flex; flex-direction: column; gap: {gap}px; \ + padding: {pv}px {ph}px; background: {bg}; \ + border-top: 1px solid {border}; border-bottom: 1px solid {border}; \ + font-family: {ff}; color: {fg}; flex-shrink: 0;", + gap = tokens::SPACE_2, + pv = tokens::SPACE_2, + ph = tokens::SPACE_4, + bg = tokens::COLOR_SURFACE_2, + border = tokens::COLOR_BORDER_CHROME, + ff = tokens::FONT_FAMILY_UI, + fg = tokens::COLOR_TEXT_ON_CHROME, + ), + + // Header: title + close. + div { + style: "display: flex; flex-direction: row; align-items: center; gap: 8px;", + span { + style: format!("font-weight: bold; font-size: {}px;", tokens::FONT_SIZE_BODY), + "{labels.title}" + } + div { style: "flex: 1;" } + button { + style: "{text_btn}", + aria_label: labels.close.clone(), + onclick: move |_| on_close.call(()), + "\u{2715}" + } + } + + // Sections side by side; each column is narrow. + div { + style: format!( + "display: flex; flex-direction: row; align-items: flex-start; gap: {}px;", + tokens::SPACE_6, + ), + + // Clear + preset grid. + div { + style: format!( + "display: flex; flex-direction: column; gap: {}px;", + tokens::SPACE_2, + ), + button { + style: "{text_btn}", + onclick: move |_| on_pick.call(None), + {square("transparent")} + "{labels.clear}" + } + {swatch_rows(&swatches, ¤t_value, on_pick)} + } + + // Recent colours. + if !recent.is_empty() { + div { + style: format!( + "display: flex; flex-direction: column; gap: {}px;", + tokens::SPACE_2, + ), + span { style: "{heading_style}", "{labels.recent_heading}" } + {swatch_rows(&recent, ¤t_value, on_pick)} + } + } + + // Custom entry. + if show_custom { + CustomColorSection { + heading: labels.custom_heading.clone(), + apply_label: labels.apply.clone(), + on_apply: move |hex: String| on_pick.call(Some(hex)), + } + } + } + } + } +} + +/// Renders `swatches` as rows of [`SWATCHES_PER_ROW`] swatch buttons. +fn swatch_rows( + swatches: &[AtColorSwatch], + current_value: &Option, + pick: EventHandler>, +) -> Element { + rsx! { + div { + style: "display: flex; flex-direction: column; gap: 2px;", + for (row_idx, row) in swatches.chunks(SWATCHES_PER_ROW).enumerate() { + div { + key: "{row_idx}", + style: "display: flex; flex-direction: row; gap: 2px;", + for sw in row.iter().cloned() { + AtRibbonIconButton { + key: "{sw.value}", + aria_label: sw.aria_label.clone(), + is_active: current_value.as_deref() == Some(sw.value.as_str()), + is_disabled: false, + on_click: { + let value = sw.value.clone(); + move |_| pick.call(Some(value.clone())) + }, + {square(&sw.fill)} + } + } + } + } + } + } +} diff --git a/appthere-ui/src/components/icons.rs b/appthere-ui/src/components/icons.rs index 2c583df3..d76b027f 100644 --- a/appthere-ui/src/components/icons.rs +++ b/appthere-ui/src/components/icons.rs @@ -125,6 +125,13 @@ pub const AT_FONT_GROW: &str = "M6 15 10 7 14 15M7.5 12h5M18 8v7M15.5 10.5 18 8 /// Decrease font size: an "A" with a downward arrow. pub const AT_FONT_SHRINK: &str = "M6 15 10 7 14 15M7.5 12h5M18 8v7M15.5 12.5 18 15 20.5 12.5"; +/// Lucide `baseline` — an "A" over a baseline rule. Font-colour picker trigger. +pub const LUCIDE_BASELINE: &str = "M4 20h16M6 16 12 4 18 16M8 12h8"; + +/// Lucide `highlighter` — a marker pen. Highlight-colour picker trigger. +pub const LUCIDE_HIGHLIGHTER: &str = + "M9 11l-6 6v3h9l3-3M22 12l-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"; + // App-custom page-orientation glyphs (not Lucide): a tall vs. wide page rect. /// Portrait orientation: a tall page rectangle. diff --git a/appthere-ui/src/components/mod.rs b/appthere-ui/src/components/mod.rs index f28f431f..1d95b2ce 100644 --- a/appthere-ui/src/components/mod.rs +++ b/appthere-ui/src/components/mod.rs @@ -5,6 +5,7 @@ //! All components are application-agnostic — they must not reference any //! application-specific route enum, document model, or business logic. +pub mod color_picker; pub mod confirm_dialog; pub mod document_tab; pub mod home_tab; @@ -19,6 +20,9 @@ pub mod template_browser; pub mod title_bar; pub mod zoom; +pub use color_picker::{ + AtColorPickerLabels, AtColorPickerPanel, AtColorPickerTrigger, AtColorSwatch, +}; pub use confirm_dialog::{AtConfirmDialog, AtConfirmDialogProps}; pub use document_tab::{AtDocumentTab, AtDocumentTabProps}; pub use home_tab::{AtHomeTab, AtHomeTabProps, BuiltinTemplate, RecentDocument}; diff --git a/appthere-ui/src/components/status_bar.rs b/appthere-ui/src/components/status_bar.rs index 2fee3713..95050c0c 100644 --- a/appthere-ui/src/components/status_bar.rs +++ b/appthere-ui/src/components/status_bar.rs @@ -13,6 +13,9 @@ use crate::tokens::layout::STATUS_BAR_HEIGHT; use crate::tokens::spacing::{RADIUS_SM, SPACE_1, SPACE_2, SPACE_4, TOUCH_MIN}; use crate::tokens::typography::{FONT_FAMILY_UI, FONT_SIZE_XS, FONT_WEIGHT_MEDIUM}; +#[path = "status_bar_chips.rs"] +mod chips; + // ── AtStatusBar ─────────────────────────────────────────────────────────────── /// Bottom application status bar. @@ -43,7 +46,7 @@ pub fn AtStatusBar(props: AtStatusBarProps) -> Element { } else { palette.surface_3 }; - let mut notice_hovered = use_signal(|| false); + let notice_hovered = use_signal(|| false); let notice_bg = if notice_hovered() { "#444444" } else { @@ -101,36 +104,27 @@ pub fn AtStatusBar(props: AtStatusBarProps) -> Element { } } - // Optional status notice chip (e.g. recover a dismissed warning). - // Border + background only — no box-shadow (Blitz constraint). - // Hit area: full bar height × ≥ TOUCH_MIN wide (see component doc). + // Optional status notice chip (e.g. recover a dismissed warning) and + // the transient status chip ("Document saved") — split into + // `status_bar_chips.rs` for the 300-line ceiling. if show_notice { - button { - "aria-label": props.notice_aria_label.clone(), - style: format!( - "{hit} background: transparent; border: none; cursor: pointer;", - hit = hit_area_style(), - ), - onmouseenter: move |_| { notice_hovered.set(true); }, - onmouseleave: move |_| { notice_hovered.set(false); }, - onclick: move |_| { props.on_notice_click.call(()); }, - span { - style: format!( - "background: {bg}; border: 1px solid {border}; border-radius: {r}px; \ - color: {fg}; font-size: {size}px; font-weight: {weight}; \ - padding: {pv}px {ph}px;", - bg = notice_bg, - border = palette.contextual_tab, - r = RADIUS_SM, - fg = palette.text_on_chrome_secondary, - size = FONT_SIZE_XS, - weight = FONT_WEIGHT_MEDIUM, - pv = SPACE_1, - ph = SPACE_2, - ), - "⚠ {props.notice_label}" - } - } + {chips::notice_chip( + props.notice_label.clone(), + props.notice_aria_label.clone(), + notice_bg, + &palette, + hit_area_style(), + notice_hovered, + props.on_notice_click, + )} + } + if !props.status_note_label.is_empty() { + {chips::status_note_chip( + props.status_note_label.clone(), + &palette, + hit_area_style(), + props.on_status_note_click, + )} } // Flex spacer — pushes right-side content to the far right @@ -288,4 +282,16 @@ pub struct AtStatusBarProps { /// Callback invoked when the notice chip is clicked. #[props(default)] pub on_notice_click: Callback<()>, + + /// Optional transient status chip (e.g. "Document saved"). Empty (the + /// default) hides it. The app owns the message's lifetime — auto-clearing + /// and clear-on-edit live in the caller; clicking the chip dismisses it. + /// + /// Touch target: ≥ `TOUCH_MIN` wide × full bar height. + #[props(default)] + pub status_note_label: String, + + /// Callback invoked when the status chip is clicked (dismiss). + #[props(default)] + pub on_status_note_click: Callback<()>, } diff --git a/appthere-ui/src/components/status_bar_chips.rs b/appthere-ui/src/components/status_bar_chips.rs new file mode 100644 index 00000000..7f5c6d07 --- /dev/null +++ b/appthere-ui/src/components/status_bar_chips.rs @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The status bar's left-cluster chips (notice + transient status), split from +//! `status_bar.rs` for the 300-line ceiling. Plain render helpers — no hooks — +//! so `AtStatusBar` owns the hover signals and calls these unconditionally. + +use dioxus::prelude::*; + +use crate::tokens::spacing::{RADIUS_SM, SPACE_1, SPACE_2}; +use crate::tokens::typography::{FONT_SIZE_XS, FONT_WEIGHT_MEDIUM}; +use crate::tokens::ThemePalette; + +/// The warning-coloured notice chip (e.g. "N fonts substituted"). +/// +/// Touch target: the transparent button uses the shared `hit_area` style +/// (≥ 44 px wide × full bar height); the visible chip is the nested span. +#[allow(clippy::too_many_arguments)] +pub(super) fn notice_chip( + label: String, + aria_label: String, + bg: &'static str, + palette: &ThemePalette, + hit_area: String, + mut hovered: Signal, + on_click: Callback<()>, +) -> Element { + rsx! { + button { + "aria-label": aria_label, + style: format!("{hit_area} background: transparent; border: none; cursor: pointer;"), + onmouseenter: move |_| { hovered.set(true); }, + onmouseleave: move |_| { hovered.set(false); }, + onclick: move |_| { on_click.call(()); }, + span { + style: format!( + "background: {bg}; border: 1px solid {border}; border-radius: {r}px; \ + color: {fg}; font-size: {size}px; font-weight: {weight}; \ + padding: {pv}px {ph}px;", + bg = bg, + border = palette.contextual_tab, + r = RADIUS_SM, + fg = palette.text_on_chrome_secondary, + size = FONT_SIZE_XS, + weight = FONT_WEIGHT_MEDIUM, + pv = SPACE_1, + ph = SPACE_2, + ), + "⚠ {label}" + } + } + } +} + +/// The neutral transient status chip (e.g. "Document saved"). The app owns the +/// message's lifetime (auto-clear / clear-on-edit); clicking dismisses. +/// +/// Touch target: same convention as [`notice_chip`]. +pub(super) fn status_note_chip( + label: String, + palette: &ThemePalette, + hit_area: String, + on_click: Callback<()>, +) -> Element { + rsx! { + button { + "aria-label": label.clone(), + style: format!("{hit_area} background: transparent; border: none; cursor: pointer;"), + onclick: move |_| { on_click.call(()); }, + span { + style: format!( + "background: {bg}; border: 1px solid {border}; border-radius: {r}px; \ + color: {fg}; font-size: {size}px; padding: {pv}px {ph}px;", + bg = palette.surface_3, + border = palette.border_chrome, + r = RADIUS_SM, + fg = palette.text_on_chrome_secondary, + size = FONT_SIZE_XS, + pv = SPACE_1, + ph = SPACE_2, + ), + "{label}" + } + } + } +} diff --git a/appthere-ui/src/lib.rs b/appthere-ui/src/lib.rs index bc620198..2b74f71d 100644 --- a/appthere-ui/src/lib.rs +++ b/appthere-ui/src/lib.rs @@ -38,10 +38,11 @@ pub use components::icons::{ AT_PAGE_LETTER, AT_PAGE_PORTRAIT, AT_TABLE_COL_DELETE, AT_TABLE_COL_INSERT, AT_TABLE_COL_INSERT_LEFT, AT_TABLE_ROW_DELETE, AT_TABLE_ROW_INSERT, AT_TABLE_ROW_INSERT_ABOVE, AT_TOC_INSERT, AT_TOC_UPDATE, AT_TRACK_CHANGES, LUCIDE_ALIGN_CENTER, LUCIDE_ALIGN_JUSTIFY, - LUCIDE_ALIGN_LEFT, LUCIDE_ALIGN_RIGHT, LUCIDE_BOLD, LUCIDE_DOWNLOAD, LUCIDE_FOOTNOTE, - LUCIDE_IMAGE, LUCIDE_ITALIC, LUCIDE_LAYOUT_TEMPLATE, LUCIDE_LINK, LUCIDE_MORE_HORIZONTAL, - LUCIDE_PILCROW, LUCIDE_REDO, LUCIDE_SAVE, LUCIDE_STRIKETHROUGH, LUCIDE_SUBSCRIPT, - LUCIDE_SUPERSCRIPT, LUCIDE_TABLE, LUCIDE_TRASH_2, LUCIDE_UNDERLINE, LUCIDE_UNDO, + LUCIDE_ALIGN_LEFT, LUCIDE_ALIGN_RIGHT, LUCIDE_BASELINE, LUCIDE_BOLD, LUCIDE_DOWNLOAD, + LUCIDE_FOOTNOTE, LUCIDE_HIGHLIGHTER, LUCIDE_IMAGE, LUCIDE_ITALIC, LUCIDE_LAYOUT_TEMPLATE, + LUCIDE_LINK, LUCIDE_MORE_HORIZONTAL, LUCIDE_PILCROW, LUCIDE_REDO, LUCIDE_SAVE, + LUCIDE_STRIKETHROUGH, LUCIDE_SUBSCRIPT, LUCIDE_SUPERSCRIPT, LUCIDE_TABLE, LUCIDE_TRASH_2, + LUCIDE_UNDERLINE, LUCIDE_UNDO, }; pub use components::ribbon::{ AtRibbon, AtRibbonGroup, AtRibbonGroups, AtRibbonIconButton, AtRibbonSelect, RibbonGroupSpec, @@ -49,16 +50,17 @@ pub use components::ribbon::{ }; pub use components::{ next_zoom, use_backdrop, use_provide_backdrop, AtBackdropContext, AtBackdropHost, - AtConfirmDialog, AtConfirmDialogProps, AtDocumentTab, AtDocumentTabData, AtDocumentTabProps, - AtHomeTab, AtHomeTabProps, AtPanelHost, AtPanelHostProps, AtStatusBar, AtStatusBarProps, - AtTabBar, AtTabBarProps, AtTemplateBrowser, AtTemplateBrowserProps, AtTitleBar, - AtTitleBarProps, BuiltinTemplate, PanelPosture, Platform, RecentDocument, BACKDROP_Z_INDEX, + AtColorPickerLabels, AtColorPickerPanel, AtColorPickerTrigger, AtColorSwatch, AtConfirmDialog, + AtConfirmDialogProps, AtDocumentTab, AtDocumentTabData, AtDocumentTabProps, AtHomeTab, + AtHomeTabProps, AtPanelHost, AtPanelHostProps, AtStatusBar, AtStatusBarProps, AtTabBar, + AtTabBarProps, AtTemplateBrowser, AtTemplateBrowserProps, AtTitleBar, AtTitleBarProps, + BuiltinTemplate, PanelPosture, Platform, RecentDocument, BACKDROP_Z_INDEX, }; pub use responsive::{ estimate_group_metrics, group_layout, page_fits, required_page_width, resolve_cascade, resolve_page_fit, use_breakpoint, use_provide_responsive, use_responsive, use_ribbon_cascade, - use_viewport, AtResponsiveContext, AtViewportWidthSensor, Breakpoint, GroupCollapse, - GroupLayout, GroupMetrics, PageFit, RibbonCascade, Viewport, DEFAULT_DPI, + use_viewport, AtResponsiveContext, AtViewportWidthSensor, AtWindowSizeSensor, Breakpoint, + GroupCollapse, GroupLayout, GroupMetrics, PageFit, RibbonCascade, Viewport, DEFAULT_DPI, }; pub use safe_area::{set_safe_area_insets, update_safe_area_insets, use_safe_area, SafeAreaInsets}; pub use theme::{use_theme, AtThemeContext, ThemeVariant}; diff --git a/appthere-ui/src/responsive/mod.rs b/appthere-ui/src/responsive/mod.rs index 26f498e7..ac7675da 100644 --- a/appthere-ui/src/responsive/mod.rs +++ b/appthere-ui/src/responsive/mod.rs @@ -30,6 +30,7 @@ mod breakpoint; mod page_fit; mod ribbon_collapse; +mod size_sensor; mod viewport; mod width_sensor; @@ -39,6 +40,7 @@ pub use ribbon_collapse::{ estimate_group_metrics, group_layout, resolve_cascade, GroupCollapse, GroupLayout, GroupMetrics, RibbonCascade, }; +pub use size_sensor::AtWindowSizeSensor; pub use viewport::{Viewport, DEFAULT_DPI}; pub use width_sensor::AtViewportWidthSensor; diff --git a/appthere-ui/src/responsive/size_sensor.rs b/appthere-ui/src/responsive/size_sensor.rs new file mode 100644 index 00000000..3ba0391b --- /dev/null +++ b/appthere-ui/src/responsive/size_sensor.rs @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! [`AtWindowSizeSensor`] — reports the app root's logical size to the caller +//! (e.g. for persisting window dimensions across sessions). + +use dioxus::prelude::*; + +/// Invisible probes that measure the app root's width **and** height: once at +/// mount, and again on every shell `resync_scroll_geometry` tick (the blitz +/// shell re-emits `onscroll` to every scroll container after a window resize), +/// reporting `(width, height)` in logical pixels whenever either changes by +/// more than half a pixel. +/// +/// Mount inside the app's root element (which must be `position: relative` and +/// span the window, as the standard shell root does). The probes are +/// absolutely positioned and zero-area — one full-width × zero-height, one +/// zero-width × full-height — so they never affect flow or intercept input. +/// +/// Not an interactive element (zero-size, no pointer targets), so the 44 px +/// touch-target convention does not apply. +#[component] +pub fn AtWindowSizeSensor( + /// Called with the root's `(width, height)` in logical pixels on mount and + /// after each observed change. + on_size: EventHandler<(f64, f64)>, +) -> Element { + let mut width_mounted = use_signal(|| Option::::None); + let mut height_mounted = use_signal(|| Option::::None); + let last = use_signal(|| (0.0_f64, 0.0_f64)); + + let measure = move || { + let (Some(w_evt), Some(h_evt)) = + (width_mounted.peek().clone(), height_mounted.peek().clone()) + else { + return; + }; + let mut last = last; + spawn(async move { + let (Ok(w_rect), Ok(h_rect)) = + (w_evt.get_client_rect().await, h_evt.get_client_rect().await) + else { + return; + }; + let size = (w_rect.size.width, h_rect.size.height); + let (pw, ph) = *last.peek(); + if size.0 > 0.0 + && size.1 > 0.0 + && ((size.0 - pw).abs() > 0.5 || (size.1 - ph).abs() > 0.5) + { + last.set(size); + on_size.call(size); + } + }); + }; + + rsx! { + div { + style: "position: absolute; top: 0; left: 0; width: 100%; height: 0px; overflow: auto;", + onmounted: move |e| { + width_mounted.set(Some(e)); + measure(); + }, + onscroll: move |_| measure(), + } + div { + style: "position: absolute; top: 0; left: 0; width: 0px; height: 100%; overflow: auto;", + onmounted: move |e| { + height_mounted.set(Some(e)); + measure(); + }, + onscroll: move |_| measure(), + } + } +} diff --git a/docs/patches.md b/docs/patches.md index e45546f5..16bb8fcf 100644 --- a/docs/patches.md +++ b/docs/patches.md @@ -14,6 +14,34 @@ temporary and has a documented removal condition. ## Active patches +### loki-file-access — 0.1.3 (git `main` @ `d2b7bc5`) + +**Source:** `patches/loki-file-access/` (local), vendored 2026-07-13 from +[appthere/loki-file-access](https://github.com/appthere/loki-file-access) +commit `d2b7bc5`. Wired via +`[patch."https://github.com/appthere/loki-file-access"]` in the root +`Cargo.toml`. + +**Android Save As fix (PATCH(loki)).** `pick_save` treated a +`takePersistableUriPermission` failure as fatal (`?`), **after** +`ACTION_CREATE_DOCUMENT` had already created the target document — providers +that grant no persistable permission on create results throw +`SecurityException`, so Save As stranded a freshly-created **blank** file and +surfaced an error while the write itself would have succeeded (and plain Save, +which never re-creates, kept working). The patch: + +- makes the persistable grant **best-effort** (`tracing::warn!` + continue — + the session grant from the create result is sufficient for the write; + persistence only affects reopening after an app restart); +- clears the pending JNI exception on that failure so subsequent JNI calls on + the thread keep working; +- queries the created document's **real display name** (the user may rename in + the create dialog) instead of trusting `suggested_name` — the name drives + format detection on export. + +**Removal condition:** upstream `loki-file-access` ships the equivalent fix; +then drop the `[patch]` entry and `patches/loki-file-access/`. + ### dioxus-native-dom — 0.7.9 **Version pin:** the whole dioxus family is pinned to `=0.7.9` in the root diff --git a/loki-app-shell/src/lib.rs b/loki-app-shell/src/lib.rs index 5bddedce..c4c86a8b 100644 --- a/loki-app-shell/src/lib.rs +++ b/loki-app-shell/src/lib.rs @@ -27,6 +27,7 @@ pub mod recent_documents; pub mod spell; pub mod tabs; pub mod untitled; +pub mod window_geometry; pub use untitled::{ NewDocSource, UNTITLED_SCHEME, import_path, is_untitled, parse_new_doc_source, template_path, diff --git a/loki-app-shell/src/window_geometry.rs b/loki-app-shell/src/window_geometry.rs new file mode 100644 index 00000000..2310a54e --- /dev/null +++ b/loki-app-shell/src/window_geometry.rs @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Persisted window size — the desktop window opens at the size the user last +//! left it (position is not persisted: the shell does not observe move events). +//! +//! Mirrors the [`crate::recent_documents`] persistence idiom: each application +//! supplies its own relative file name under the platform data directory, and +//! load/save fail silently (a missing or corrupt file just yields the default). + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Smallest believable persisted dimension — anything below this is treated as +/// corrupt and ignored (a window this small is unusable). +const MIN_DIMENSION_PX: f64 = 320.0; + +/// Largest believable persisted dimension. +const MAX_DIMENSION_PX: f64 = 16384.0; + +/// A window's logical inner size, persisted across sessions. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct WindowGeometry { + /// Logical (DPI-independent) inner width in pixels. + pub width: f64, + /// Logical (DPI-independent) inner height in pixels. + pub height: f64, +} + +impl WindowGeometry { + /// A geometry from logical dimensions. + pub const fn new(width: f64, height: f64) -> Self { + Self { width, height } + } + + /// Loads the persisted geometry, or `None` when missing, unparsable, or + /// outside believable bounds (the caller falls back to its default). + pub fn load(geometry_file: &str) -> Option { + let g: Self = geometry_file_path(geometry_file) + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str(&s).ok())?; + let sane = |v: f64| (MIN_DIMENSION_PX..=MAX_DIMENSION_PX).contains(&v); + (sane(g.width) && sane(g.height)).then_some(g) + } + + /// Persists to the platform data directory. Errors are silently ignored + /// (disk full, read-only FS, etc.) — losing a window size is harmless. + pub fn save(&self, geometry_file: &str) { + let Some(path) = geometry_file_path(geometry_file) else { + return; + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(path, json); + } + } +} + +/// Schedules a debounced save of `size` to `geometry_file`. Each call +/// supersedes the previous pending one (an interactive resize emits a stream +/// of intermediate sizes; only the settled one is written), and the write +/// happens on a worker thread, never on the UI thread. +pub fn save_debounced(geometry_file: &'static str, size: (f64, f64)) { + use std::sync::atomic::{AtomicU64, Ordering}; + /// Debounce before persisting a new size. + const SAVE_DEBOUNCE_MS: u64 = 800; + static SEQ: AtomicU64 = AtomicU64::new(0); + let my_seq = SEQ.fetch_add(1, Ordering::Relaxed) + 1; + let _ = std::thread::Builder::new() + .name("loki-window-save".into()) + .spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(SAVE_DEBOUNCE_MS)); + if SEQ.load(Ordering::Relaxed) == my_seq { + WindowGeometry::new(size.0, size.1).save(geometry_file); + } + }); +} + +/// Resolves the absolute persistence path. Window geometry is a desktop +/// concern (Android windows are fullscreen), so unlike recent-documents this +/// does not consult the Android data-dir override — on platforms without a +/// data dir it simply resolves to `None` and persistence is a no-op. +fn geometry_file_path(geometry_file: &str) -> Option { + dirs::data_dir().map(|d| d.join(geometry_file)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn implausible_dimensions_are_rejected() { + let json = serde_json::to_string(&WindowGeometry::new(10.0, 10.0)).expect("serialize"); + let parsed: WindowGeometry = serde_json::from_str(&json).expect("parse"); + // The raw parse succeeds; the load-time validation is what rejects it. + let sane = |v: f64| (MIN_DIMENSION_PX..=MAX_DIMENSION_PX).contains(&v); + assert!(!(sane(parsed.width) && sane(parsed.height))); + } + + #[test] + fn round_trips_through_json() { + let g = WindowGeometry::new(1280.0, 800.0); + let json = serde_json::to_string(&g).expect("serialize"); + let back: WindowGeometry = serde_json::from_str(&json).expect("parse"); + assert_eq!(g, back); + } +} diff --git a/loki-i18n/i18n/en-US/editor.ftl b/loki-i18n/i18n/en-US/editor.ftl index 2d40c46f..53e8ceb9 100644 --- a/loki-i18n/i18n/en-US/editor.ftl +++ b/loki-i18n/i18n/en-US/editor.ftl @@ -41,18 +41,15 @@ editor-action-add-bullet = Add bullet editor-placeholder-title = Title editor-placeholder-subtitle = Subtitle -# Font warning banner +# Font substitution — status-bar chip (the indicator) + detail panel editor-font-substitution-title = Font Substitution editor-font-substitution-message = Some fonts in this document are not available and were substituted. Layout and formatting might differ from Office 365. -editor-font-substitution-download = Download original fonts: -editor-font-dismiss = Dismiss -# Compact chip + recovery indicator (one concise line) +# Status-bar indicator chip; clicking it opens the detail panel editor-font-substitution-chip = { $count -> [one] 1 font substituted *[other] { $count } fonts substituted } -editor-font-substitution-details = Details -editor-font-substitution-collapse = Hide +editor-font-substitution-close = Close # Per-substitution card / row editor-font-substitution-requested = Requested editor-font-substitution-substitute = Substitute diff --git a/loki-i18n/i18n/en-US/ribbon.ftl b/loki-i18n/i18n/en-US/ribbon.ftl index 9030c71a..a96b398b 100644 --- a/loki-i18n/i18n/en-US/ribbon.ftl +++ b/loki-i18n/i18n/en-US/ribbon.ftl @@ -4,6 +4,8 @@ # The ribbon's primary tab is "Write" (renamed from "Home" — Spec 04 D1 — to # resolve the collision with the application Home *screen*). ribbon-tab-write = Write +# Span-level (character) formatting: font colour and highlight pickers. +ribbon-tab-format = Format # Inline formatting group (Write tab) ribbon-group-inline = Inline formatting @@ -36,11 +38,17 @@ ribbon-style-apply-aria = Apply style: { $name } ribbon-group-paragraph = Paragraph ribbon-para-props-aria = Edit paragraph style ribbon-para-props-heading = Paragraph Properties -ribbon-group-font = Font -ribbon-font-grow-aria = Increase font size -ribbon-font-shrink-aria = Decrease font size + +# Format tab — Font colour and Highlight pickers ribbon-group-font-color = Font colour -ribbon-color-automatic-aria = Automatic colour +ribbon-font-color-picker-aria = Font colour +ribbon-highlight-picker-aria = Highlight colour +ribbon-color-clear = Automatic +ribbon-highlight-clear = No highlight +ribbon-color-recent = Recent +ribbon-color-custom = Custom +ribbon-color-apply = Apply +ribbon-color-close-aria = Close colour picker ribbon-color-red-aria = Red ribbon-color-orange-aria = Orange ribbon-color-yellow-aria = Yellow @@ -48,17 +56,22 @@ ribbon-color-green-aria = Green ribbon-color-blue-aria = Blue ribbon-color-purple-aria = Purple ribbon-group-highlight = Highlight -ribbon-highlight-none-aria = No highlight ribbon-highlight-yellow-aria = Yellow highlight ribbon-highlight-green-aria = Green highlight ribbon-highlight-cyan-aria = Cyan highlight ribbon-highlight-magenta-aria = Magenta highlight +ribbon-highlight-blue-aria = Blue highlight ribbon-highlight-red-aria = Red highlight -ribbon-group-alignment = Alignment -ribbon-align-left-aria = Align left -ribbon-align-centre-aria = Centre -ribbon-align-right-aria = Align right -ribbon-align-justify-aria = Justify +ribbon-highlight-dark-blue-aria = Dark blue highlight +ribbon-highlight-dark-cyan-aria = Dark cyan highlight +ribbon-highlight-dark-green-aria = Dark green highlight +ribbon-highlight-dark-magenta-aria = Dark magenta highlight +ribbon-highlight-dark-red-aria = Dark red highlight +ribbon-highlight-dark-yellow-aria = Dark yellow highlight +ribbon-highlight-dark-gray-aria = Dark grey highlight +ribbon-highlight-light-gray-aria = Light grey highlight +ribbon-highlight-black-aria = Black highlight +ribbon-highlight-white-aria = White highlight # Style editor panel ribbon-style-editor-heading = Edit Style diff --git a/loki-layout/src/font_handle.rs b/loki-layout/src/font_handle.rs new file mode 100644 index 00000000..7965712e --- /dev/null +++ b/loki-layout/src/font_handle.rs @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Warm-up-aware shared handle to [`FontResources`]. +//! +//! [`FontResources::new`] performs a full system-font scan (DirectWrite +//! enumeration on Windows), which can take long enough to freeze a UI frame. +//! [`SharedFontResources`] lets an application start that scan on a background +//! thread at launch ([`SharedFontResources::warm_up`]) and hand out a cheap +//! cloneable handle immediately. Consumers either block until the scan is done +//! ([`SharedFontResources::lock`], for worker threads) or observe readiness +//! without blocking ([`SharedFontResources::try_lock`], for render paths). + +use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock}; + +use crate::font::FontResources; + +/// Cheap cloneable handle to a lazily warmed, shared [`FontResources`]. +/// +/// All clones refer to the same underlying font context; the system-font scan +/// is paid at most once per handle family. +#[derive(Clone)] +pub struct SharedFontResources { + inner: Arc, +} + +struct Inner { + /// The built font context. Empty until the warm-up finishes (or the first + /// blocking `lock` builds it inline as a fallback). + slot: OnceLock>, + /// Warm-up completion flag + condvar for blocking waiters. Set even if the + /// warm-up thread dies without filling `slot`, so waiters never hang. + done: Mutex, + ready: Condvar, +} + +/// Marks the warm-up as finished when dropped — including on unwind — so a +/// panicking warm-up thread wakes waiters (which then build inline) instead of +/// deadlocking them. +struct DoneOnDrop(Arc); + +impl Drop for DoneOnDrop { + fn drop(&mut self) { + let mut done = self.0.done.lock().unwrap_or_else(|e| e.into_inner()); + *done = true; + self.0.ready.notify_all(); + } +} + +impl SharedFontResources { + fn empty() -> Self { + Self { + inner: Arc::new(Inner { + slot: OnceLock::new(), + done: Mutex::new(false), + ready: Condvar::new(), + }), + } + } + + /// Wraps an already-built [`FontResources`]; the handle is ready at once. + pub fn new_ready(resources: FontResources) -> Self { + let handle = Self::empty(); + let _ = handle.inner.slot.set(Mutex::new(resources)); + drop(DoneOnDrop(Arc::clone(&handle.inner))); + handle + } + + /// Starts building [`FontResources`] (system-font scan + family-index + /// population) on a background thread and returns immediately. + /// + /// If the thread cannot be spawned, the scan runs inline as a fallback. + pub fn warm_up() -> Self { + let handle = Self::empty(); + let inner = Arc::clone(&handle.inner); + let spawned = std::thread::Builder::new() + .name("loki-font-warmup".into()) + .spawn(move || { + let _done = DoneOnDrop(Arc::clone(&inner)); + let mut resources = FontResources::new(); + // Fontique populates its family-name index lazily; touch it here + // so the style editor's font enumeration is also prepaid. + let _ = resources.available_font_families(); + let _ = inner.slot.set(Mutex::new(resources)); + }); + if spawned.is_err() { + let _ = handle.inner.slot.set(Mutex::new(FontResources::new())); + drop(DoneOnDrop(Arc::clone(&handle.inner))); + } + handle + } + + /// Locks the shared font context, blocking until the warm-up has finished. + /// + /// Intended for worker threads (layout, export) and post-load paths where + /// the scan is long since complete. If the warm-up thread died without + /// producing a context, one is built inline here. + pub fn lock(&self) -> MutexGuard<'_, FontResources> { + if self.inner.slot.get().is_none() { + let mut done = self.inner.done.lock().unwrap_or_else(|e| e.into_inner()); + while !*done { + done = self + .inner + .ready + .wait(done) + .unwrap_or_else(|e| e.into_inner()); + } + } + self.inner + .slot + .get_or_init(|| Mutex::new(FontResources::new())) + .lock() + .unwrap_or_else(|e| e.into_inner()) + } + + /// Non-blocking lock: returns `None` while the warm-up is still running + /// *or* while another thread (e.g. a layout worker) holds the lock. + /// + /// Intended for render paths that must never stall a frame; callers treat + /// `None` as "no font information this frame" and pick the data up on a + /// later render. + pub fn try_lock(&self) -> Option> { + self.inner.slot.get()?.try_lock().ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_ready_is_immediately_lockable() { + let handle = SharedFontResources::new_ready(FontResources::new()); + assert!(handle.try_lock().is_some()); + let _guard = handle.lock(); + } + + #[test] + fn warm_up_lock_blocks_until_built_and_clones_share() { + let handle = SharedFontResources::warm_up(); + let clone = handle.clone(); + // Blocking lock from a worker thread must rendezvous with the warm-up. + // A family that certainly exists on no platform, so resolving it always + // records an entry in `substitutions`. + const MISSING: &str = "Loki Test Nonexistent Family"; + let joined = std::thread::spawn(move || { + let mut fr = clone.lock(); + fr.resolve_font_name(MISSING) + }) + .join() + .expect("worker thread panicked"); + assert!(!joined.is_empty()); + // The substitution recorded on the worker is visible through the + // original handle: both point at the same FontResources. + let fr = handle.lock(); + assert!(fr.substitutions.contains_key(MISSING)); + } + + #[test] + fn try_lock_yields_while_another_thread_holds_the_guard() { + let handle = SharedFontResources::new_ready(FontResources::new()); + let guard = handle.lock(); + assert!(handle.try_lock().is_none()); + drop(guard); + assert!(handle.try_lock().is_some()); + } +} diff --git a/loki-layout/src/layout_entry.rs b/loki-layout/src/layout_entry.rs index 98b060b9..9c54a950 100644 --- a/loki-layout/src/layout_entry.rs +++ b/loki-layout/src/layout_entry.rs @@ -142,10 +142,19 @@ pub fn layout_paginated_full( // first section and at every section that does *not* start `continuous`. A // `continuous` section continues on the previous group's last page (sharing // its page geometry + headers/footers), only switching column layout. + // + // Exception (Word fidelity): a `continuous` break that *changes the page + // size or orientation* cannot share the previous page — Word promotes it to + // a page break so the new geometry can take effect. Without this, e.g. an + // A4 continuous section after a Letter section is laid out on the Letter + // page (wrong geometry, one page short). let mut groups: Vec> = Vec::new(); for section in &doc.sections { match groups.last_mut() { - Some(last) if section.start == loki_doc_model::layout::SectionStart::Continuous => { + Some(last) + if section.start == loki_doc_model::layout::SectionStart::Continuous + && section.layout.page_size == last[0].layout.page_size => + { last.push(section); } _ => groups.push(vec![section]), diff --git a/loki-layout/src/lib.rs b/loki-layout/src/lib.rs index c2499a72..3d195d6b 100644 --- a/loki-layout/src/lib.rs +++ b/loki-layout/src/lib.rs @@ -26,6 +26,7 @@ pub mod color; pub mod error; pub mod flow; pub mod font; +pub mod font_handle; pub mod geometry; pub mod incremental; pub mod items; @@ -48,6 +49,7 @@ pub use color::LayoutColor; pub use error::{LayoutError, LayoutResult}; pub use flow::{FlowOutput, LayoutWarning, flow_section}; pub use font::FontResources; +pub use font_handle::SharedFontResources; pub use geometry::{LayoutInsets, LayoutPoint, LayoutRect, LayoutSize}; pub use incremental::{ FlowCheckpoint, PageStart, PaginatedReuse, document_has_notes, relayout_paginated_incremental, diff --git a/loki-layout/tests/hanging_indent_tests.rs b/loki-layout/tests/hanging_indent_tests.rs index 98c97a7f..0365a34d 100644 --- a/loki-layout/tests/hanging_indent_tests.rs +++ b/loki-layout/tests/hanging_indent_tests.rs @@ -308,3 +308,70 @@ fn numbered_continuation_aligns_with_hanging_text_start() { marker; got {delta}pt" ); } + +/// Extend a catalog with a wide-glyph bullet list "w" (U+25CF BLACK CIRCLE, the +/// Google-Docs export bullet) at the same geometry as the other lists. +fn with_wide_bullet(mut catalog: StyleCatalog) -> StyleCatalog { + catalog.list_styles.insert( + ListId::new("w"), + ListStyle { + id: ListId::new("w"), + display_name: None, + levels: vec![ListLevel { + level: 0, + kind: ListLevelKind::Bullet { + char: BulletChar::Char('\u{25CF}'), + font: None, + }, + indent_start: Points::new(INDENT_START), + hanging_indent: Points::new(HANGING), + label_alignment: LabelAlignment::Left, + tab_stop_after_label: None, + char_props: Default::default(), + }], + extensions: ExtensionBag::default(), + }, + ); + catalog +} + +/// The first line's **text** (not its marker) must start where the continuation +/// lines start — Word aligns wrapped lines under the text start. This is the +/// user-visible bug the older assertions missed: they compared the continuation +/// x against the *marker* x, so first-line text drifting right of +/// `indent_start` (a marker-tab overshooting to the default 36pt grid) passed. +#[test] +fn first_line_text_aligns_with_continuation_lines() { + for list in ["b", "w", "d"] { + let catalog = with_wide_bullet(list_catalog()); + let items = flow( + &catalog, + list_para( + "This is a fairly long list item whose text must wrap across \ + several lines so both line starts are measurable.", + list, + ), + ); + let origins = glyph_origins(&items); + let min_y = origins + .iter() + .map(|(_, y)| *y) + .fold(f32::INFINITY, f32::min); + // First line: the marker run is the leftmost; the text run(s) follow it. + let mut line0: Vec = origins + .iter() + .filter(|(_, y)| (*y - min_y).abs() < 1.0) + .map(|(x, _)| *x) + .collect(); + line0.sort_by(f32::total_cmp); + assert!(line0.len() >= 2, "list {list}: expected marker + text runs"); + let first_text_x = line0[1]; + let (_, continuation_x) = first_and_continuation_x(&origins); + let delta = first_text_x - continuation_x; + assert!( + delta.abs() < 2.0, + "list {list}: first-line text ({first_text_x}) must align with \ + continuation lines ({continuation_x}); delta = {delta}pt" + ); + } +} diff --git a/loki-layout/tests/multi_section_tests.rs b/loki-layout/tests/multi_section_tests.rs index 9aa604e7..6bbf5f61 100644 --- a/loki-layout/tests/multi_section_tests.rs +++ b/loki-layout/tests/multi_section_tests.rs @@ -188,6 +188,9 @@ fn continuous_multi_column_section_flows_into_two_columns_on_shared_page() { .collect(); let mut s1 = section(&lines); s1.start = SectionStart::Continuous; + // Same page geometry as s0 — a *size-changing* continuous break is promoted + // to a page break (Word fidelity), which is not what this test exercises. + s1.layout.page_size.height = Points::new(220.0); s1.layout.columns = Some(SectionColumns { count: 2, gap: Points::new(18.0), @@ -230,3 +233,83 @@ fn continuous_multi_column_section_flows_into_two_columns_on_shared_page() { "expected a second-column glyph run far from the left edge, got max x = {max_x}" ); } + +/// Word fidelity: a `continuous` section break that changes the page size is +/// promoted to a page break — the new section starts its own page carrying its +/// own geometry (an A4 continuous section after Letter must not be laid out on +/// the Letter page). Regression test for the ACID document's missing page. +#[test] +fn continuous_break_with_new_page_size_starts_its_own_page() { + use loki_doc_model::layout::{PageLayout, PageSize, SectionStart}; + + let letter_layout = PageLayout { + page_size: PageSize::letter(), + ..PageLayout::default() + }; + let a4_layout = PageLayout { + page_size: PageSize::a4(), + ..PageLayout::default() + }; + + let mut s1 = section(&["letter body"]); + s1.layout = letter_layout; + let mut s2 = section(&["a4 body"]); + s2.layout = a4_layout; + s2.start = SectionStart::Continuous; + + let mut doc = Document::new(); + doc.sections = vec![s1, s2]; + + let mut r = resources(); + let DocumentLayout::Paginated(paginated) = layout_document( + &mut r, + &doc, + LayoutMode::Paginated, + 1.0, + &LayoutOptions::default(), + ) else { + panic!("Paginated mode must yield a Paginated layout"); + }; + assert!( + paginated.pages.len() >= 2, + "the size-changing continuous section must start a new page (got {})", + paginated.pages.len() + ); + let last = paginated.pages.last().expect("at least one page"); + assert!( + (last.page_size.width - 595.28).abs() < 0.5 && (last.page_size.height - 841.89).abs() < 0.5, + "the new page must carry the A4 geometry, got {:?}", + last.page_size + ); +} + +/// The counterpart: a genuine continuous section (same page size) still shares +/// the previous section's page — the Word-fidelity exception must not regress +/// ordinary continuous column changes. +#[test] +fn continuous_break_with_same_page_size_still_shares_the_page() { + use loki_doc_model::layout::SectionStart; + + let s1 = section(&["intro"]); + let mut s2 = section(&["continues on the same page"]); + s2.start = SectionStart::Continuous; + + let mut doc = Document::new(); + doc.sections = vec![s1, s2]; + + let mut r = resources(); + let DocumentLayout::Paginated(paginated) = layout_document( + &mut r, + &doc, + LayoutMode::Paginated, + 1.0, + &LayoutOptions::default(), + ) else { + panic!("Paginated mode must yield a Paginated layout"); + }; + assert_eq!( + paginated.pages.len(), + 1, + "a same-geometry continuous section shares the page" + ); +} diff --git a/loki-odf/src/odt/mapper/props/tests.rs b/loki-odf/src/odt/mapper/props/tests.rs index 4ed6a4c9..8cf7f1fb 100644 --- a/loki-odf/src/odt/mapper/props/tests.rs +++ b/loki-odf/src/odt/mapper/props/tests.rs @@ -214,21 +214,21 @@ fn underline_solid_maps_to_single() { #[test] fn text_position_super_and_sub() { - let sup = OdfTextProps { + let superscript = OdfTextProps { text_position: Some("super".into()), ..Default::default() }; assert_eq!( - map_text_props(&sup).vertical_align, + map_text_props(&superscript).vertical_align, Some(VerticalAlign::Superscript) ); - let sub = OdfTextProps { + let subscript = OdfTextProps { text_position: Some("sub".into()), ..Default::default() }; assert_eq!( - map_text_props(&sub).vertical_align, + map_text_props(&subscript).vertical_align, Some(VerticalAlign::Subscript) ); } diff --git a/loki-ooxml/src/docx/omml/read_structs.rs b/loki-ooxml/src/docx/omml/read_structs.rs index 9e06746e..be5c20d4 100644 --- a/loki-ooxml/src/docx/omml/read_structs.rs +++ b/loki-ooxml/src/docx/omml/read_structs.rs @@ -50,10 +50,10 @@ pub(super) fn delim(node: &XmlNode) -> String { pub(super) fn nary(node: &XmlNode) -> String { let chr = pr_val(node, "naryPr", "chr").unwrap_or("\u{222B}"); let und_ovr = pr_val(node, "naryPr", "limLoc").unwrap_or("undOvr") != "subSup"; - let sub = limit_arg(node, "sub", pr_flag(node, "naryPr", "subHide")); - let sup = limit_arg(node, "sup", pr_flag(node, "naryPr", "supHide")); + let lower = limit_arg(node, "sub", pr_flag(node, "naryPr", "subHide")); + let upper = limit_arg(node, "sup", pr_flag(node, "naryPr", "supHide")); let op = format!("{}", escape_xml(chr)); - let script = match (sub, sup) { + let script = match (lower, upper) { (Some(s), Some(p)) if und_ovr => format!("{op}{s}{p}"), (Some(s), Some(p)) => format!("{op}{s}{p}"), (Some(s), None) if und_ovr => format!("{op}{s}"), diff --git a/loki-presentation/AndroidManifest.xml b/loki-presentation/AndroidManifest.xml index e3847b23..8ee51df6 100644 --- a/loki-presentation/AndroidManifest.xml +++ b/loki-presentation/AndroidManifest.xml @@ -11,7 +11,7 @@ android:targetSdkVersion="34" /> Element { SafeAreaResizeSensor {} // Measure the root width into the responsive context (F7a). AtViewportWidthSensor {} + // Persist the window size across sessions (debounced; desktop only + // in effect — Android windows are fullscreen). + appthere_ui::AtWindowSizeSensor { + on_size: |size: (f64, f64)| { + loki_app_shell::window_geometry::save_debounced(GEOMETRY_FILE, size) + }, + } Router:: {} diff --git a/loki-presentation/src/main.rs b/loki-presentation/src/main.rs index 852946c4..71d19cbb 100644 --- a/loki-presentation/src/main.rs +++ b/loki-presentation/src/main.rs @@ -8,6 +8,20 @@ fn main() { loki_i18n::init(); + // Window: product title and the last session's inner size (persisted by + // the AtWindowSizeSensor in `app`; falls back to a comfortable default). + let geometry = loki_app_shell::window_geometry::WindowGeometry::load( + loki_presentation::app::GEOMETRY_FILE, + ) + .unwrap_or(loki_app_shell::window_geometry::WindowGeometry::new( + 1280.0, 800.0, + )); + let attributes = dioxus::native::WindowAttributes::default() + .with_title(loki_presentation::app::WINDOW_TITLE) + .with_inner_size(dioxus::native::LogicalSize::new( + geometry.width, + geometry.height, + )); // Register the bundled UI + metric-compatible fonts directly into the // renderer's font collection so they resolve synchronously on every platform, // not via the asynchronous `@font-face` `data:` URI fetch (unreliable on @@ -16,7 +30,9 @@ fn main() { loki_presentation::app::App, vec![], vec![Box::new( - dioxus::native::Config::new().with_fonts(loki_fonts::ui_font_blobs()), + dioxus::native::Config::new() + .with_fonts(loki_fonts::ui_font_blobs()) + .with_window_attributes(attributes), )], ); } diff --git a/loki-renderer/src/page_paint_render.rs b/loki-renderer/src/page_paint_render.rs index 1439d32f..8ea0286a 100644 --- a/loki-renderer/src/page_paint_render.rs +++ b/loki-renderer/src/page_paint_render.rs @@ -44,38 +44,158 @@ pub(super) fn allocate_page_texture( (texture, view) } -/// Compute page-relative cursor paint data for this page. +/// Compute page-relative cursor paint data for this page: the caret (when the +/// focus is on this page) plus per-paragraph selection highlight spans for +/// whatever part of the (possibly multi-paragraph, multi-page) selection is +/// visible here. /// -/// Returns `None` when there is no selection, the caret is on another page, or -/// the layout for `generation` carries no editing data (reflow layouts). The -/// layout guard is scoped inside so it is dropped before the caller re-locks -/// the layout for the paint pass. +/// Returns `None` when there is nothing to paint on this page, or the layout +/// for `generation` carries no editing data (reflow layouts). The layout guard +/// is scoped inside so it is dropped before the caller re-locks the layout for +/// the paint pass. pub(super) fn page_cursor_paint( source: &DocPageSource, page_index: usize, generation: u64, current_sel: Option, ) -> Option { - current_sel.and_then(|sel| { - let cp = sel.focus; - if cp.page_index != page_index { - return None; - } - let guard = source.layout_for_generation(generation); - // Reflow layouts carry no editing data — no cursor is painted. - let layout = guard.as_ref()?.1.as_paginated()?; - let page = layout.pages.get(page_index)?; - let editing_data = page.editing_data.as_ref()?; - let para_data = editing_data - .paragraphs - .iter() - .find(|p| p.block_index == cp.paragraph_index)?; - let cursor_rect = para_data.layout.cursor_rect(cp.byte_offset); - Some(loki_vello::CursorPaint { - cursor_rect, - selection_rects: vec![], - selection_handles: vec![], - paragraph_index: cp.paragraph_index, + let sel = current_sel?; + let guard = source.layout_for_generation(generation); + // Reflow layouts carry no editing data — no cursor is painted. + let layout = guard.as_ref()?.1.as_paginated()?; + let page = layout.pages.get(page_index)?; + let editing_data = page.editing_data.as_ref()?; + + // Caret: only on the focus's page. + let cursor_rect = (sel.focus.page_index == page_index) + .then(|| { + editing_data + .paragraphs + .iter() + .find(|p| p.block_index == sel.focus.paragraph_index) + .and_then(|p| p.layout.cursor_rect(sel.focus.byte_offset)) }) + .flatten(); + + let selection_spans = if sel.is_collapsed() { + vec![] + } else { + selection_spans_for_page(page, &sel) + }; + + if cursor_rect.is_none() && selection_spans.is_empty() { + return None; + } + Some(loki_vello::CursorPaint { + cursor_rect, + selection_rects: vec![], + selection_handles: vec![], + selection_spans, + paragraph_index: sel.focus.paragraph_index, }) } + +/// Builds the per-paragraph highlight spans for the part of `sel` that is +/// visible on `page`. Selection endpoints are ordered document-forward; a +/// paragraph strictly inside the range is fully selected. Rects are clipped to +/// the page's content band (a paragraph split across pages registers the same +/// layout on both pages, at shifted origins). Table-cell paragraphs (non-empty +/// path) are skipped — the selection endpoints carry no path. +fn selection_spans_for_page( + page: &loki_layout::LayoutPage, + sel: &RendererSelection, +) -> Vec { + let Some(editing_data) = page.editing_data.as_ref() else { + return vec![]; + }; + let (start, end) = { + let a = (sel.anchor.paragraph_index, sel.anchor.byte_offset); + let f = (sel.focus.paragraph_index, sel.focus.byte_offset); + if a <= f { (a, f) } else { (f, a) } + }; + let content_h = page.page_size.height - page.margins.top - page.margins.bottom; + + let mut spans = Vec::new(); + for para in &editing_data.paragraphs { + if para.block_index < start.0 || para.block_index > end.0 || !para.path.is_empty() { + continue; + } + let from = if para.block_index == start.0 { + start.1 + } else { + 0 + }; + let to = if para.block_index == end.0 { + end.1 + } else { + usize::MAX // clamped by selection_rects + }; + let rects: Vec = para + .layout + .selection_rects(from, to) + .into_iter() + // Clip to this page's content band (page-split fragments). + .filter(|r| { + let top = r.origin.y + para.origin.1; + top + r.size.height > 0.0 && top < content_h + }) + .map(|r| loki_vello::SelectionRect { + x: r.origin.x, + y: r.origin.y, + width: r.size.width, + height: r.size.height, + }) + .collect(); + if rects.is_empty() { + continue; + } + let handles = selection_handles_for_span(para.block_index, &rects, start.0, end.0); + spans.push(loki_vello::SelectionSpan { + paragraph_index: para.block_index, + rects, + handles, + }); + } + spans +} + +/// Teardrop drag handles at the selection edges — mobile only (idiomatic for +/// touch; on desktop the highlight alone is standard). +#[cfg(target_os = "android")] +fn selection_handles_for_span( + block_index: usize, + rects: &[loki_vello::SelectionRect], + start_block: usize, + end_block: usize, +) -> Vec { + let mut handles = Vec::new(); + if block_index == start_block + && let Some(first) = rects.first() + { + handles.push(loki_vello::SelectionHandle { + tip_x: first.x, + tip_y: first.y + first.height, + kind: loki_vello::SelectionHandleKind::Anchor, + }); + } + if block_index == end_block + && let Some(last) = rects.last() + { + handles.push(loki_vello::SelectionHandle { + tip_x: last.x + last.width, + tip_y: last.y + last.height, + kind: loki_vello::SelectionHandleKind::Focus, + }); + } + handles +} + +#[cfg(not(target_os = "android"))] +fn selection_handles_for_span( + _block_index: usize, + _rects: &[loki_vello::SelectionRect], + _start_block: usize, + _end_block: usize, +) -> Vec { + Vec::new() +} diff --git a/loki-spreadsheet/AndroidManifest.xml b/loki-spreadsheet/AndroidManifest.xml index 2c262a0e..52800791 100644 --- a/loki-spreadsheet/AndroidManifest.xml +++ b/loki-spreadsheet/AndroidManifest.xml @@ -11,7 +11,7 @@ android:targetSdkVersion="34" /> Element { SafeAreaResizeSensor {} // Measure the root width into the responsive context (F7a). AtViewportWidthSensor {} + // Persist the window size across sessions (debounced; desktop only + // in effect — Android windows are fullscreen). + appthere_ui::AtWindowSizeSensor { + on_size: |size: (f64, f64)| { + loki_app_shell::window_geometry::save_debounced(GEOMETRY_FILE, size) + }, + } Router:: {} diff --git a/loki-spreadsheet/src/main.rs b/loki-spreadsheet/src/main.rs index ed29c12d..c30668d3 100644 --- a/loki-spreadsheet/src/main.rs +++ b/loki-spreadsheet/src/main.rs @@ -8,6 +8,19 @@ fn main() { loki_i18n::init(); + // Window: product title and the last session's inner size (persisted by + // the AtWindowSizeSensor in `app`; falls back to a comfortable default). + let geometry = + loki_app_shell::window_geometry::WindowGeometry::load(loki_spreadsheet::app::GEOMETRY_FILE) + .unwrap_or(loki_app_shell::window_geometry::WindowGeometry::new( + 1280.0, 800.0, + )); + let attributes = dioxus::native::WindowAttributes::default() + .with_title(loki_spreadsheet::app::WINDOW_TITLE) + .with_inner_size(dioxus::native::LogicalSize::new( + geometry.width, + geometry.height, + )); // Register the bundled UI + metric-compatible fonts directly into the // renderer's font collection so they resolve synchronously on every platform, // not via the asynchronous `@font-face` `data:` URI fetch (unreliable on @@ -16,7 +29,9 @@ fn main() { loki_spreadsheet::app::App, vec![], vec![Box::new( - dioxus::native::Config::new().with_fonts(loki_fonts::ui_font_blobs()), + dioxus::native::Config::new() + .with_fonts(loki_fonts::ui_font_blobs()) + .with_window_attributes(attributes), )], ); } diff --git a/loki-text/AndroidManifest.xml b/loki-text/AndroidManifest.xml index 9bedac95..d0fa6849 100644 --- a/loki-text/AndroidManifest.xml +++ b/loki-text/AndroidManifest.xml @@ -16,7 +16,7 @@ FilePickerActivity is compiled into classes.dex and injected by the build script. --> Element { // renders the active backdrop inside this positioned root. use_provide_backdrop(); + // Start the document font warm-up (system-font scan + family-index build) + // on a background thread now, so it overlaps the Home screen instead of + // stalling the first document open. On Windows the DirectWrite enumeration + // is the dominant first-open cost; the editor consumes this handle via + // context and only ever blocks on it from worker threads. + use_context_provider(loki_layout::SharedFontResources::warm_up); + // Spell-check service — starts on the bundled English dictionary so checking // works offline. Provided into context for any component (e.g. a future // language picker), and installed into the editor's ambient layout state so @@ -206,6 +213,13 @@ pub fn App() -> Element { // Re-query safe-area insets on resize (Android orientation change). SafeAreaResizeSensor {} + // Persist the window size across sessions (debounced; desktop only + // in effect — Android windows are fullscreen and the geometry file + // simply never resolves there). + appthere_ui::AtWindowSizeSensor { + on_size: |size: (f64, f64)| crate::window_state::persist_geometry_debounced(size), + } + Router:: {} // Window-level dismiss backdrop (e.g. the ribbon overflow menu's diff --git a/loki-text/src/editing/page_locate.rs b/loki-text/src/editing/page_locate.rs index 2f7abd54..2e515be1 100644 --- a/loki-text/src/editing/page_locate.rs +++ b/loki-text/src/editing/page_locate.rs @@ -30,17 +30,27 @@ use super::cursor::DocumentPosition; #[path = "page_locate_tests.rs"] mod tests; +/// Geometry tolerance for the content-band fit checks (points). +const BAND_EPSILON: f32 = 0.5; + /// Returns `pos` with its `page_index` re-derived from `layout`. /// /// When the position's paragraph is found on exactly one page, that page /// wins. When it spans several pages (a split paragraph), the page whose -/// content band contains the byte offset's line-centre wins; if no band -/// matches (degenerate geometry), the first page holding the paragraph is -/// used. When the paragraph is not in the layout at all (e.g. the layout is -/// momentarily stale), `pos` is returned unchanged. +/// content band **fully contains** the byte offset's line wins — the split +/// engine moves a non-fitting line entirely to the next page, so only the +/// page that actually renders the line satisfies this. (A centre-only check +/// mis-attributed the first line after a page break to the *previous* page +/// whenever that page ended with more than half a line of slack, painting +/// the caret near the bottom of the wrong page.) If no page fully contains +/// the line (degenerate geometry, e.g. a line taller than the band), the +/// line-centre rule decides; failing that, the first page holding the +/// paragraph is used. When the paragraph is not in the layout at all (e.g. +/// the layout is momentarily stale), `pos` is returned unchanged. #[must_use] pub fn recompute_page_index(layout: &PaginatedLayout, pos: &DocumentPosition) -> DocumentPosition { let mut first_holder: Option = None; + let mut center_match: Option = None; let mut visible: Option = None; for (pi, page) in layout.pages.iter().enumerate() { @@ -55,15 +65,19 @@ pub fn recompute_page_index(layout: &PaginatedLayout, pos: &DocumentPosition) -> continue; }; first_holder.get_or_insert(pi); - if visible.is_none() - && let Some(rect) = para.layout.cursor_rect(pos.byte_offset) - { - // Content-band check: the line's centre, in page-content - // coordinates (origin is already content-relative). - let y_center = rect.y + rect.height / 2.0 + para.origin.1; + if let Some(rect) = para.layout.cursor_rect(pos.byte_offset) { + // Line extent in page-content coordinates (the per-page origin is + // already content-relative). + let y_top = rect.y + para.origin.1; + let y_bottom = y_top + rect.height; let content_h = page.page_size.height - page.margins.top - page.margins.bottom; - if y_center >= 0.0 && y_center < content_h { + if y_top >= -BAND_EPSILON && y_bottom <= content_h + BAND_EPSILON { visible = Some(pi); + } else if center_match.is_none() { + let y_center = y_top + rect.height / 2.0; + if y_center >= 0.0 && y_center < content_h { + center_match = Some(pi); + } } } if visible.is_some() { @@ -71,10 +85,11 @@ pub fn recompute_page_index(layout: &PaginatedLayout, pos: &DocumentPosition) -> } } - let new_page = match (visible, first_holder) { - (Some(pi), _) => pi, - (None, Some(pi)) => pi, - (None, None) => pos.page_index, + let new_page = match (visible, center_match, first_holder) { + (Some(pi), _, _) => pi, + (None, Some(pi), _) => pi, + (None, None, Some(pi)) => pi, + (None, None, None) => pos.page_index, }; if new_page == pos.page_index { return pos.clone(); diff --git a/loki-text/src/editing/page_locate_tests.rs b/loki-text/src/editing/page_locate_tests.rs index 7372e0dd..99fe7a5b 100644 --- a/loki-text/src/editing/page_locate_tests.rs +++ b/loki-text/src/editing/page_locate_tests.rs @@ -156,3 +156,45 @@ fn split_paragraph_picks_the_page_showing_the_bytes_line() { let last = recompute_page_index(&layout, &DocumentPosition::top_level(0, 0, text.len())); assert_eq!(last.page_index, 1); } + +#[test] +fn first_line_after_a_page_break_resolves_to_the_new_page() { + // Regression: the split engine moves a line that does not fit page 0 + // entirely onto page 1, leaving up to a line of slack at page 0's bottom. + // With more than half a line of slack, the old line-CENTRE band check + // still claimed the line for page 0 and the caret painted on the previous + // page. The line must resolve to the page that actually renders it. + let text = "alpha beta gamma delta epsilon zeta eta theta iota kappa"; + let p = para(text, 60.0); // narrow → many lines + // Find a mid-paragraph line boundary: the line top of the middle byte. + let mid = text.len() / 2; + let mid_rect = p.cursor_rect(mid).expect("mid rect"); + let line_top = mid_rect.y; + let line_h = mid_rect.height; + assert!(line_top > 0.0, "test premise: mid byte is not on line 0"); + + // Page 0 shows lines [0, line_top) and then 80% of a line of slack — + // the mid line itself did NOT fit and went to page 1. + let slack = 0.8 * line_h; + let layout = PaginatedLayout { + page_size: LayoutSize::new(595.0, PAGE_H), + pages: vec![ + page( + vec![entry(0, Arc::clone(&p), CONTENT_H - line_top - slack)], + 1, + ), + page(vec![entry(0, Arc::clone(&p), -line_top)], 2), + ], + }; + + // A caret on the moved line must paint on page 1, not page 0. + let fixed = recompute_page_index(&layout, &DocumentPosition::top_level(0, 0, mid)); + assert_eq!( + fixed.page_index, 1, + "the first line after the break renders on page 1" + ); + + // And a caret on page 0's own last line stays on page 0. + let first = recompute_page_index(&layout, &DocumentPosition::top_level(1, 0, 0)); + assert_eq!(first.page_index, 0); +} diff --git a/loki-text/src/editing/state.rs b/loki-text/src/editing/state.rs index d7507e7f..d24e004d 100644 --- a/loki-text/src/editing/state.rs +++ b/loki-text/src/editing/state.rs @@ -12,8 +12,8 @@ use std::sync::{Arc, Mutex}; use loki_doc_model::document::Document; use loki_doc_model::loro_bridge::IncrementalReader; use loki_layout::{ - ContinuousLayout, DocumentLayout, FontResources, LayoutMode, LayoutOptions, PaginatedLayout, - PaginatedReuse, + ContinuousLayout, DocumentLayout, LayoutMode, LayoutOptions, PaginatedLayout, PaginatedReuse, + SharedFontResources, }; use super::relayout::{LaidOut, page_metrics, relayout_paginated}; @@ -44,8 +44,10 @@ pub struct DocumentState { /// Page height in CSS px from the current layout (A4 fallback: 1123 px). pub page_height_px: f32, /// Shared Parley font + shaping context — one per editor to avoid the - /// ≈20 MB font-scan cost on every mutation. - pub shared_font_resources: Arc>, + /// ≈20 MB font-scan cost on every mutation. The handle is warm-up-aware: + /// the system-font scan runs on a background thread (started at app + /// launch), so constructing `DocumentState` never blocks on it. + pub shared_font_resources: SharedFontResources, /// Lazily-computed reflow layout for reflow-mode navigation, keyed by /// `(generation, content-width key)`. Recomputed when stale. Separate from /// the renderer's copy; only built when the user navigates in reflow mode. @@ -61,8 +63,16 @@ pub struct DocumentState { } impl DocumentState { - /// Creates a fresh state with no document loaded. + /// Creates a fresh state with no document loaded, starting its own + /// background font warm-up. Prefer [`DocumentState::with_fonts`] with the + /// app-wide handle so the scan overlaps app startup instead. pub fn new() -> Self { + Self::with_fonts(SharedFontResources::warm_up()) + } + + /// Creates a fresh state sharing an existing (possibly still warming) + /// font context. Never blocks on the system-font scan. + pub fn with_fonts(fonts: SharedFontResources) -> Self { Self { document: None, generation: 0, @@ -70,7 +80,7 @@ impl DocumentState { paginated_layout: None, page_width_px: appthere_ui::tokens::PAGE_WIDTH_PX, page_height_px: appthere_ui::tokens::PAGE_HEIGHT_PX, - shared_font_resources: Arc::new(Mutex::new(FontResources::new())), + shared_font_resources: fonts, reflow_cache: None, incremental: None, layout_reuse: None, @@ -98,10 +108,7 @@ pub fn ensure_reflow_layout( return Some(layout.clone()); } let layout = { - let mut resources = state - .shared_font_resources - .lock() - .unwrap_or_else(|e| e.into_inner()); + let mut resources = state.shared_font_resources.lock(); let options = LayoutOptions { preserve_for_editing: true, spell: crate::editing::spell::active(), @@ -161,15 +168,14 @@ pub fn seed_layout_from_document(doc_state: &Arc>, doc: &Do /// worker thread; publish the result on the main thread with /// [`publish_seed_layout`]. Both [`FontResources`] and the returned layout are /// `Send`, which is what makes the off-main-thread open path possible. -pub(crate) fn compute_seed_layout( - font_resources: &Arc>, - doc: &Document, -) -> LaidOut { +pub(crate) fn compute_seed_layout(font_resources: &SharedFontResources, doc: &Document) -> LaidOut { // Open-path timing (the worker thread). Logged under `loki_text::open` so the // CPU layout cost of opening a document is visible on-device: // RUST_LOG=loki_text::open=info cargo run -p loki-text --release let started = std::time::Instant::now(); - let mut fr = font_resources.lock().unwrap_or_else(|e| e.into_inner()); + // Blocks until the background font warm-up finishes — this runs on the + // open-path worker thread, so the UI keeps painting meanwhile. + let mut fr = font_resources.lock(); let lock_ms = started.elapsed().as_secs_f64() * 1000.0; // New document: drop the previous document's memoised paragraph layouts so // the shaping cache does not accumulate across loads. diff --git a/loki-text/src/editing/state_apply.rs b/loki-text/src/editing/state_apply.rs index 66c3f97c..a8f89b20 100644 --- a/loki-text/src/editing/state_apply.rs +++ b/loki-text/src/editing/state_apply.rs @@ -78,7 +78,7 @@ pub fn apply_mutation_and_relayout( ) }; let laid_out = { - let mut fr = fr_arc.lock().unwrap_or_else(|e| e.into_inner()); + let mut fr = fr_arc.lock(); let prev = match (&prev_doc, &prev_layout, &prev_reuse) { (Some(d), Some(l), Some(r)) => Some((d.as_ref(), l.as_ref(), r)), _ => None, diff --git a/loki-text/src/lib.rs b/loki-text/src/lib.rs index 2b4a7f1c..54fdd9b6 100644 --- a/loki-text/src/lib.rs +++ b/loki-text/src/lib.rs @@ -21,6 +21,7 @@ pub mod routes; pub mod sessions; pub mod tabs; pub mod utils; +pub mod window_state; // Android NativeActivity entry point. Shared body lives in // `loki_app_shell::android_main!` so the three suite binaries don't each carry a diff --git a/loki-text/src/main.rs b/loki-text/src/main.rs index bb7ae024..570ffdd0 100644 --- a/loki-text/src/main.rs +++ b/loki-text/src/main.rs @@ -8,6 +8,16 @@ fn main() { loki_i18n::init(); + // Window: proper product title (instead of winit's "Dioxus App") and the + // last session's inner size (persisted by `window_state`; falls back to a + // comfortable default rather than winit's tiny built-in size). + let geometry = loki_text::window_state::initial_geometry(); + let attributes = dioxus::native::WindowAttributes::default() + .with_title(loki_text::window_state::WINDOW_TITLE) + .with_inner_size(dioxus::native::LogicalSize::new( + geometry.width, + geometry.height, + )); // Register the bundled UI + metric-compatible fonts directly into the // renderer's font collection at startup. This is the robust, cross-platform // path: the families resolve synchronously, without depending on the @@ -17,7 +27,9 @@ fn main() { loki_text::app::App, vec![], vec![Box::new( - dioxus::native::Config::new().with_fonts(loki_fonts::ui_font_blobs()), + dioxus::native::Config::new() + .with_fonts(loki_fonts::ui_font_blobs()) + .with_window_attributes(attributes), )], ); } diff --git a/loki-text/src/routes/editor/editor_alignment.rs b/loki-text/src/routes/editor/editor_alignment.rs deleted file mode 100644 index cc64d56e..00000000 --- a/loki-text/src/routes/editor/editor_alignment.rs +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Paragraph-alignment actions for the ribbon. -//! -//! Reads/sets alignment on the caret's paragraph(s) using the path-aware -//! `loki_doc_model` mutations, so alignment works in table cells and note bodies -//! as well as top-level paragraphs. Range resolution reuses -//! [`resolve_format_ranges`](super::editor_format_range::resolve_format_ranges), -//! the same per-paragraph mapping the inline-format toggles use. - -use loki_doc_model::{MutationError, get_block_alignment_at, set_block_alignment_at}; -use loro::LoroDoc; - -use super::editor_format_range::resolve_format_ranges; -use crate::editing::cursor::CursorState; - -/// The alignment of the caret's paragraph — the first resolved range's block -/// (`"Left"` when there is no cursor). Drives the ribbon buttons' active state. -pub(super) fn current_alignment(loro: &LoroDoc, cursor: &CursorState) -> String { - match resolve_format_ranges(loro, cursor).first() { - Some((path, _, _)) => get_block_alignment_at(loro, path), - None => "Left".to_string(), - } -} - -/// Sets `alignment` on every paragraph in the selection (or the caret's -/// paragraph for a point cursor). One `BlockPath` per paragraph, so a -/// multi-paragraph selection is aligned uniformly. -pub(super) fn apply_alignment( - loro: &LoroDoc, - cursor: &CursorState, - alignment: &str, -) -> Result<(), MutationError> { - for (path, _, _) in &resolve_format_ranges(loro, cursor) { - set_block_alignment_at(loro, path, alignment)?; - } - Ok(()) -} diff --git a/loki-text/src/routes/editor/editor_color_panel.rs b/loki-text/src/routes/editor/editor_color_panel.rs new file mode 100644 index 00000000..cef6227d --- /dev/null +++ b/loki-text/src/routes/editor/editor_color_panel.rs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Docked colour-picker panel — hosted in `EditorInner`'s flex column above +//! the ribbon (the same posture as the style picker and publish panels), +//! opened by the Format tab's [`AtColorPickerTrigger`]s. +//! +//! Picks apply through the same mark-mutation + relayout path as every other +//! formatting control, are recorded into the session's recent-colour list, +//! and close the panel. +//! +//! [`AtColorPickerTrigger`]: appthere_ui::AtColorPickerTrigger + +use std::sync::{Arc, Mutex}; + +use appthere_ui::{AtColorPickerLabels, AtColorPickerPanel}; +use dioxus::prelude::*; +use loki_doc_model::MutationError; +use loki_i18n::fl; +use loro::LoroDoc; + +use super::editor_highlight_color::{apply_highlight, current_highlight}; +use super::editor_ribbon_color::{ + FONT_COLOR_PALETTE, HIGHLIGHT_PALETTE, highlight_fill, preset_swatches, push_recent, + recent_swatches, +}; +use super::editor_ribbon_format::RibbonEditCtx; +use super::editor_state::ColorPickerTarget; +use super::editor_text_color::{apply_text_color, current_text_color}; +use crate::editing::cursor::CursorState; +use crate::editing::state::DocumentState; + +/// Builds the docked picker panel for `target`. +pub(super) fn color_picker_panel( + doc_state: &Arc>, + target: ColorPickerTarget, + mut open_picker: Signal>, + ctx: RibbonEditCtx, + recent_text_colors: Signal>, + recent_highlights: Signal>, +) -> Element { + let loro = ctx.loro_doc; + let cursor = ctx.cursor_state; + + struct Target { + current: Option, + swatches: Vec, + recent: Signal>, + recent_fill: fn(&str) -> String, + show_custom: bool, + title: String, + clear: String, + apply: fn(&LoroDoc, &CursorState, Option<&str>) -> Result<(), MutationError>, + } + + let t = match target { + ColorPickerTarget::Text => Target { + current: loro + .read() + .as_ref() + .and_then(|ldoc| current_text_color(ldoc, &cursor.read())), + swatches: preset_swatches(FONT_COLOR_PALETTE), + recent: recent_text_colors, + recent_fill: str::to_string, + show_custom: true, + title: fl!("ribbon-group-font-color"), + clear: fl!("ribbon-color-clear"), + apply: apply_text_color, + }, + ColorPickerTarget::Highlight => Target { + current: loro + .read() + .as_ref() + .and_then(|ldoc| current_highlight(ldoc, &cursor.read())), + swatches: preset_swatches(HIGHLIGHT_PALETTE), + recent: recent_highlights, + recent_fill: |name| highlight_fill(name).unwrap_or("transparent").to_string(), + show_custom: false, + title: fl!("ribbon-group-highlight"), + clear: fl!("ribbon-highlight-clear"), + apply: apply_highlight, + }, + }; + + let labels = AtColorPickerLabels { + title: t.title, + close: fl!("ribbon-color-close-aria"), + clear: t.clear, + recent_heading: fl!("ribbon-color-recent"), + custom_heading: fl!("ribbon-color-custom"), + apply: fl!("ribbon-color-apply"), + }; + let recent_list = recent_swatches(&t.recent.read(), t.recent_fill); + let ds = Arc::clone(doc_state); + let recent_sig = t.recent; + let apply = t.apply; + + rsx! { + AtColorPickerPanel { + current_value: t.current, + swatches: t.swatches, + recent: recent_list, + show_custom: t.show_custom, + labels: labels, + on_pick: move |value: Option| { + if let Some(ldoc) = loro.read().as_ref() + && apply(ldoc, &cursor.read(), value.as_deref()).is_ok() + { + ctx.finish(&ds, ldoc); + if let Some(v) = value { + push_recent(recent_sig, v); + } + } + open_picker.set(None); + }, + on_close: move |_| open_picker.set(None), + } + } +} diff --git a/loki-text/src/routes/editor/editor_dirty.rs b/loki-text/src/routes/editor/editor_dirty.rs index 465c043c..2f4774c9 100644 --- a/loki-text/src/routes/editor/editor_dirty.rs +++ b/loki-text/src/routes/editor/editor_dirty.rs @@ -8,6 +8,7 @@ use dioxus::prelude::*; +use super::editor_state::SaveStatus; use crate::editing::cursor::CursorState; use crate::editing::saved_state::SavedStateHandle; use crate::new_document::is_untitled; @@ -19,6 +20,9 @@ use crate::tabs::OpenTab; /// Dirty = the live generation differs from the clean baseline AND the /// undo-stack clean checkpoint disagrees (undoing to the save point clears /// dirty; plan 4b.3); untitled docs are always dirty until the first Save As. +/// +/// Also clears a lingering *success* status chip the moment the document goes +/// dirty — a stale "Document saved" must never sit over unsaved edits. pub(super) fn use_dirty_tracking( cursor_state: Signal, path_signal: Signal, @@ -26,6 +30,7 @@ pub(super) fn use_dirty_tracking( saved_state: Signal, mut is_dirty: Signal, mut tabs: Signal>, + mut save_message: Signal>, ) { use_effect(move || { let live_gen = cursor_state.read().document_generation; @@ -36,6 +41,14 @@ pub(super) fn use_dirty_tracking( if *is_dirty.peek() != dirty { is_dirty.set(dirty); // guard avoids a needless ribbon re-render } + if dirty + && save_message + .peek() + .as_ref() + .is_some_and(|status| !status.is_error) + { + save_message.set(None); + } // Only take a write guard when a tab's flag actually changes. A bare // `tabs.write()` marks the shared tabs signal dirty and re-renders the // whole tab bar on every keystroke, even when nothing changed — peek diff --git a/loki-text/src/routes/editor/editor_font_size.rs b/loki-text/src/routes/editor/editor_font_size.rs deleted file mode 100644 index d56f1599..00000000 --- a/loki-text/src/routes/editor/editor_font_size.rs +++ /dev/null @@ -1,100 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Font-size grow/shrink for the ribbon. -//! -//! Font size is a character mark ([`MARK_FONT_SIZE_PT`], a point value), so it -//! applies to the caret's paragraph(s) through the same path-aware -//! [`mark_text_at`] + [`resolve_format_ranges`] path the inline toggles use — -//! it works in table cells and across a multi-paragraph selection. -//! -//! Grow/shrink step through a fixed ladder of common sizes. The current size is -//! read from the selection's **direct** size mark; a range with no explicit size -//! (its size comes from a style) steps from [`DEFAULT_FONT_SIZE_PT`]. - -use loki_doc_model::loro_schema::MARK_FONT_SIZE_PT; -use loki_doc_model::{MutationError, get_mark_at_path, mark_text_at}; -use loro::{LoroDoc, LoroValue}; - -use super::editor_format_range::resolve_format_ranges; -use crate::editing::cursor::CursorState; - -/// Common point sizes the grow/shrink buttons step through (Word's ladder). -const SIZE_LADDER: &[f64] = &[ - 8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 32.0, 36.0, 40.0, 44.0, - 48.0, 54.0, 60.0, 66.0, 72.0, 80.0, 88.0, 96.0, -]; - -/// Fallback size when the selection carries no direct size mark. -const DEFAULT_FONT_SIZE_PT: f64 = 11.0; - -/// The next ladder size strictly greater than `size` (clamped to the top). -fn grow(size: f64) -> f64 { - SIZE_LADDER - .iter() - .copied() - .find(|&s| s > size + f64::EPSILON) - .unwrap_or(96.0) -} - -/// The previous ladder size strictly less than `size` (clamped to the bottom). -fn shrink(size: f64) -> f64 { - SIZE_LADDER - .iter() - .rev() - .copied() - .find(|&s| s < size - f64::EPSILON) - .unwrap_or(8.0) -} - -/// The direct font size (pt) at the caret's first resolved range, or -/// [`DEFAULT_FONT_SIZE_PT`] when it has no explicit size mark. -fn current_font_size(loro: &LoroDoc, cursor: &CursorState) -> f64 { - let ranges = resolve_format_ranges(loro, cursor); - let Some((path, start, _)) = ranges.first() else { - return DEFAULT_FONT_SIZE_PT; - }; - match get_mark_at_path(loro, path, *start, MARK_FONT_SIZE_PT) { - Ok(Some(LoroValue::Double(v))) => v, - _ => DEFAULT_FONT_SIZE_PT, - } -} - -/// Applies `size_pt` across every resolved range (the selection, or the word at -/// a point cursor). -fn apply_font_size( - loro: &LoroDoc, - cursor: &CursorState, - size_pt: f64, -) -> Result<(), MutationError> { - for (path, start, end) in &resolve_format_ranges(loro, cursor) { - mark_text_at( - loro, - path, - *start, - *end, - MARK_FONT_SIZE_PT, - LoroValue::Double(size_pt), - )?; - } - Ok(()) -} - -/// Grows (`grow_it = true`) or shrinks the selection's font size by one ladder -/// step from its current size. -pub(super) fn adjust_font_size( - loro: &LoroDoc, - cursor: &CursorState, - grow_it: bool, -) -> Result<(), MutationError> { - let current = current_font_size(loro, cursor); - let next = if grow_it { - grow(current) - } else { - shrink(current) - }; - apply_font_size(loro, cursor, next) -} - -#[cfg(test)] -#[path = "editor_font_size_tests.rs"] -mod tests; diff --git a/loki-text/src/routes/editor/editor_font_size_tests.rs b/loki-text/src/routes/editor/editor_font_size_tests.rs deleted file mode 100644 index c06e9ebf..00000000 --- a/loki-text/src/routes/editor/editor_font_size_tests.rs +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Tests for font-size grow/shrink: the pure ladder stepping and the -//! end-to-end mark application over a selection. - -use loki_doc_model::content::block::Block; -use loki_doc_model::content::inline::Inline; -use loki_doc_model::document::Document; -use loki_doc_model::get_mark_at; -use loki_doc_model::loro_bridge::document_to_loro; -use loki_doc_model::loro_schema::MARK_FONT_SIZE_PT; -use loro::{LoroDoc, LoroValue}; - -use super::{adjust_font_size, grow, shrink}; -use crate::editing::cursor::{CursorState, DocumentPosition}; - -fn loro_with(text: &str) -> LoroDoc { - let mut doc = Document::new(); - doc.sections[0].blocks = vec![Block::Para(vec![Inline::Str(text.into())])]; - document_to_loro(&doc).expect("to loro") -} - -fn selection(start: usize, end: usize) -> CursorState { - let mut cs = CursorState::new(); - cs.anchor = Some(DocumentPosition::top_level(0, 0, start)); - cs.focus = Some(DocumentPosition::top_level(0, 0, end)); - cs -} - -fn size_at(loro: &LoroDoc, byte: usize) -> Option { - match get_mark_at(loro, 0, byte, MARK_FONT_SIZE_PT).expect("get_mark_at") { - Some(LoroValue::Double(v)) => Some(v), - _ => None, - } -} - -#[test] -fn grow_steps_up_the_ladder() { - assert_eq!(grow(11.0), 12.0); - assert_eq!(grow(12.0), 14.0); - assert_eq!(grow(8.0), 9.0); - assert_eq!(grow(96.0), 96.0, "clamped at the top"); - assert_eq!(grow(500.0), 96.0); -} - -#[test] -fn shrink_steps_down_the_ladder() { - assert_eq!(shrink(12.0), 11.0); - assert_eq!(shrink(11.0), 10.5); - assert_eq!(shrink(9.0), 8.0); - assert_eq!(shrink(8.0), 8.0, "clamped at the bottom"); -} - -#[test] -fn off_ladder_size_snaps_to_the_neighbour() { - // 13 pt is not on the ladder: grow → 14, shrink → 12. - assert_eq!(grow(13.0), 14.0); - assert_eq!(shrink(13.0), 12.0); -} - -#[test] -fn adjust_applies_a_size_mark_over_the_selection_only() { - let loro = loro_with("hello world"); - // Select "hello" (0..5). No direct size → default 11 → grow → 12. - adjust_font_size(&loro, &selection(0, 5), true).expect("grow"); - assert_eq!( - size_at(&loro, 2), - Some(12.0), - "grown to 12pt inside selection" - ); - assert_eq!(size_at(&loro, 8), None, "untouched outside the selection"); -} - -#[test] -fn grow_then_shrink_returns_to_the_start() { - let loro = loro_with("hello world"); - adjust_font_size(&loro, &selection(0, 5), true).expect("grow"); // 11 → 12 - adjust_font_size(&loro, &selection(0, 5), false).expect("shrink"); // 12 → 11 - assert_eq!(size_at(&loro, 2), Some(11.0)); -} diff --git a/loki-text/src/routes/editor/editor_font_warning.rs b/loki-text/src/routes/editor/editor_font_warning.rs index 5c1e5968..c57d394b 100644 --- a/loki-text/src/routes/editor/editor_font_warning.rs +++ b/loki-text/src/routes/editor/editor_font_warning.rs @@ -1,14 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 AppThere Loki contributors -//! Font-substitution warning UI (Spec 03 M3 / D3). +//! Font-substitution detail panel (Spec 03 M3 / D3, inverted 2026-07). //! -//! Compact-by-default, expand-on-demand, dismissible (recovery lives in the -//! status bar), and **breakpoint-aware**: the expanded view is a compact table -//! on Expanded width and a vertical stack of cards on Compact — never a -//! full-width band that wraps (the named offender). Blitz-clean: no -//! `position: fixed`, no `box-shadow` (elevation via border/background), no CSS -//! custom properties. All strings via `fl!()`. +//! The *indicator* is the status-bar chip ("N fonts substituted"), shown +//! whenever the layout engine recorded substitutions — there is no banner. +//! Clicking the chip opens this panel above the ribbon with the full +//! requested→substitute table; clicking Close (or the chip again) hides it. +//! **Breakpoint-aware**: a compact table on Expanded width and a vertical +//! stack of cards on Compact — never a full-width band that wraps. Blitz- +//! clean: no `position: fixed`, no `box-shadow` (elevation via border/ +//! background), no CSS custom properties. All strings via `fl!()`. //! //! Owns only the warning *UI*; the substitution engine is Spec 02's. @@ -195,22 +197,24 @@ fn item_row(item: &Sub) -> Element { } } -/// The font-substitution warning. Renders nothing when there are no -/// substitutions or it has been dismissed (recovery is via the status bar). +/// The font-substitution detail panel. Renders nothing when there are no +/// substitutions or while closed; the status-bar chip toggles `open`. +/// +/// The Close button inherits the ribbon-button touch-target convention: its +/// padded hit area meets the 44×44 logical-pixel minimum (WCAG 2.5.8) through +/// the surrounding header row height plus padding. #[component] -pub(super) fn FontWarning( +pub(super) fn FontSubstitutionPanel( substitutions: HashMap>, - dismiss: Signal, + open: Signal, ) -> Element { - let mut expanded = use_signal(|| false); - let mut dismiss = dismiss; + let mut open = open; let compact = use_breakpoint().is_compact(); - if substitutions.is_empty() || dismiss() { + if substitutions.is_empty() || !open() { return rsx! {}; } let items = build_items(&substitutions); - let count = items.len() as i64; let container = format!( "display: flex; flex-direction: column; gap: {gap}px; padding: {pv}px {ph}px; \ @@ -238,45 +242,30 @@ pub(super) fn FontWarning( rsx! { div { style: "{container}", - // Header row — compact chip when collapsed, title when expanded. + // Header row — title + close. div { style: "display: flex; flex-direction: row; align-items: center; gap: 8px;", span { style: format!("color: {}; font-weight: bold;", tokens::COLOR_CONTEXTUAL_TAB), - if expanded() { - "⚠ {fl!(\"editor-font-substitution-title\")}" - } else { - "⚠ {fl!(\"editor-font-substitution-chip\", count = count)}" - } + "⚠ {fl!(\"editor-font-substitution-title\")}" } div { style: "flex: 1;" } button { style: "{btn}", - onclick: move |_| { let v = expanded(); expanded.set(!v); }, - if expanded() { - {fl!("editor-font-substitution-collapse")} - } else { - {fl!("editor-font-substitution-details")} - } - } - button { - style: "{btn}", - onclick: move |_| { dismiss.set(true); }, - {fl!("editor-font-dismiss")} + onclick: move |_| { open.set(false); }, + {fl!("editor-font-substitution-close")} } } - // Expanded body — card stack (Compact) or table (Expanded). - if expanded() { - span { - style: format!("font-size: {}px; color: {};", tokens::FONT_SIZE_LABEL, tokens::COLOR_TEXT_ON_CHROME_SECONDARY), - {fl!("editor-font-substitution-message")} - } - div { - style: "display: flex; flex-direction: column; gap: 6px;", - for item in items.iter() { - div { key: "{item.requested}", - if compact { {item_card(item)} } else { {item_row(item)} } - } + // Detail body — card stack (Compact) or table (Expanded). + span { + style: format!("font-size: {}px; color: {};", tokens::FONT_SIZE_LABEL, tokens::COLOR_TEXT_ON_CHROME_SECONDARY), + {fl!("editor-font-substitution-message")} + } + div { + style: "display: flex; flex-direction: column; gap: 6px;", + for item in items.iter() { + div { key: "{item.requested}", + if compact { {item_card(item)} } else { {item_row(item)} } } } } diff --git a/loki-text/src/routes/editor/editor_fonts.rs b/loki-text/src/routes/editor/editor_fonts.rs new file mode 100644 index 00000000..2120e08c --- /dev/null +++ b/loki-text/src/routes/editor/editor_fonts.rs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Font-related editor hooks and reads, split from `editor_inner` (300-line +//! ceiling budget). +//! +//! Both entry points are warm-up-aware: the shared [`FontResources`] handle +//! may still be scanning system fonts on its background thread when the editor +//! mounts, and nothing here may block the UI thread on that scan. +//! +//! [`FontResources`]: loki_layout::FontResources + +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; + +use dioxus::prelude::*; + +use super::editor_style_catalog::available_font_families; +use crate::editing::state::DocumentState; + +/// Enumerates the available font families once per editor (system + bundled + +/// document-embedded), memoised for the style editor's font picker. +/// +/// The enumeration blocks until the background font warm-up finishes, so it +/// runs on a worker thread and lands in the returned signal — the mount stays +/// cheap and the loading view can paint while a first-run system-font scan is +/// still going. Trade-off (unchanged from the synchronous version): faces +/// embedded after mount are not reflected until reopen. +pub(super) fn use_font_families(doc_state: &Arc>) -> Signal>> { + let mut font_families: Signal>> = use_signal(|| Rc::new(Vec::new())); + let ds_fonts = Arc::clone(doc_state); + use_hook(move || { + let fonts = ds_fonts + .lock() + .map(|s| s.shared_font_resources.clone()) + .unwrap_or_else(|e| e.into_inner().shared_font_resources.clone()); + let (tx, rx) = futures_channel::oneshot::channel(); + let spawned = std::thread::Builder::new() + .name("loki-font-families".into()) + .spawn(move || { + let _ = tx.send(available_font_families(&fonts)); + }); + if spawned.is_ok() { + spawn(async move { + if let Ok(names) = rx.await { + font_families.set(Rc::new(names)); + } + }); + } + }); + font_families +} + +/// The font substitutions recorded by the layout engine (requested → +/// substitute), for the status-bar chip and the detail panel. +/// +/// Non-blocking: while the warm-up scan or a layout worker holds the font +/// context this returns empty — "no substitutions this frame" — and the +/// publish that follows bumps the generation and re-renders with the real map. +pub(super) fn font_substitutions( + doc_state: &Arc>, +) -> HashMap> { + doc_state + .lock() + .ok() + .and_then(|s| { + s.shared_font_resources + .try_lock() + .map(|fr| fr.substitutions.clone()) + }) + .unwrap_or_default() +} diff --git a/loki-text/src/routes/editor/editor_inner.rs b/loki-text/src/routes/editor/editor_inner.rs index c9e60940..70d7fa4b 100644 --- a/loki-text/src/routes/editor/editor_inner.rs +++ b/loki-text/src/routes/editor/editor_inner.rs @@ -19,7 +19,6 @@ //! 3. All per-document state is reset synchronously when path changes so the //! reset happens before `use_resource` evaluates. -use std::rc::Rc; use std::sync::Arc; use appthere_ui::{AtRibbon, AtStatusBar, tokens, use_breakpoint}; @@ -49,7 +48,6 @@ use super::editor_save_banner::save_banner; use super::editor_spell::SpellMenu; use super::editor_state::{EditorState, StyleDraft, use_editor_state}; use super::editor_style::style_picker_panel; -use super::editor_style_catalog::available_font_families; use super::editor_style_editor::style_editor_panel; use crate::error::LoadError; use crate::sessions::DocSessions; @@ -67,8 +65,10 @@ pub(super) fn EditorInner(path: String) -> Element { // ── Path signal: bridge from prop-space to signal-space ────────────────── let mut path_signal: Signal = use_signal(|| path.clone()); - // ── Font warning dismiss state ─────────────────────────────────────────── - let mut dismiss_font_warning = use_signal(|| false); + // ── Font-substitution detail panel open state ──────────────────────────── + // Closed by default; the status-bar chip (shown whenever substitutions + // exist) toggles it. + let mut font_panel_open = use_signal(|| false); // ── Ribbon collapse state ──────────────────────────────────────────────── let mut ribbon_collapsed = use_signal(|| false); @@ -109,6 +109,9 @@ pub(super) fn EditorInner(path: String) -> Element { save_message, save_request, mut active_ribbon_tab, + open_color_picker, + recent_text_colors, + recent_highlights, is_publish_panel_open, pdf_level, editing_metadata, @@ -140,6 +143,25 @@ pub(super) fn EditorInner(path: String) -> Element { // "Clean" generation (matches disk), captured at load/save; tab is dirty when live gen differs. let mut baseline_gen = use_signal(|| 0_u64); + // The per-document signals reset or restored on tab switch, bundled for the + // three handover sites below (every field is a `Copy` signal). + let path_sync_signals = move || PathSyncSignals { + cursor_state, + loro_doc, + undo_manager, + total_pages, + current_page, + can_undo, + can_redo, + font_panel_open, + is_style_picker_open, + open_color_picker, + editing_style_draft, + save_message, + baseline_gen, + saved_state, + }; + // ── Session restore at mount ───────────────────────────────────────────── // // Navigating Editor → Home unmounts this component (different routes), so @@ -154,21 +176,7 @@ pub(super) fn EditorInner(path: String) -> Element { let initial_path = path_signal.peek().clone(); let restored = sessions_at_mount.write().remove(&initial_path); if let Some(session) = restored { - let mut sig = PathSyncSignals { - cursor_state, - loro_doc, - undo_manager, - total_pages, - current_page, - can_undo, - can_redo, - dismiss_font_warning, - is_style_picker_open, - editing_style_draft, - save_message, - baseline_gen, - saved_state, - }; + let mut sig = path_sync_signals(); restore_session(session, &doc_state_restore, &mut sig, path_signal); } }); @@ -185,21 +193,7 @@ pub(super) fn EditorInner(path: String) -> Element { let mut sessions_at_drop = doc_sessions; use_drop(move || { let path = path_signal.peek().clone(); - let mut sig = PathSyncSignals { - cursor_state, - loro_doc, - undo_manager, - total_pages, - current_page, - can_undo, - can_redo, - dismiss_font_warning, - is_style_picker_open, - editing_style_draft, - save_message, - baseline_gen, - saved_state, - }; + let mut sig = path_sync_signals(); stash_outgoing( &path, &doc_state_drop, @@ -221,21 +215,7 @@ pub(super) fn EditorInner(path: String) -> Element { &doc_state, tabs, doc_sessions, - &mut PathSyncSignals { - cursor_state, - loro_doc, - undo_manager, - total_pages, - current_page, - can_undo, - can_redo, - dismiss_font_warning, - is_style_picker_open, - editing_style_draft, - save_message, - baseline_gen, - saved_state, - }, + &mut path_sync_signals(), ); // Current paragraph style name, from signals — updates in the same render cycle as the cursor. @@ -268,14 +248,9 @@ pub(super) fn EditorInner(path: String) -> Element { let doc_state_render = Arc::clone(&doc_state); let doc_state_scroll = Arc::clone(&doc_state); - // Enumerate the available font families once per editor (system + bundled + - // document-embedded), memoised for the style editor's font picker. Scanning - // the Fontique collection on every render would be wasteful; the trade-off - // is that faces embedded after mount are not reflected until reopen. - let font_families: Rc> = { - let ds = Arc::clone(&doc_state); - use_hook(move || Rc::new(available_font_families(&ds))) - }; + // Font-family enumeration for the style editor's picker — async so the + // mount never blocks on the background system-font warm-up. + let font_families = super::editor_fonts::use_font_families(&doc_state); // ── Document load — reactive on path_signal ─────────────────────────────── let document_load: Resource<(String, Result)> = use_resource(move || { @@ -460,6 +435,7 @@ pub(super) fn EditorInner(path: String) -> Element { crate::editing::word_count::use_word_count_label(Arc::clone(&doc_state), cursor_state); // Unsaved-changes (dirty) tracking → tab indicator + ribbon Save state. + // Also clears a lingering success chip the moment the document goes dirty. super::editor_dirty::use_dirty_tracking( cursor_state, path_signal, @@ -467,7 +443,10 @@ pub(super) fn EditorInner(path: String) -> Element { saved_state, is_dirty, tabs, + save_message, ); + // Success statuses ("Document saved", …) clear themselves after a moment. + super::editor_save_banner::use_save_status_autoclear(save_message); // ── Save As / Save as Template (extracted flows: editor_save_callbacks) ── let save_as = super::editor_save_callbacks::use_save_as_callback( @@ -541,19 +520,9 @@ pub(super) fn EditorInner(path: String) -> Element { ) }; - // Font substitutions reported by the layout engine (requested → substitute). - // The redesigned warning UI lives in `editor_font_warning`; recovery (after - // dismiss) is the status-bar notice chip below. - let font_substitutions = doc_state - .lock() - .ok() - .and_then(|s| { - s.shared_font_resources - .lock() - .ok() - .map(|fr| fr.substitutions.clone()) - }) - .unwrap_or_default(); + // Font substitutions reported by the layout engine (requested → substitute): + // the status-bar chip is the indicator; the detail panel opens from it. + let font_substitutions = super::editor_fonts::font_substitutions(&doc_state); let font_sub_count = font_substitutions.len() as i64; rsx! { @@ -603,13 +572,28 @@ pub(super) fn EditorInner(path: String) -> Element { zoom_percent, )} - // ── Font-substitution warning (Spec 03 M3) ──────────────────────── - // Compact-by-default, expand-on-demand, breakpoint-aware (table vs. - // card stack). Renders nothing when empty or dismissed; recovery is - // the status-bar notice chip below. - super::editor_font_warning::FontWarning { + // ── Font-substitution detail panel (Spec 03 M3, inverted) ───────── + // The indicator is the status-bar chip below; this panel opens on + // demand from that chip. Breakpoint-aware (table vs. card stack); + // renders nothing while closed or when there are no substitutions. + super::editor_font_warning::FontSubstitutionPanel { substitutions: font_substitutions.clone(), - dismiss: dismiss_font_warning, + open: font_panel_open, + } + + // ── Colour-picker panel (inline, above ribbon) ──────────────────── + // Opened by the Format tab's Font colour / Highlight triggers. + if let Some(target) = open_color_picker() { + {super::editor_color_panel::color_picker_panel( + &doc_state_ribbon, + target, + open_color_picker, + super::editor_ribbon_format::RibbonEditCtx { + loro_doc, cursor_state, undo_manager, can_undo, can_redo, + }, + recent_text_colors, + recent_highlights, + )} } // ── Paragraph style picker panel (inline, above ribbon) ─────────── @@ -645,7 +629,7 @@ pub(super) fn EditorInner(path: String) -> Element { editing_page_style, style_panel_inspect, use_breakpoint(), - Rc::clone(&font_families), + font_families(), super::editor_style_editor::StyleEditorSync { loro_doc, cursor_state, @@ -705,7 +689,7 @@ pub(super) fn EditorInner(path: String) -> Element { )} } - // ── Save message banner ─────────────────────────────────────────── + // ── Save/export error banner (successes are the status chip) ───── {save_banner(save_message)} // ── Ribbon (formatting controls) ────────────────────────────────── @@ -722,14 +706,17 @@ pub(super) fn EditorInner(path: String) -> Element { fl!("ribbon-collapse-aria") }, tab_content: match active_ribbon_tab() { - 1 => insert_tab_content(link_draft, insert_ctx.clone()), - 6 if table_selected => super::editor_ribbon_table::table_tab_content( + 1 => super::editor_ribbon_span::format_tab_content( + loro_doc, cursor_state, open_color_picker, + ), + 2 => insert_tab_content(link_draft, insert_ctx.clone()), + 7 if table_selected => super::editor_ribbon_table::table_tab_content( &doc_state_ribbon, loro_doc, cursor_state, undo_manager, can_undo, can_redo, ), - 2 => super::editor_ribbon_layout::layout_tab_content(&doc_state_ribbon, loro_doc, cursor_state, undo_manager, can_undo, can_redo), - 3 => super::editor_ribbon_references::references_tab_content(&doc_state_ribbon, loro_doc, cursor_state, undo_manager, can_undo, can_redo), - 4 => super::editor_ribbon_review::review_tab_content(&doc_state_ribbon, loro_doc, cursor_state, undo_manager, can_undo, can_redo), - 5 => publish_tab_content( + 3 => super::editor_ribbon_layout::layout_tab_content(&doc_state_ribbon, loro_doc, cursor_state, undo_manager, can_undo, can_redo), + 4 => super::editor_ribbon_references::references_tab_content(&doc_state_ribbon, loro_doc, cursor_state, undo_manager, can_undo, can_redo), + 5 => super::editor_ribbon_review::review_tab_content(&doc_state_ribbon, loro_doc, cursor_state, undo_manager, can_undo, can_redo), + 6 => publish_tab_content( &doc_state_publish, path_signal, save_message, is_publish_panel_open, editing_metadata, ), @@ -786,14 +773,26 @@ pub(super) fn EditorInner(path: String) -> Element { }; view_mode.set(next); }, - // Recover a dismissed font-substitution warning (Spec 03 M3). - notice_label: if dismiss_font_warning() && font_sub_count > 0 { + // Font-substitution indicator (Spec 03 M3, inverted): the chip + // is the always-on signal that fonts were substituted; clicking + // it toggles the detail panel above the ribbon. + notice_label: if font_sub_count > 0 { fl!("editor-font-substitution-chip", count = font_sub_count) } else { String::new() }, notice_aria_label: fl!("editor-font-substitution-title"), - on_notice_click: move |_| { dismiss_font_warning.set(false); }, + on_notice_click: move |_| { + let v = *font_panel_open.peek(); + font_panel_open.set(!v); + }, + // Transient success chip ("Document saved", …). Auto-clears + // (use_save_status_autoclear) and clears on dirty; click = dismiss. + status_note_label: super::editor_save_banner::save_status_chip_label(save_message), + on_status_note_click: { + let mut save_message = save_message; + move |_| save_message.set(None) + }, } } } diff --git a/loki-text/src/routes/editor/editor_metadata_panel.rs b/loki-text/src/routes/editor/editor_metadata_panel.rs index 2e2c9daf..01cc32f3 100644 --- a/loki-text/src/routes/editor/editor_metadata_panel.rs +++ b/loki-text/src/routes/editor/editor_metadata_panel.rs @@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex}; +use super::editor_state::SaveStatus; use appthere_ui::{tokens, use_viewport}; use dioxus::prelude::*; use loki_i18n::fl; @@ -52,7 +53,7 @@ pub(super) struct MetaPanelSync { pub(super) fn metadata_panel( doc_state: Arc>, mut editing_metadata: Signal>, - mut save_message: Signal>, + mut save_message: Signal>, sync: MetaPanelSync, ) -> Element { let draft = match editing_metadata.read().clone() { @@ -160,7 +161,7 @@ pub(super) fn metadata_panel( sync.can_undo, sync.can_redo, ); - save_message.set(Some(fl!("metadata-saved"))); + save_message.set(Some(SaveStatus::ok(fl!("metadata-saved")))); } } editing_metadata.set(None); diff --git a/loki-text/src/routes/editor/editor_path_sync.rs b/loki-text/src/routes/editor/editor_path_sync.rs index be00eaa7..8f540c9a 100644 --- a/loki-text/src/routes/editor/editor_path_sync.rs +++ b/loki-text/src/routes/editor/editor_path_sync.rs @@ -14,7 +14,7 @@ use std::sync::{Arc, Mutex}; use dioxus::prelude::*; -use super::editor_state::StyleDraft; +use super::editor_state::{ColorPickerTarget, SaveStatus, StyleDraft}; use crate::editing::cursor::CursorState; use crate::editing::saved_state::SavedStateHandle; use crate::editing::state::DocumentState; @@ -31,10 +31,11 @@ pub(super) struct PathSyncSignals { pub current_page: Signal, pub can_undo: Signal, pub can_redo: Signal, - pub dismiss_font_warning: Signal, + pub font_panel_open: Signal, pub is_style_picker_open: Signal, + pub open_color_picker: Signal>, pub editing_style_draft: Signal>, - pub save_message: Signal>, + pub save_message: Signal>, pub baseline_gen: Signal, pub saved_state: Signal, } @@ -64,8 +65,9 @@ pub(super) fn sync_path_and_reset( stash_outgoing(¤t, doc_state, tabs, &mut sessions, sig); // Transient UI state never carries across documents. - sig.dismiss_font_warning.set(false); + sig.font_panel_open.set(false); sig.is_style_picker_open.set(false); + sig.open_color_picker.set(None); sig.editing_style_draft.set(None); sig.save_message.set(None); diff --git a/loki-text/src/routes/editor/editor_publish.rs b/loki-text/src/routes/editor/editor_publish.rs index 16da8eb7..13ae3d87 100644 --- a/loki-text/src/routes/editor/editor_publish.rs +++ b/loki-text/src/routes/editor/editor_publish.rs @@ -11,6 +11,7 @@ use std::io::{Cursor, Write}; use std::sync::{Arc, Mutex}; +use super::editor_state::SaveStatus; use appthere_ui::tokens; use dioxus::prelude::*; use loki_doc_model::io::DocumentExport; @@ -54,7 +55,7 @@ impl From for PdfXLevel { pub(super) fn publish_panel( doc_state: Arc>, path_signal: Signal, - save_message: Signal>, + save_message: Signal>, mut is_publish_panel_open: Signal, mut pdf_level: Signal, ) -> Element { @@ -130,12 +131,15 @@ pub(super) fn run_export( doc_state: &Arc>, format: PublishFormat, cur_path: &str, - mut save_message: Signal>, + mut save_message: Signal>, ) { let doc = match doc_state.lock().ok().and_then(|s| s.document.clone()) { Some(d) => d, None => { - save_message.set(Some(fl!("publish-export-error", reason = "no document"))); + save_message.set(Some(SaveStatus::error(fl!( + "publish-export-error", + reason = "no document" + )))); return; } }; @@ -156,23 +160,34 @@ pub(super) fn run_export( let bytes = match serialize(&doc, format) { Ok(b) => b, Err(e) => { - save_message.set(Some(fl!("publish-export-error", reason = e))); + save_message.set(Some(SaveStatus::error(fl!( + "publish-export-error", + reason = e + )))); return; } }; match token.open_write() { Ok(mut w) => match w.write_all(&bytes) { - Ok(()) => save_message.set(Some(fl!("publish-export-success"))), - Err(e) => save_message - .set(Some(fl!("publish-export-error", reason = e.to_string()))), + Ok(()) => { + save_message.set(Some(SaveStatus::ok(fl!("publish-export-success")))) + } + Err(e) => save_message.set(Some(SaveStatus::error(fl!( + "publish-export-error", + reason = e.to_string() + )))), }, - Err(e) => { - save_message.set(Some(fl!("publish-export-error", reason = e.to_string()))) - } + Err(e) => save_message.set(Some(SaveStatus::error(fl!( + "publish-export-error", + reason = e.to_string() + )))), } } - Ok(None) => save_message.set(Some(fl!("publish-export-cancelled"))), - Err(e) => save_message.set(Some(fl!("publish-export-error", reason = e.to_string()))), + Ok(None) => save_message.set(Some(SaveStatus::ok(fl!("publish-export-cancelled")))), + Err(e) => save_message.set(Some(SaveStatus::error(fl!( + "publish-export-error", + reason = e.to_string() + )))), } }); } diff --git a/loki-text/src/routes/editor/editor_ribbon.rs b/loki-text/src/routes/editor/editor_ribbon.rs index 79f49846..ebc8c709 100644 --- a/loki-text/src/routes/editor/editor_ribbon.rs +++ b/loki-text/src/routes/editor/editor_ribbon.rs @@ -61,8 +61,8 @@ pub(super) fn write_tab_content( let ds_undo = Arc::clone(doc_state); let ds_redo = Arc::clone(doc_state); - // The inline-formatting and alignment groups are extracted to - // `editor_ribbon_format` (ceiling). They share these live handles + states. + // The inline-formatting group is extracted to `editor_ribbon_format` + // (ceiling). It shares these live handles + states. let edit_ctx = super::editor_ribbon_format::RibbonEditCtx { loro_doc, cursor_state, @@ -78,25 +78,11 @@ pub(super) fn write_tab_content( superscript: superscript_active, subscript: subscript_active, }; - // Alignment of the caret's paragraph, for the alignment group's active state. - let current_align = loro_doc - .read() - .as_ref() - .map(|ldoc| super::editor_alignment::current_alignment(ldoc, &cursor_state.read())) - .unwrap_or_else(|| "Left".to_string()); - // Direct text colour / highlight at the caret, for the swatch groups' active state. - let current_color = loro_doc - .read() - .as_ref() - .and_then(|ldoc| super::editor_text_color::current_text_color(ldoc, &cursor_state.read())); - let current_highlight = loro_doc.read().as_ref().and_then(|ldoc| { - super::editor_highlight_color::current_highlight(ldoc, &cursor_state.read()) - }); // Collapse priorities (higher = kept full longer, Spec 04 M3 §7): the core - // editing controls (Inline, Alignment, Font, Styles) stay full the longest; - // the wide colour-swatch groups overflow first (they also reclaim the most - // strip width per overflow). + // editing controls (Inline, Styles) stay full the longest. Font/highlight + // colour live on the Format tab; paragraph alignment lives in the + // paragraph style editor. let document = RibbonGroupSpec { metrics: estimate_group_metrics(4, 3, true), label: Some(fl!("ribbon-group-document")), @@ -239,11 +225,7 @@ pub(super) fn write_tab_content( history, styles, paragraph, - super::editor_ribbon_format::font_group(doc_state, edit_ctx, 6), super::editor_ribbon_format::inline_format_group(doc_state, edit_ctx, inline_state, 8), - super::editor_ribbon_color::font_color_group(doc_state, edit_ctx, current_color, 1), - super::editor_ribbon_color::highlight_group(doc_state, edit_ctx, current_highlight, 0), - super::editor_ribbon_format::alignment_group(doc_state, edit_ctx, current_align, 7), ], } } diff --git a/loki-text/src/routes/editor/editor_ribbon_color.rs b/loki-text/src/routes/editor/editor_ribbon_color.rs index 8d8c3455..ea69dd6f 100644 --- a/loki-text/src/routes/editor/editor_ribbon_color.rs +++ b/loki-text/src/routes/editor/editor_ribbon_color.rs @@ -1,216 +1,181 @@ // SPDX-License-Identifier: Apache-2.0 -//! The Write tab's colour swatch groups: **Font colour** and **Highlight**. +//! Format-tab colour-picker trigger groups (**Font colour** / **Highlight**) +//! and the shared colour palettes. //! -//! Both are a "clear" swatch (revert to no override) plus a fixed palette of -//! preset colours rendered inside an [`AtRibbonIconButton`] (which accepts -//! arbitrary children). The generic [`swatch_group`] drives both; each group -//! supplies its palette and the mark-apply function. - -use std::sync::{Arc, Mutex}; +//! Each group is a single [`AtColorPickerTrigger`] whose click toggles the +//! docked picker panel (`editor_color_panel`) above the ribbon — the ribbon +//! content row is a scroll container, so the panel cannot be anchored inside +//! it (see the `appthere_ui` colour-picker module docs). +//! +//! Highlight is constrained to the named `HighlightColor` palette because the +//! document model (and DOCX `w:highlight`) only round-trips named variants. +//! TODO(highlight-custom-color): offering arbitrary hex highlight needs a +//! `HighlightColor` model extension plus OOXML (`w:shd`) / ODF export mapping. -use appthere_ui::{AtRibbonIconButton, RibbonGroupSpec, estimate_group_metrics, tokens}; +use appthere_ui::{ + AtColorPickerTrigger, AtColorSwatch, AtIcon, LUCIDE_BASELINE, LUCIDE_HIGHLIGHTER, + RibbonGroupSpec, estimate_group_metrics, +}; use dioxus::prelude::*; -use loki_doc_model::MutationError; use loki_i18n::fl; -use loro::LoroDoc; -use super::editor_highlight_color::apply_highlight; -use super::editor_ribbon_format::RibbonEditCtx; -use super::editor_text_color::apply_text_color; -use crate::editing::cursor::CursorState; -use crate::editing::state::DocumentState; +use super::editor_state::ColorPickerTarget; -/// One preset swatch: the mark `value` written on click, the `fill` colour of -/// the visible square (equal to `value` for font colour; a display colour for a -/// named highlight), and its `aria` key. -#[derive(Clone, Copy)] -pub(super) struct Swatch { - pub value: &'static str, - pub fill: &'static str, - pub aria: &'static str, -} +/// Recent colours kept per picker (most recent first). +const RECENT_MAX: usize = 6; -/// Preset text colours (fill == value, both the hex). Readable on a white page. -const FONT_COLOR_PALETTE: &[Swatch] = &[ - Swatch { - value: "#C0392B", - fill: "#C0392B", - aria: "ribbon-color-red-aria", - }, - Swatch { - value: "#E67E22", - fill: "#E67E22", - aria: "ribbon-color-orange-aria", - }, - Swatch { - value: "#F1C40F", - fill: "#F1C40F", - aria: "ribbon-color-yellow-aria", - }, - Swatch { - value: "#27AE60", - fill: "#27AE60", - aria: "ribbon-color-green-aria", - }, - Swatch { - value: "#2980B9", - fill: "#2980B9", - aria: "ribbon-color-blue-aria", - }, - Swatch { - value: "#8E44AD", - fill: "#8E44AD", - aria: "ribbon-color-purple-aria", - }, +/// Preset text colours as `(value, fill, aria-key)` — fill == value (a hex). +/// Readable on a white page. +pub(super) const FONT_COLOR_PALETTE: &[(&str, &str, &str)] = &[ + ("#C0392B", "#C0392B", "ribbon-color-red-aria"), + ("#E67E22", "#E67E22", "ribbon-color-orange-aria"), + ("#F1C40F", "#F1C40F", "ribbon-color-yellow-aria"), + ("#27AE60", "#27AE60", "ribbon-color-green-aria"), + ("#2980B9", "#2980B9", "ribbon-color-blue-aria"), + ("#8E44AD", "#8E44AD", "ribbon-color-purple-aria"), ]; -/// Preset highlight colours: the mark `value` is a `HighlightColor` variant -/// name; the `fill` is the RGB that variant renders as (`resolve::map_highlight_color`). -const HIGHLIGHT_PALETTE: &[Swatch] = &[ - Swatch { - value: "Yellow", - fill: "#FFFF00", - aria: "ribbon-highlight-yellow-aria", - }, - Swatch { - value: "Green", - fill: "#00FF00", - aria: "ribbon-highlight-green-aria", - }, - Swatch { - value: "Cyan", - fill: "#00FFFF", - aria: "ribbon-highlight-cyan-aria", - }, - Swatch { - value: "Magenta", - fill: "#FF00FF", - aria: "ribbon-highlight-magenta-aria", - }, - Swatch { - value: "Red", - fill: "#FF0000", - aria: "ribbon-highlight-red-aria", - }, +/// The full named highlight palette as `(variant name, fill, aria-key)`. The +/// fills mirror `loki-layout`'s `map_highlight_color` so the swatch matches +/// what the page paints. +pub(super) const HIGHLIGHT_PALETTE: &[(&str, &str, &str)] = &[ + ("Yellow", "#FFFF00", "ribbon-highlight-yellow-aria"), + ("Green", "#00FF00", "ribbon-highlight-green-aria"), + ("Cyan", "#00FFFF", "ribbon-highlight-cyan-aria"), + ("Magenta", "#FF00FF", "ribbon-highlight-magenta-aria"), + ("Blue", "#0000FF", "ribbon-highlight-blue-aria"), + ("Red", "#FF0000", "ribbon-highlight-red-aria"), + ("DarkBlue", "#000080", "ribbon-highlight-dark-blue-aria"), + ("DarkCyan", "#008080", "ribbon-highlight-dark-cyan-aria"), + ("DarkGreen", "#008000", "ribbon-highlight-dark-green-aria"), + ( + "DarkMagenta", + "#800080", + "ribbon-highlight-dark-magenta-aria", + ), + ("DarkRed", "#800000", "ribbon-highlight-dark-red-aria"), + ("DarkYellow", "#808000", "ribbon-highlight-dark-yellow-aria"), + ("DarkGray", "#808080", "ribbon-highlight-dark-gray-aria"), + ("LightGray", "#C0C0C0", "ribbon-highlight-light-gray-aria"), + ("Black", "#000000", "ribbon-highlight-black-aria"), + ("White", "#FFFFFF", "ribbon-highlight-white-aria"), ]; -/// A colour swatch square filled with `hex`. -fn square(hex: &str) -> Element { - rsx! { - div { - style: format!( - "width: 18px; height: 18px; border-radius: {r}px; background: {hex}; \ - border: 1px solid rgba(0,0,0,0.25);", - r = tokens::RADIUS_SM, - ), - } - } +/// The display fill for a named highlight value, if known. +pub(super) fn highlight_fill(name: &str) -> Option<&'static str> { + HIGHLIGHT_PALETTE + .iter() + .find(|(value, _, _)| *value == name) + .map(|(_, fill, _)| *fill) } -/// A swatch group: a "clear" button (outlined square, applies `None`) plus one -/// filled button per palette entry. `apply` writes the mark for the picked -/// value; `current` is the active mark value (drives the highlighted swatch). -#[allow(clippy::too_many_arguments)] -fn swatch_group( - doc_state: &Arc>, - ctx: RibbonEditCtx, - group_aria: String, - clear_aria: String, - current: Option, - palette: &'static [Swatch], - apply: fn(&LoroDoc, &CursorState, Option<&str>) -> Result<(), MutationError>, +/// A preset palette as picker swatches (aria labels resolved via `fl!`). +pub(super) fn preset_swatches(palette: &'static [(&str, &str, &str)]) -> Vec { + palette + .iter() + .map(|(value, fill, aria)| AtColorSwatch { + value: (*value).to_string(), + fill: (*fill).to_string(), + aria_label: fl!(aria), + }) + .collect() +} + +/// Recent values → swatches. `fill_for` maps a stored value to its display +/// fill (identity for hex text colours, the palette lookup for highlights). +pub(super) fn recent_swatches( + values: &[String], + fill_for: fn(&str) -> String, +) -> Vec { + values + .iter() + .map(|v| AtColorSwatch { + value: v.clone(), + fill: fill_for(v), + // A colour value is data, not prose — announced as-is. + aria_label: v.clone(), + }) + .collect() +} + +/// Records a pick at the front of the recent list (deduplicated, capped). +pub(super) fn push_recent(mut recent: Signal>, value: String) { + let mut list = recent.peek().clone(); + list.retain(|v| v != &value); + list.insert(0, value); + list.truncate(RECENT_MAX); + recent.set(list); +} + +/// Shared builder: one trigger group toggling the docked panel for `target`. +fn trigger_group( + group_label: String, + trigger_aria: String, + trigger_icon: &'static str, + current_fill: Option, + target: ColorPickerTarget, + mut open_picker: Signal>, priority: u8, ) -> RibbonGroupSpec { - let ds_clear = Arc::clone(doc_state); - let loro = ctx.loro_doc; - let cursor = ctx.cursor_state; - RibbonGroupSpec { - // One clear swatch + one button per palette colour. - metrics: estimate_group_metrics(priority, palette.len() + 1, true), - label: Some(group_aria.clone()), - aria_label: group_aria, + metrics: estimate_group_metrics(priority, 1, true), + label: Some(group_label.clone()), + aria_label: group_label, content: rsx! { - AtRibbonIconButton { - aria_label: clear_aria, - is_active: current.is_none(), - is_disabled: false, - on_click: move |_| { - if let Some(ldoc) = loro.read().as_ref() - && apply(ldoc, &cursor.read(), None).is_ok() - { - ctx.finish(&ds_clear, ldoc); - } + AtColorPickerTrigger { + aria_label: trigger_aria, + current_fill: current_fill, + is_open: open_picker() == Some(target), + on_toggle: move |_| { + let next = if *open_picker.peek() == Some(target) { + None + } else { + Some(target) + }; + open_picker.set(next); }, - // An outlined (empty) square = "no colour override". - div { - style: format!( - "width: 18px; height: 18px; border-radius: {r}px; background: transparent; \ - border: 1px solid {b};", - r = tokens::RADIUS_SM, - b = tokens::COLOR_TEXT_ON_CHROME_SECONDARY, - ), - } - } - - for sw in palette.iter().copied() { - AtRibbonIconButton { - key: "{sw.value}", - aria_label: fl!(sw.aria), - is_active: current.as_deref() == Some(sw.value), - is_disabled: false, - on_click: { - let ds = Arc::clone(doc_state); - move |_| { - if let Some(ldoc) = loro.read().as_ref() - && apply(ldoc, &cursor.read(), Some(sw.value)).is_ok() - { - ctx.finish(&ds, ldoc); - } - } - }, - {square(sw.fill)} - } + AtIcon { path_d: trigger_icon.to_string() } } }, } } -/// The Font colour group. +/// The Font colour trigger group. `current` is the direct colour hex at the +/// caret (also the indicator fill). pub(super) fn font_color_group( - doc_state: &Arc>, - ctx: RibbonEditCtx, current: Option, + open_picker: Signal>, priority: u8, ) -> RibbonGroupSpec { - swatch_group( - doc_state, - ctx, + trigger_group( fl!("ribbon-group-font-color"), - fl!("ribbon-color-automatic-aria"), + fl!("ribbon-font-color-picker-aria"), + LUCIDE_BASELINE, current, - FONT_COLOR_PALETTE, - apply_text_color, + ColorPickerTarget::Text, + open_picker, priority, ) } -/// The Highlight colour group. +/// The Highlight trigger group. `current` is the named highlight at the caret. pub(super) fn highlight_group( - doc_state: &Arc>, - ctx: RibbonEditCtx, current: Option, + open_picker: Signal>, priority: u8, ) -> RibbonGroupSpec { - swatch_group( - doc_state, - ctx, + let current_fill = current + .as_deref() + .and_then(highlight_fill) + .map(str::to_string); + trigger_group( fl!("ribbon-group-highlight"), - fl!("ribbon-highlight-none-aria"), - current, - HIGHLIGHT_PALETTE, - apply_highlight, + fl!("ribbon-highlight-picker-aria"), + LUCIDE_HIGHLIGHTER, + current_fill, + ColorPickerTarget::Highlight, + open_picker, priority, ) } diff --git a/loki-text/src/routes/editor/editor_ribbon_format.rs b/loki-text/src/routes/editor/editor_ribbon_format.rs index 73990b2d..d8696ced 100644 --- a/loki-text/src/routes/editor/editor_ribbon_format.rs +++ b/loki-text/src/routes/editor/editor_ribbon_format.rs @@ -1,25 +1,21 @@ // SPDX-License-Identifier: Apache-2.0 -//! The Write tab's inline-formatting and paragraph-alignment ribbon groups. +//! The Write tab's inline-formatting ribbon group. //! //! Extracted from `editor_ribbon` so `write_tab_content` stays under the -//! 300-line ceiling. Both groups apply their mutation to the live document, -//! relayout, and sync undo/redo — the same path keyboard shortcuts use. +//! 300-line ceiling. The group applies its mutation to the live document, +//! relayouts, and syncs undo/redo — the same path keyboard shortcuts use. use std::sync::{Arc, Mutex}; use appthere_ui::{ - AT_FONT_GROW, AT_FONT_SHRINK, AtIcon, AtRibbonIconButton, LUCIDE_ALIGN_CENTER, - LUCIDE_ALIGN_JUSTIFY, LUCIDE_ALIGN_LEFT, LUCIDE_ALIGN_RIGHT, LUCIDE_BOLD, LUCIDE_ITALIC, - LUCIDE_STRIKETHROUGH, LUCIDE_SUBSCRIPT, LUCIDE_SUPERSCRIPT, LUCIDE_UNDERLINE, RibbonGroupSpec, - estimate_group_metrics, + AtIcon, AtRibbonIconButton, LUCIDE_BOLD, LUCIDE_ITALIC, LUCIDE_STRIKETHROUGH, LUCIDE_SUBSCRIPT, + LUCIDE_SUPERSCRIPT, LUCIDE_UNDERLINE, RibbonGroupSpec, estimate_group_metrics, }; use dioxus::prelude::*; use loki_i18n::fl; use loro::LoroDoc; -use super::editor_alignment::apply_alignment; -use super::editor_font_size::adjust_font_size; use super::editor_formatting; use super::editor_keydown_ctrl::post_mutation_sync; use crate::editing::cursor::CursorState; @@ -160,97 +156,3 @@ pub(super) fn inline_format_group( }, } } - -/// The Font group — grow / shrink the selection's font size by one step. -pub(super) fn font_group( - doc_state: &Arc>, - ctx: RibbonEditCtx, - priority: u8, -) -> RibbonGroupSpec { - let ds_grow = Arc::clone(doc_state); - let ds_shrink = Arc::clone(doc_state); - let loro = ctx.loro_doc; - let cursor = ctx.cursor_state; - RibbonGroupSpec { - metrics: estimate_group_metrics(priority, 2, true), - label: Some(fl!("ribbon-group-font")), - aria_label: fl!("ribbon-group-font"), - content: rsx! { - AtRibbonIconButton { - aria_label: fl!("ribbon-font-grow-aria"), - is_active: false, - is_disabled: false, - on_click: move |_| { - if let Some(ldoc) = loro.read().as_ref() - && adjust_font_size(ldoc, &cursor.read(), true).is_ok() - { - ctx.finish(&ds_grow, ldoc); - } - }, - AtIcon { path_d: AT_FONT_GROW.to_string() } - } - AtRibbonIconButton { - aria_label: fl!("ribbon-font-shrink-aria"), - is_active: false, - is_disabled: false, - on_click: move |_| { - if let Some(ldoc) = loro.read().as_ref() - && adjust_font_size(ldoc, &cursor.read(), false).is_ok() - { - ctx.finish(&ds_shrink, ldoc); - } - }, - AtIcon { path_d: AT_FONT_SHRINK.to_string() } - } - }, - } -} - -/// One alignment button. `value` is the para-props alignment (`"Left"`, …). -fn align_button( - doc_state: &Arc>, - ctx: RibbonEditCtx, - current: &str, - value: &'static str, - aria: String, - icon: &'static str, -) -> Element { - let ds = Arc::clone(doc_state); - let loro = ctx.loro_doc; - let cursor = ctx.cursor_state; - rsx! { - AtRibbonIconButton { - aria_label: aria, - is_active: current == value, - is_disabled: false, - on_click: move |_| { - if let Some(ldoc) = loro.read().as_ref() - && apply_alignment(ldoc, &cursor.read(), value).is_ok() - { - ctx.finish(&ds, ldoc); - } - }, - AtIcon { path_d: icon.to_string() } - } - } -} - -/// The Paragraph alignment group (left / centre / right / justify). -pub(super) fn alignment_group( - doc_state: &Arc>, - ctx: RibbonEditCtx, - current_align: String, - priority: u8, -) -> RibbonGroupSpec { - RibbonGroupSpec { - metrics: estimate_group_metrics(priority, 4, true), - label: Some(fl!("ribbon-group-alignment")), - aria_label: fl!("ribbon-group-alignment"), - content: rsx! { - {align_button(doc_state, ctx, ¤t_align, "Left", fl!("ribbon-align-left-aria"), LUCIDE_ALIGN_LEFT)} - {align_button(doc_state, ctx, ¤t_align, "Center", fl!("ribbon-align-centre-aria"), LUCIDE_ALIGN_CENTER)} - {align_button(doc_state, ctx, ¤t_align, "Right", fl!("ribbon-align-right-aria"), LUCIDE_ALIGN_RIGHT)} - {align_button(doc_state, ctx, ¤t_align, "Justify", fl!("ribbon-align-justify-aria"), LUCIDE_ALIGN_JUSTIFY)} - }, - } -} diff --git a/loki-text/src/routes/editor/editor_ribbon_insert.rs b/loki-text/src/routes/editor/editor_ribbon_insert.rs index 60760b40..f95baff7 100644 --- a/loki-text/src/routes/editor/editor_ribbon_insert.rs +++ b/loki-text/src/routes/editor/editor_ribbon_insert.rs @@ -16,6 +16,7 @@ use std::sync::{Arc, Mutex}; +use super::editor_state::SaveStatus; use appthere_ui::{ AtIcon, AtRibbonGroups, AtRibbonIconButton, LUCIDE_FOOTNOTE, LUCIDE_IMAGE, LUCIDE_LINK, LUCIDE_TABLE, RibbonGroupSpec, estimate_group_metrics, @@ -48,7 +49,7 @@ pub(super) struct InsertCtx { /// Whether redo is available. pub can_redo: Signal, /// Status-banner sink for success / error feedback. - pub save_message: Signal>, + pub save_message: Signal>, } /// Builds the Insert tab ribbon content element. @@ -219,9 +220,9 @@ fn run_insert( if let Some(pos) = target { set_collapsed_cursor(&ctx.doc_state, ctx.cursor_state, pos); } - save_message.set(Some(success)); + save_message.set(Some(SaveStatus::ok(success))); } - Err(true) => save_message.set(Some(fl!("editor-insert-no-cursor"))), - Err(false) => save_message.set(Some(fl!("editor-insert-failed"))), + Err(true) => save_message.set(Some(SaveStatus::error(fl!("editor-insert-no-cursor")))), + Err(false) => save_message.set(Some(SaveStatus::error(fl!("editor-insert-failed")))), } } diff --git a/loki-text/src/routes/editor/editor_ribbon_insert_image.rs b/loki-text/src/routes/editor/editor_ribbon_insert_image.rs index cf3e9bcd..a4bee646 100644 --- a/loki-text/src/routes/editor/editor_ribbon_insert_image.rs +++ b/loki-text/src/routes/editor/editor_ribbon_insert_image.rs @@ -9,6 +9,7 @@ use std::io::Read; +use super::editor_state::SaveStatus; use dioxus::prelude::*; use loki_file_access::{FileAccessToken, FilePicker, PickOptions}; use loki_i18n::fl; @@ -56,15 +57,17 @@ pub(super) fn spawn_pick_and_insert_image(ctx: InsertCtx) { let bytes = match read_token_bytes(&token) { Ok(b) => b, Err(e) => { - save_message.set(Some(fl!( + save_message.set(Some(SaveStatus::error(fl!( "editor-insert-image-error", reason = e.to_string() - ))); + )))); return; } }; let Some(image) = image_inline_from_bytes(&bytes) else { - save_message.set(Some(fl!("editor-insert-image-unsupported"))); + save_message.set(Some(SaveStatus::error(fl!( + "editor-insert-image-unsupported" + )))); return; }; let outcome = { @@ -93,17 +96,22 @@ pub(super) fn spawn_pick_and_insert_image(ctx: InsertCtx) { ctx.can_undo, ctx.can_redo, ); - save_message.set(Some(fl!("editor-insert-image-success"))); + save_message.set(Some(SaveStatus::ok(fl!("editor-insert-image-success")))); } - Some(false) => save_message.set(Some(fl!("editor-insert-image-no-cursor"))), - None => save_message.set(Some(fl!("editor-insert-image-error", reason = "—"))), + Some(false) => save_message.set(Some(SaveStatus::error(fl!( + "editor-insert-image-no-cursor" + )))), + None => save_message.set(Some(SaveStatus::error(fl!( + "editor-insert-image-error", + reason = "—" + )))), } } Ok(None) => { /* user cancelled — no-op */ } - Err(e) => save_message.set(Some(fl!( + Err(e) => save_message.set(Some(SaveStatus::error(fl!( "editor-insert-image-error", reason = e.to_string() - ))), + )))), } }); } diff --git a/loki-text/src/routes/editor/editor_ribbon_publish.rs b/loki-text/src/routes/editor/editor_ribbon_publish.rs index 4408aa38..d7054ce9 100644 --- a/loki-text/src/routes/editor/editor_ribbon_publish.rs +++ b/loki-text/src/routes/editor/editor_ribbon_publish.rs @@ -10,6 +10,7 @@ use std::sync::{Arc, Mutex}; +use super::editor_state::SaveStatus; use appthere_ui::{ AtRibbonGroups, AtRibbonIconButton, RibbonGroupSpec, estimate_group_metrics, tokens, }; @@ -24,7 +25,7 @@ use crate::editing::state::DocumentState; pub(super) fn publish_tab_content( doc_state: &Arc>, path_signal: Signal, - save_message: Signal>, + save_message: Signal>, mut is_publish_panel_open: Signal, mut editing_metadata: Signal>, ) -> Element { diff --git a/loki-text/src/routes/editor/editor_ribbon_span.rs b/loki-text/src/routes/editor/editor_ribbon_span.rs new file mode 100644 index 00000000..2f18e9eb --- /dev/null +++ b/loki-text/src/routes/editor/editor_ribbon_span.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Format tab ribbon content — span-level (character) colour formatting. +//! +//! [`format_tab_content`] hosts the controls that colour the selected text: +//! the **Font colour** and **Highlight** picker triggers (moved off the Write +//! tab, which keeps only the core writing controls). Each trigger toggles the +//! docked picker panel (`editor_color_panel`) above the ribbon. + +use appthere_ui::AtRibbonGroups; +use dioxus::prelude::*; +use loki_i18n::fl; +use loro::LoroDoc; + +use super::editor_ribbon_color::{font_color_group, highlight_group}; +use super::editor_state::ColorPickerTarget; +use crate::editing::cursor::CursorState; + +/// Builds the Format tab ribbon content element. +/// +/// `open_color_picker` is the docked panel's open state, owned by +/// `EditorState`; the triggers toggle it and the panel itself is mounted by +/// `EditorInner` above the ribbon. +pub(super) fn format_tab_content( + loro_doc: Signal>, + cursor_state: Signal, + open_color_picker: Signal>, +) -> Element { + // Direct text colour / highlight at the caret — drives each trigger's + // indicator bar (and the active swatch once the panel opens). + let current_color = loro_doc + .read() + .as_ref() + .and_then(|ldoc| super::editor_text_color::current_text_color(ldoc, &cursor_state.read())); + let current_highlight = loro_doc.read().as_ref().and_then(|ldoc| { + super::editor_highlight_color::current_highlight(ldoc, &cursor_state.read()) + }); + + rsx! { + AtRibbonGroups { + overflow_aria_label: fl!("ribbon-overflow-aria"), + groups: vec![ + font_color_group(current_color, open_color_picker, 1), + highlight_group(current_highlight, open_color_picker, 0), + ], + } + } +} diff --git a/loki-text/src/routes/editor/editor_ribbon_table.rs b/loki-text/src/routes/editor/editor_ribbon_table.rs index 862c132d..96d6be7a 100644 --- a/loki-text/src/routes/editor/editor_ribbon_table.rs +++ b/loki-text/src/routes/editor/editor_ribbon_table.rs @@ -25,10 +25,10 @@ use crate::editing::cursor::CursorState; use crate::editing::selected_object::{SelectedObject, selected_object}; use crate::editing::state::DocumentState; -/// Index of the Table contextual tab in the ribbon strip — it follows the six -/// core tabs (Write=0, Insert=1, Layout=2, References=3, Review=4, Publish=5), -/// so any `active_tab >= 6` is the contextual tab. -const CONTEXTUAL_TAB_INDEX: usize = 6; +/// Index of the Table contextual tab in the ribbon strip — it follows the +/// seven core tabs (Write=0, Format=1, Insert=2, Layout=3, References=4, +/// Review=5, Publish=6), so any `active_tab >= 7` is the contextual tab. +const CONTEXTUAL_TAB_INDEX: usize = 7; /// The ribbon tab descriptors for the current `selected` object: the four core /// tabs, plus the Table contextual tab (amber) when the caret is in a table. @@ -41,6 +41,11 @@ pub(super) fn ribbon_tabs(selected: SelectedObject) -> Vec { is_contextual: false, aria_label: None, }, + RibbonTabDesc { + label: fl!("ribbon-tab-format"), + is_contextual: false, + aria_label: None, + }, RibbonTabDesc { label: fl!("ribbon-tab-insert"), is_contextual: false, diff --git a/loki-text/src/routes/editor/editor_ribbon_table_tests.rs b/loki-text/src/routes/editor/editor_ribbon_table_tests.rs index 525a0be4..64f0abb0 100644 --- a/loki-text/src/routes/editor/editor_ribbon_table_tests.rs +++ b/loki-text/src/routes/editor/editor_ribbon_table_tests.rs @@ -11,8 +11,8 @@ fn no_selection_shows_only_the_core_tabs() { let tabs = ribbon_tabs(SelectedObject::None); assert_eq!( tabs.len(), - 6, - "Write, Insert, Layout, References, Review, Publish" + 7, + "Write, Format, Insert, Layout, References, Review, Publish" ); assert!( tabs.iter().all(|t| !t.is_contextual), @@ -23,11 +23,11 @@ fn no_selection_shows_only_the_core_tabs() { #[test] fn table_selection_appends_a_contextual_tab() { let tabs = ribbon_tabs(SelectedObject::Table); - assert_eq!(tabs.len(), 7, "the Table tab is appended"); - // The six core tabs stay non-contextual... - assert!(tabs[..6].iter().all(|t| !t.is_contextual)); + assert_eq!(tabs.len(), 8, "the Table tab is appended"); + // The seven core tabs stay non-contextual... + assert!(tabs[..7].iter().all(|t| !t.is_contextual)); // ...and the appended Table tab is contextual (renders amber). - assert!(tabs[6].is_contextual, "the Table tab is contextual"); + assert!(tabs[7].is_contextual, "the Table tab is contextual"); } #[test] @@ -35,6 +35,6 @@ fn the_contextual_tab_sits_at_the_reserved_index() { // The reset logic keys off `active_tab >= CONTEXTUAL_TAB_INDEX`; that index // must be exactly where `ribbon_tabs` puts the contextual tab. let tabs = ribbon_tabs(SelectedObject::Table); - assert_eq!(CONTEXTUAL_TAB_INDEX, 6); + assert_eq!(CONTEXTUAL_TAB_INDEX, 7); assert!(tabs[CONTEXTUAL_TAB_INDEX].is_contextual); } diff --git a/loki-text/src/routes/editor/editor_save_banner.rs b/loki-text/src/routes/editor/editor_save_banner.rs index 1ae5f4ea..80d81001 100644 --- a/loki-text/src/routes/editor/editor_save_banner.rs +++ b/loki-text/src/routes/editor/editor_save_banner.rs @@ -1,18 +1,28 @@ // SPDX-License-Identifier: Apache-2.0 -//! Transient save-status banner shown below the panels, above the ribbon. +//! Transient status surfaces for save/export/insert results. //! -//! Extracted from `editor_inner` (which is over the 300-line ceiling) so adding -//! the spelling panels there is net line-neutral. +//! Successes render as an auto-clearing chip in the status bar (wired in +//! `editor_inner`; the auto-clear lives in [`use_save_status_autoclear`]). +//! Errors render here as a persistent banner below the panels, above the +//! ribbon, dismissed manually — failures must not silently vanish. + +use std::time::Duration; use appthere_ui::tokens; use dioxus::prelude::*; -/// Renders the save-status banner when `save_message` is `Some`, with a close -/// button that clears it. -pub(super) fn save_banner(mut save_message: Signal>) -> Element { - let Some(msg) = save_message.read().clone() else { - return rsx! {}; +use super::editor_state::SaveStatus; + +/// How long a success chip stays before clearing itself. +const AUTO_CLEAR_MS: u64 = 4000; + +/// Renders the error banner when `save_message` holds an error, with a close +/// button that clears it. Successes are the status-bar chip, not this banner. +pub(super) fn save_banner(mut save_message: Signal>) -> Element { + let msg = match save_message.read().as_ref() { + Some(status) if status.is_error => status.text.clone(), + _ => return rsx! {}, }; rsx! { div { @@ -45,3 +55,43 @@ pub(super) fn save_banner(mut save_message: Signal>) -> Element { } } } + +/// The status-bar chip label: the current *success* status text, or empty +/// (which hides the chip). Errors render in the banner instead. +pub(super) fn save_status_chip_label(save_message: Signal>) -> String { + save_message + .read() + .as_ref() + .filter(|status| !status.is_error) + .map(|status| status.text.clone()) + .unwrap_or_default() +} + +/// Clears a *success* status a few seconds after it appears (errors persist +/// until dismissed). A worker thread sleeps and signals back through a oneshot +/// — the same cross-thread yield pattern as the open-path layout task — and +/// the clear is skipped if a newer status replaced this one meanwhile. +pub(super) fn use_save_status_autoclear(mut save_message: Signal>) { + use_effect(move || { + let Some(status) = save_message.read().clone() else { + return; + }; + if status.is_error { + return; + } + let (tx, rx) = futures_channel::oneshot::channel(); + let spawned = std::thread::Builder::new() + .name("loki-status-clear".into()) + .spawn(move || { + std::thread::sleep(Duration::from_millis(AUTO_CLEAR_MS)); + let _ = tx.send(()); + }); + if spawned.is_ok() { + spawn(async move { + if rx.await.is_ok() && save_message.peek().as_ref() == Some(&status) { + save_message.set(None); + } + }); + } + }); +} diff --git a/loki-text/src/routes/editor/editor_save_callbacks.rs b/loki-text/src/routes/editor/editor_save_callbacks.rs index 9b98f8a7..d7f47e5c 100644 --- a/loki-text/src/routes/editor/editor_save_callbacks.rs +++ b/loki-text/src/routes/editor/editor_save_callbacks.rs @@ -12,6 +12,7 @@ use loki_file_access::{FilePicker, SaveOptions}; use loki_i18n::fl; use super::editor_save::{export_document_to_token, export_template_to_token}; +use super::editor_state::SaveStatus; use crate::editing::state::DocumentState; use crate::recent_documents::RecentDocuments; use crate::routes::Route; @@ -31,7 +32,7 @@ const DOTX_MIME: &str = "application/vnd.openxmlformats-officedocument.wordproce /// an untitled document. pub(super) fn use_save_as_callback( doc_state: Arc>, - save_message: Signal>, + save_message: Signal>, baseline_gen: Signal, path_signal: Signal, ) -> Callback<()> { @@ -71,19 +72,25 @@ pub(super) fn use_save_as_callback( } recent.write().record(new_path.clone(), new_title); recent.read().save(); - save_message.set(Some(fl!("editor-save-success"))); + save_message.set(Some(SaveStatus::ok(fl!("editor-save-success")))); // Navigate to the saved file; the editor reloads it and // re-establishes a clean baseline. baseline_gen.set(0); nav.push(Route::Editor { path: new_path }); } Err(e) => { - save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); + save_message.set(Some(SaveStatus::error(fl!( + "editor-save-error", + reason = e.to_string() + )))); } }, Ok(None) => { /* user cancelled — no-op */ } Err(e) => { - save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); + save_message.set(Some(SaveStatus::error(fl!( + "editor-save-error", + reason = e.to_string() + )))); } } }); @@ -95,7 +102,7 @@ pub(super) fn use_save_as_callback( /// repoint the tab — the template is a separate artifact. pub(super) fn use_save_as_template_callback( doc_state: Arc>, - save_message: Signal>, + save_message: Signal>, path_signal: Signal, ) -> Callback<()> { use_callback(move |_: ()| { @@ -111,14 +118,19 @@ pub(super) fn use_save_as_template_callback( match picker.pick_file_to_save(opts).await { Ok(Some(token)) => { let msg = match export_template_to_token(&token, &doc_state) { - Ok(()) => fl!("editor-save-template-success"), - Err(e) => fl!("editor-save-error", reason = e.to_string()), + Ok(()) => SaveStatus::ok(fl!("editor-save-template-success")), + Err(e) => { + SaveStatus::error(fl!("editor-save-error", reason = e.to_string())) + } }; save_message.set(Some(msg)); } Ok(None) => { /* user cancelled — no-op */ } Err(e) => { - save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); + save_message.set(Some(SaveStatus::error(fl!( + "editor-save-error", + reason = e.to_string() + )))); } } }); @@ -138,7 +150,7 @@ pub(super) struct CtrlSCtx { pub saved_state: Signal, pub can_undo: Signal, pub can_redo: Signal, - pub save_message: Signal>, + pub save_message: Signal>, } /// The Ctrl+S save effect: the keydown handler bumps `save_request`; the save @@ -189,9 +201,9 @@ pub(super) fn use_ctrl_s_save(ctx: CtrlSCtx) { can_redo, &doc_state, ); - fl!("editor-save-success") + SaveStatus::ok(fl!("editor-save-success")) } - Err(e) => fl!("editor-save-error", reason = e.to_string()), + Err(e) => SaveStatus::error(fl!("editor-save-error", reason = e.to_string())), }; save_message.set(Some(msg)); }); diff --git a/loki-text/src/routes/editor/editor_spell.rs b/loki-text/src/routes/editor/editor_spell.rs index 16d760cf..ac85859f 100644 --- a/loki-text/src/routes/editor/editor_spell.rs +++ b/loki-text/src/routes/editor/editor_spell.rs @@ -216,7 +216,7 @@ fn force_full_relayout( }; let Some(doc) = doc else { return }; let laid_out = { - let mut fr = fr_arc.lock().unwrap_or_else(|e| e.into_inner()); + let mut fr = fr_arc.lock(); // `None` previous → full pass, so every paragraph re-checks under the new // generation rather than reusing cached pages. relayout_paginated(&mut fr, &doc, None) diff --git a/loki-text/src/routes/editor/editor_state.rs b/loki-text/src/routes/editor/editor_state.rs index cafc30b2..50451468 100644 --- a/loki-text/src/routes/editor/editor_state.rs +++ b/loki-text/src/routes/editor/editor_state.rs @@ -80,6 +80,45 @@ impl Default for StyleDraft { } } +/// A transient status line (save/export/insert results, style-edit errors). +/// +/// Successes surface as an auto-clearing status-bar chip (cleared after a few +/// seconds, or immediately once the document is edited again, so a stale +/// "Document saved" can never sit over a dirty document). Errors keep the +/// persistent banner so failures are not silently hidden. +#[derive(Clone, PartialEq)] +pub(super) struct SaveStatus { + pub text: String, + pub is_error: bool, +} + +impl SaveStatus { + /// A success/info status — shown as the auto-clearing status-bar chip. + pub fn ok(text: String) -> Self { + Self { + text, + is_error: false, + } + } + + /// An error status — shown in the persistent banner until dismissed. + pub fn error(text: String) -> Self { + Self { + text, + is_error: true, + } + } +} + +/// Which colour the docked colour-picker panel is editing (`None` = closed). +#[derive(Clone, Copy, PartialEq)] +pub(super) enum ColorPickerTarget { + /// Text (font) colour — hex mark values, custom entry enabled. + Text, + /// Highlight — named `HighlightColor` variants only. + Highlight, +} + /// All per-document signals for the editor, grouped for ergonomic initialisation. pub(super) struct EditorState { pub doc_state: Arc>, @@ -138,14 +177,22 @@ pub(super) struct EditorState { /// a clean *titled* document has nothing to save. Untitled documents stay /// dirty (Save routes to Save As), so their Save button stays enabled. pub is_dirty: Signal, - /// Last save result message (`None` = nothing to show). - pub save_message: Signal>, + /// Last transient status (`None` = nothing to show). See [`SaveStatus`] + /// for how successes vs errors are surfaced. + pub save_message: Signal>, /// Monotonic counter bumped by the Ctrl+S handler. `EditorInner` watches it /// and runs the save (or Save As for untitled documents) — the keydown /// handler has no access to the tab/recents context, so it signals instead. pub save_request: Signal, - /// Index of the active ribbon tab (0 = Home … Publish is the last). + /// Index of the active ribbon tab (0 = Write, 1 = Format, … Publish is + /// the last core tab; the Table contextual tab follows). pub active_ribbon_tab: Signal, + /// Which colour the docked picker panel is editing (`None` = closed). + pub open_color_picker: Signal>, + /// Recent Format-tab font-colour picks (hex, most recent first). + pub recent_text_colors: Signal>, + /// Recent Format-tab highlight picks (variant names, most recent first). + pub recent_highlights: Signal>, /// Whether the Publish-tab PDF/X export panel is open above the ribbon. pub is_publish_panel_open: Signal, /// Selected PDF/X conformance level for export. @@ -160,8 +207,14 @@ pub(super) struct EditorState { /// of `EditorInner`. Hook call order is preserved because `EditorInner` /// always calls this as its first hook operation. pub(super) fn use_editor_state() -> EditorState { - let doc_state: Arc> = - use_hook(|| Arc::new(Mutex::new(DocumentState::new()))); + // Share the app-wide font handle (warm-up started at launch in `App`) so + // mounting the editor never pays — or waits for — the system-font scan. + // The fallback covers tests/hosts that mount the editor without `App`. + let doc_state: Arc> = use_hook(|| { + let fonts = try_consume_context::() + .unwrap_or_else(loki_layout::SharedFontResources::warm_up); + Arc::new(Mutex::new(DocumentState::with_fonts(fonts))) + }); // Synchronously read page_count in case doc_state is already populated // (covers tab-switch-back where the document was previously loaded). @@ -209,6 +262,9 @@ pub(super) fn use_editor_state() -> EditorState { save_message: use_signal(|| None), save_request: use_signal(|| 0_u32), active_ribbon_tab: use_signal(|| 0_usize), + open_color_picker: use_signal(|| None), + recent_text_colors: use_signal(Vec::new), + recent_highlights: use_signal(Vec::new), is_publish_panel_open: use_signal(|| false), pdf_level: use_signal(super::editor_publish::PdfXLevelChoice::default), editing_metadata: use_signal(|| None), diff --git a/loki-text/src/routes/editor/editor_style_catalog.rs b/loki-text/src/routes/editor/editor_style_catalog.rs index 230fce63..7b39a3ac 100644 --- a/loki-text/src/routes/editor/editor_style_catalog.rs +++ b/loki-text/src/routes/editor/editor_style_catalog.rs @@ -200,16 +200,11 @@ pub(super) fn get_catalog_style( /// Returns the font families available for layout (system + bundled + /// document-embedded), sorted, for the style editor's font picker. /// -/// Enumerates the editor's shared Fontique collection. Intended to be called -/// once (memoised) per editor rather than per render. -pub(super) fn available_font_families(doc_state: &Arc>) -> Vec { - let Ok(state) = doc_state.lock() else { - return vec![]; - }; - let Ok(mut fr) = state.shared_font_resources.lock() else { - return vec![]; - }; - fr.available_font_families() +/// Blocks until the background font warm-up finishes, so call it from a +/// worker thread (the editor fills its `font_families` signal that way) +/// rather than on the render path. +pub(super) fn available_font_families(fonts: &loki_layout::SharedFontResources) -> Vec { + fonts.lock().available_font_families() } /// Returns `(style_id, display_name, depth)` for all catalog paragraph styles in diff --git a/loki-text/src/routes/editor/editor_style_editor/actions.rs b/loki-text/src/routes/editor/editor_style_editor/actions.rs index 7aabedd6..9f7a392e 100644 --- a/loki-text/src/routes/editor/editor_style_editor/actions.rs +++ b/loki-text/src/routes/editor/editor_style_editor/actions.rs @@ -10,6 +10,7 @@ use std::sync::{Arc, Mutex}; +use super::super::editor_state::SaveStatus; use appthere_ui::tokens; use dioxus::prelude::*; use loki_i18n::fl; @@ -64,11 +65,14 @@ pub(super) fn delete_button( sync.can_undo, sync.can_redo, ); - save_message.set(Some(fl!("style-delete-success", count = n as i64))); + save_message.set(Some(SaveStatus::ok(fl!( + "style-delete-success", + count = n as i64 + )))); editing_style_draft.set(None); // close the panel; the style is gone } Some(Err(DeleteError::Builtin)) => { - save_message.set(Some(fl!("style-delete-builtin"))); + save_message.set(Some(SaveStatus::error(fl!("style-delete-builtin")))); } _ => {} } diff --git a/loki-text/src/routes/editor/editor_style_editor/char_form.rs b/loki-text/src/routes/editor/editor_style_editor/char_form.rs index 05587189..775b8661 100644 --- a/loki-text/src/routes/editor/editor_style_editor/char_form.rs +++ b/loki-text/src/routes/editor/editor_style_editor/char_form.rs @@ -15,6 +15,7 @@ use std::rc::Rc; use std::sync::{Arc, Mutex}; +use super::super::editor_state::SaveStatus; use appthere_ui::tokens; use dioxus::prelude::*; use loki_doc_model::style::StyleId; @@ -110,7 +111,7 @@ fn apply_button( .is_some_and(|cat| cat.char_reparent_cycles(&child, &new_parent)); if cycles { let mut save_message = sync.save_message; - save_message.set(Some(fl!("style-reparent-cycle"))); + save_message.set(Some(SaveStatus::error(fl!("style-reparent-cycle")))); return; } } diff --git a/loki-text/src/routes/editor/editor_style_editor/form.rs b/loki-text/src/routes/editor/editor_style_editor/form.rs index e0f94a96..6b524d14 100644 --- a/loki-text/src/routes/editor/editor_style_editor/form.rs +++ b/loki-text/src/routes/editor/editor_style_editor/form.rs @@ -10,6 +10,7 @@ use std::rc::Rc; use std::sync::{Arc, Mutex}; +use super::super::editor_state::SaveStatus; use appthere_ui::tokens; use dioxus::prelude::*; use loki_i18n::fl; @@ -228,7 +229,7 @@ pub(super) fn style_form( .is_some_and(|cat| cat.para_reparent_cycles(&child, &new_parent)); if cycles { let mut save_message = sync.save_message; - save_message.set(Some(fl!("style-reparent-cycle"))); + save_message.set(Some(SaveStatus::error(fl!("style-reparent-cycle")))); return; } } diff --git a/loki-text/src/routes/editor/editor_style_editor/mod.rs b/loki-text/src/routes/editor/editor_style_editor/mod.rs index 396931ba..bc0c0471 100644 --- a/loki-text/src/routes/editor/editor_style_editor/mod.rs +++ b/loki-text/src/routes/editor/editor_style_editor/mod.rs @@ -37,6 +37,7 @@ mod tree_nav; use std::rc::Rc; use std::sync::{Arc, Mutex}; +use super::editor_state::SaveStatus; use appthere_ui::responsive::Breakpoint; use appthere_ui::tokens; use dioxus::prelude::*; @@ -71,7 +72,7 @@ pub(super) struct StyleEditorSync { /// Whether redo is available. pub can_redo: Signal, /// Status-banner sink for feedback (e.g. a rejected cyclic re-parent). - pub save_message: Signal>, + pub save_message: Signal>, } /// Renders the inline style catalog editor panel. diff --git a/loki-text/src/routes/editor/editor_style_editor/table_form.rs b/loki-text/src/routes/editor/editor_style_editor/table_form.rs index d5be872b..0c922bcd 100644 --- a/loki-text/src/routes/editor/editor_style_editor/table_form.rs +++ b/loki-text/src/routes/editor/editor_style_editor/table_form.rs @@ -10,6 +10,7 @@ use std::sync::{Arc, Mutex}; +use super::super::editor_state::SaveStatus; use appthere_ui::tokens; use dioxus::prelude::*; use loki_doc_model::style::StyleId; @@ -192,7 +193,7 @@ fn apply_button( .is_some_and(|cat| cat.table_reparent_cycles(&child, &new_parent)); if cycles { let mut save_message = sync.save_message; - save_message.set(Some(fl!("style-reparent-cycle"))); + save_message.set(Some(SaveStatus::error(fl!("style-reparent-cycle")))); return; } } diff --git a/loki-text/src/routes/editor/mod.rs b/loki-text/src/routes/editor/mod.rs index e2308ab0..9a9d8fca 100644 --- a/loki-text/src/routes/editor/mod.rs +++ b/loki-text/src/routes/editor/mod.rs @@ -9,15 +9,15 @@ //! //! All editing logic lives in [`editor_inner::EditorInner`]. -mod editor_alignment; mod editor_canvas; mod editor_canvas_loading; +mod editor_color_panel; mod editor_compact; mod editor_dirty; mod editor_docked_panels; mod editor_error_view; -mod editor_font_size; mod editor_font_warning; +mod editor_fonts; mod editor_format_range; mod editor_formatting; mod editor_highlight_color; @@ -47,6 +47,7 @@ mod editor_ribbon_layout; mod editor_ribbon_publish; mod editor_ribbon_references; mod editor_ribbon_review; +mod editor_ribbon_span; mod editor_ribbon_table; mod editor_ribbon_table_delete; mod editor_ribbon_table_ops; diff --git a/loki-text/src/routes/home.rs b/loki-text/src/routes/home.rs index 980a65f3..27b7d3b6 100644 --- a/loki-text/src/routes/home.rs +++ b/loki-text/src/routes/home.rs @@ -241,6 +241,14 @@ pub fn Home() -> Element { // home area (AtHomeTab sizes itself to the viewport minus tab bar). div { style: "position: relative;", + + // Width source for the responsive context while Home is the active + // route — the editor's scroll-container funnel only exists once a + // document is open, so without this the Home screen stays stuck at + // the unmeasured (Compact) layout until then. Unmounts with Home, + // so it never competes with the editor's funnel. + appthere_ui::AtViewportWidthSensor {} + AtHomeTab { templates: make_templates(), recent_documents: recent_list, diff --git a/loki-text/src/window_state.rs b/loki-text/src/window_state.rs new file mode 100644 index 00000000..0cb50a78 --- /dev/null +++ b/loki-text/src/window_state.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Window title, default size, and size persistence for the desktop shell. +//! +//! The window opens at the size the user last left it (persisted via +//! [`loki_app_shell::window_geometry`]) and falls back to a comfortable +//! default instead of winit's tiny built-in one. Live sizes are reported by +//! [`appthere_ui::AtWindowSizeSensor`] (mounted in `App`) and saved through a +//! short debounce so an interactive resize writes once, not per frame. + +use loki_app_shell::window_geometry::WindowGeometry; + +/// The product name shown in the OS window title bar. +pub const WINDOW_TITLE: &str = "Loki Text"; + +/// Relative persistence path under the platform data dir. +pub const GEOMETRY_FILE: &str = "AppThere/Loki/window.json"; + +/// Default logical inner size for a first launch (no persisted geometry). +pub const DEFAULT_GEOMETRY: WindowGeometry = WindowGeometry::new(1280.0, 800.0); + +/// The geometry to open the window with: last-persisted, else the default. +pub fn initial_geometry() -> WindowGeometry { + WindowGeometry::load(GEOMETRY_FILE).unwrap_or(DEFAULT_GEOMETRY) +} + +/// Schedules a debounced save of `size` (see +/// [`loki_app_shell::window_geometry::save_debounced`]). +pub fn persist_geometry_debounced(size: (f64, f64)) { + loki_app_shell::window_geometry::save_debounced(GEOMETRY_FILE, size); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initial_geometry_is_sane_without_a_persisted_file() { + // Whatever is (or is not) on disk, the result is a usable size. + let g = initial_geometry(); + assert!(g.width >= 320.0 && g.height >= 240.0); + } +} diff --git a/loki-vello/src/lib.rs b/loki-vello/src/lib.rs index 132d6846..4dd6d2ad 100644 --- a/loki-vello/src/lib.rs +++ b/loki-vello/src/lib.rs @@ -41,7 +41,7 @@ pub use band::{content_max_x, paint_continuous_band}; pub use error::{VelloError, VelloResult}; pub use font_cache::FontDataCache; pub use scene::{ - CursorPaint, SelectionHandle, SelectionHandleKind, SelectionRect, paint_continuous, - paint_layout, paint_paginated, paint_single_page, + CursorPaint, SelectionHandle, SelectionHandleKind, SelectionRect, SelectionSpan, + paint_continuous, paint_layout, paint_paginated, paint_single_page, }; pub use scene_cursor::paint_cursor; diff --git a/loki-vello/src/scene.rs b/loki-vello/src/scene.rs index fdbb8dc8..3f49f60b 100644 --- a/loki-vello/src/scene.rs +++ b/loki-vello/src/scene.rs @@ -64,7 +64,24 @@ pub struct SelectionRect { pub height: f32, } -/// Cursor and selection highlight data for a single paragraph on one page. +/// One paragraph's slice of a (possibly multi-paragraph) range selection. +/// +/// Rects and handle tips are in that paragraph's local coordinates; the +/// painter looks up the paragraph's per-page origin (and rotation) and +/// transforms each span independently, so a selection spanning paragraphs — +/// or a paragraph split across pages — highlights correctly on every page. +#[derive(Debug, Clone)] +pub struct SelectionSpan { + /// Global index of the paragraph block this span belongs to. + pub paragraph_index: usize, + /// Highlight rects for the selected range within this paragraph. + pub rects: Vec, + /// Selection handles anchored in this paragraph (mobile only; empty on + /// desktop). + pub handles: Vec, +} + +/// Cursor and selection highlight data for one page. /// /// All rects are in paragraph-local coordinates (points, origin at the /// paragraph's `(0, 0)` top-left). The painter applies the paragraph origin @@ -74,8 +91,9 @@ pub struct CursorPaint { /// Visual cursor rect, or `None` when the cursor has no position in this /// paragraph. pub cursor_rect: Option, - /// Zero or more selection highlight rects. Empty when no range selection - /// is active. + /// Zero or more selection highlight rects for the caret's own paragraph + /// (single-paragraph path; multi-paragraph selections use + /// [`CursorPaint::selection_spans`]). pub selection_rects: Vec, /// Selection handles for mobile (iOS/Android) long-press word selection. /// @@ -83,6 +101,9 @@ pub struct CursorPaint { /// Empty on desktop — handles are guarded by `#[cfg(target_os)]` in the /// caller. pub selection_handles: Vec, + /// Per-paragraph selection highlight spans for this page (empty when the + /// selection is collapsed). + pub selection_spans: Vec, /// Global index of the paragraph block that this data belongs to. /// Used by the painter to look up the paragraph's page-local origin. pub paragraph_index: usize, diff --git a/loki-vello/src/scene_single_page.rs b/loki-vello/src/scene_single_page.rs index a86f80d3..0844eeba 100644 --- a/loki-vello/src/scene_single_page.rs +++ b/loki-vello/src/scene_single_page.rs @@ -100,6 +100,32 @@ pub fn paint_single_page( // Cursor and selection highlights — painted after content so they appear // on top of glyphs. if let Some(cp) = cursor_paint { + // Multi-paragraph selection spans: each span is transformed by its own + // paragraph's per-page origin (and rotation), so selections spanning + // paragraphs or page-split fragments highlight correctly. + for span in &cp.selection_spans { + let span_para = page.editing_data.as_ref().and_then(|ed| { + ed.paragraphs + .iter() + .find(|p| p.block_index == span.paragraph_index) + }); + if span_para.is_none() { + continue; + } + let t = crate::scene_cursor::cursor_paint_transform(span_para, content_origin, scale); + crate::scene_cursor::paint_cursor_transformed( + scene, + &CursorRect { + x: 0.0, + y: 0.0, + height: 0.0, + }, + &span.rects, + &span.handles, + t, + ); + } + // The cursor rect and selection rects are in paragraph-local coordinates. // Find the paragraph fragment on this page that matches the global // paragraph_index, and use its origin. diff --git a/patches/loki-file-access/.cargo-ok b/patches/loki-file-access/.cargo-ok new file mode 100644 index 00000000..e69de29b diff --git a/patches/loki-file-access/.github/workflows/rust.yml b/patches/loki-file-access/.github/workflows/rust.yml new file mode 100644 index 00000000..9fd45e09 --- /dev/null +++ b/patches/loki-file-access/.github/workflows/rust.yml @@ -0,0 +1,22 @@ +name: Rust + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose diff --git a/patches/loki-file-access/.gitignore b/patches/loki-file-access/.gitignore new file mode 100644 index 00000000..ad679558 --- /dev/null +++ b/patches/loki-file-access/.gitignore @@ -0,0 +1,21 @@ +# Generated by Cargo +# will have compiled files and executables +debug +target + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Generated by cargo mutants +# Contains mutation testing data +**/mutants.out*/ + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/patches/loki-file-access/Cargo.lock b/patches/loki-file-access/Cargo.lock new file mode 100644 index 00000000..416874ca --- /dev/null +++ b/patches/loki-file-access/Cargo.lock @@ -0,0 +1,2186 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loki-file-access" +version = "0.1.3" +dependencies = [ + "base64", + "jni", + "js-sys", + "ndk-context", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "pollster", + "rfd", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2", + "dispatch2", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow 1.0.1", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.1", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow 0.7.15", +] diff --git a/patches/loki-file-access/Cargo.toml b/patches/loki-file-access/Cargo.toml new file mode 100644 index 00000000..f9980e55 --- /dev/null +++ b/patches/loki-file-access/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "loki-file-access" +version = "0.1.3" +edition = "2024" +license = "MIT" +description = "Cross-platform, frontend-agnostic file picker and capability-based file access for Rust" +repository = "https://github.com/appthere/loki-file-access" +keywords = ["file-picker", "android", "ios", "saf", "cross-platform"] +categories = ["filesystem", "os", "gui"] + +[features] +default = [] + +[dependencies] +thiserror = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.22" +tracing = "0.1" + +[target.'cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))'.dependencies] +rfd = "0.15" + +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.21" +ndk-context = "0.1" + +[target.'cfg(target_os = "ios")'.dependencies] +objc2 = "0.6" +objc2-ui-kit = { version = "0.3", features = ["UIDocumentPickerViewController", "UIViewController", "UIResponder"] } +objc2-foundation = { version = "0.3", features = ["NSURL", "NSData", "NSError"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = [ + "HtmlInputElement", "File", "FileList", "Event", + "EventTarget", "Document", "Window", "Blob", "Url" +] } +js-sys = "0.3" + +[dev-dependencies] +pollster = "0.4" +tempfile = "3" +serde_json = "1" +base64 = "0.22" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +# Vendored into the Loki workspace as a [patch] — stand alone, not a member. +[workspace] diff --git a/patches/loki-file-access/LICENSE b/patches/loki-file-access/LICENSE new file mode 100644 index 00000000..0ae7426c --- /dev/null +++ b/patches/loki-file-access/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AppThere + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/patches/loki-file-access/README.md b/patches/loki-file-access/README.md new file mode 100644 index 00000000..e7f2a211 --- /dev/null +++ b/patches/loki-file-access/README.md @@ -0,0 +1,112 @@ +# loki-file-access + +Cross-platform, frontend-agnostic file picker and capability-based file access for Rust. + +## Features + +- **Desktop** (Windows, macOS, Linux, BSD) — via the [`rfd`](https://crates.io/crates/rfd) crate +- **Android** — via the Storage Access Framework with persistable URI permissions +- **iOS** — via `UIDocumentPickerViewController` with security-scoped bookmarks +- **WASM** — via `` with in-memory file buffers + +No UI framework dependencies. Returns standard `Future` values built on `std` primitives — usable from Dioxus, egui, Iced, `pollster::block_on`, or any other async or sync context. + +## Quick start + +```toml +[dependencies] +loki-file-access = "0.1.2" +``` + +```rust +use loki_file_access::{FilePicker, PickOptions}; +use std::io::Read; + +let picker = FilePicker::new(); +let token = picker + .pick_file_to_open(PickOptions { + mime_types: vec!["text/plain".into()], + ..Default::default() + }) + .await?; + +if let Some(token) = token { + let mut reader = token.open_read()?; + let mut contents = String::new(); + reader.read_to_string(&mut contents)?; +} +``` + +## Linux requirements + +On Linux, loki-file-access uses the [XDG Desktop Portal](https://flatpak.github.io/xdg-desktop-portal/) (`org.freedesktop.portal.FileChooser`) for file dialogs, accessed via D-Bus. + +Most desktop Linux environments (GNOME, KDE, etc.) provide this portal automatically. If the portal is not running, the file picker will silently return `None` — enable a `tracing` subscriber in your application to see the warning message that explains this. + +### Zenity fallback + +When the XDG Desktop Portal is unavailable, loki-file-access automatically +falls back to [zenity](https://help.gnome.org/users/zenity/stable/) if it is +installed. Zenity is a GNOME utility that shows GTK file-chooser dialogs from +the command line. + +Install zenity on Debian/Ubuntu/ChromeOS Crostini: + +```sh +sudo apt install zenity +``` + +Install on other distributions: + +```sh +# Fedora +sudo dnf install zenity + +# Arch +sudo pacman -S zenity +``` + +If zenity is also unavailable, a `tracing::warn!` is emitted and the picker +returns no selection. + +### ChromeOS Crostini + +The XDG Desktop Portal is **not** available inside the Crostini Linux +container. Install zenity and the file picker will work via the zenity +fallback: + +```sh +sudo apt install zenity +``` + +### Troubleshooting + +**The file picker does nothing on my Linux system.** + +1. Install zenity (the automatic fallback when the portal is unavailable): + ```sh + sudo apt install zenity # Debian/Ubuntu/Crostini + sudo dnf install zenity # Fedora + sudo pacman -S zenity # Arch + ``` + +2. If zenity is installed but the picker still does nothing, ensure the XDG + Desktop Portal is present for your desktop environment: + ```sh + # Debian/Ubuntu — GNOME + sudo apt install xdg-desktop-portal xdg-desktop-portal-gtk + ``` + +3. Enable a `tracing` subscriber in your app (e.g. `tracing_subscriber::fmt::init()`) + to see diagnostic messages from the picker. + +4. To explicitly disable the picker and surface a clear error instead of a + silent no-op, set the environment variable: + ```sh + LOKI_FILE_ACCESS_BACKEND=none ./your-app + ``` + Valid values: `auto` (default), `none`. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/patches/loki-file-access/android/FilePickerActivity.java b/patches/loki-file-access/android/FilePickerActivity.java new file mode 100644 index 00000000..5b6d8dfe --- /dev/null +++ b/patches/loki-file-access/android/FilePickerActivity.java @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +package io.github.appthere.lokifileaccess; + +import android.app.Activity; +import android.content.ClipData; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; + +/** + * Transparent trampoline Activity for Android file picking via the Storage Access Framework. + * + *

{@code ANativeActivityCallbacks} has no {@code onActivityResult} callback, so + * NativeActivity cannot receive file-picker results directly. This Activity: + *

    + *
  1. Is started by NativeActivity via {@code startActivity} (no result needed).
  2. + *
  3. Calls {@code startActivityForResult(ACTION_OPEN_DOCUMENT / ACTION_CREATE_DOCUMENT)}.
  4. + *
  5. Receives the result in its own {@code onActivityResult}.
  6. + *
  7. Delivers the selected URI to the Rust future via {@code nativeOnResult}.
  8. + *
  9. Calls {@code finish()} to remove itself from the back stack.
  10. + *
+ * + *

The native library is already loaded by NativeActivity before this Activity is + * ever created, so {@code System.loadLibrary} is not required here. + * + *

Register in {@code AndroidManifest.xml}: + *

{@code
+ * 
+ * }
+ */ +public class FilePickerActivity extends Activity { + + static { + // Android 7+ scopes native-library JNI lookups to the class loader that + // called System.loadLibrary. NativeActivity loads libloki_text.so from + // the framework bootstrap class loader; FilePickerActivity runs under the + // application PathClassLoader. Without this call, nativeOnResult cannot + // be resolved and throws UnsatisfiedLinkError at runtime. + // Safe to call if the library is already loaded — ART returns immediately. + System.loadLibrary("loki_text"); + } + + private static final int REQUEST_OPEN = 1001; + private static final int REQUEST_CREATE = 1002; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Intent src = getIntent(); + String mode = src.getStringExtra("mode"); + if ("CREATE".equals(mode)) { + launchCreate(src); + } else { + launchOpen(src); + } + } + + private void launchOpen(Intent src) { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + + // mime_types is passed as a comma-separated string to avoid JNI array complexity. + String mimeTypesRaw = src.getStringExtra("mime_types"); + if (mimeTypesRaw != null && !mimeTypesRaw.isEmpty()) { + String[] mimes = mimeTypesRaw.split(","); + if (mimes.length == 1) { + intent.setType(mimes[0]); + } else { + intent.setType("*/*"); + intent.putExtra(Intent.EXTRA_MIME_TYPES, mimes); + } + } else { + intent.setType("*/*"); + } + + boolean allowMultiple = src.getBooleanExtra("allow_multiple", false); + if (allowMultiple) { + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + } + + startActivityForResult(intent, REQUEST_OPEN); + } + + private void launchCreate(Intent src) { + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + + String mimeType = src.getStringExtra("mime_type"); + intent.setType(mimeType != null ? mimeType : "*/*"); + + String suggestedName = src.getStringExtra("suggested_name"); + if (suggestedName != null) { + intent.putExtra(Intent.EXTRA_TITLE, suggestedName); + } + + startActivityForResult(intent, REQUEST_CREATE); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + String result = null; + if (resultCode == RESULT_OK && data != null) { + // Multi-select delivers URIs through ClipData; single-select uses getData(). + // Join all URIs with '\n' so a single nativeOnResult call carries the full + // selection without requiring a JNI array parameter. + ClipData clip = data.getClipData(); + if (clip != null && clip.getItemCount() > 0) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < clip.getItemCount(); i++) { + Uri u = clip.getItemAt(i).getUri(); + if (u != null) { + if (sb.length() > 0) sb.append('\n'); + sb.append(u.toString()); + } + } + if (sb.length() > 0) result = sb.toString(); + } else { + Uri u = data.getData(); + if (u != null) result = u.toString(); + } + } + nativeOnResult(result); + finish(); + } + + /** + * Delivers the selected URI (or {@code null} for cancellation) to the pending Rust future. + * + *

Resolved via JNI against + * {@code Java_io_github_appthere_lokifileaccess_FilePickerActivity_nativeOnResult} + * in the host application's native library, which is already loaded by the time + * this Activity is created. + */ + private native void nativeOnResult(String uri); +} diff --git a/patches/loki-file-access/android/ImeInsetsListener.java b/patches/loki-file-access/android/ImeInsetsListener.java new file mode 100644 index 00000000..547c03ea --- /dev/null +++ b/patches/loki-file-access/android/ImeInsetsListener.java @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +package io.github.appthere.lokifileaccess; + +import android.app.Activity; +import android.os.Build; +import android.view.View; +import android.view.WindowInsets; + +/** + * Bridges Android soft-keyboard (IME) visibility changes to native code. + * + *

On a {@code NativeActivity} the OS never reports when the user dismisses or + * re-summons the soft keyboard (back button, swipe-down gesture, keyboard hide + * key), and the surface is not resized. This listener observes the decor view's + * window insets — which include the IME inset on API 30+ — and calls + * {@code nativeOnImeInsetsChanged} on every visibility transition so the Rust + * side can re-reserve (or release) the bottom safe area. + * + *

The native method is bound from Rust via {@code RegisterNatives}, so no + * {@code System.loadLibrary} is required here and the binding is independent of + * the host application's native-library name. + */ +public final class ImeInsetsListener implements View.OnApplyWindowInsetsListener { + + private boolean lastImeVisible; + + /** + * Install the listener on the activity's decor view. + * + *

Runs on the UI thread (View listeners must be set there) and is a no-op + * below API 30, where {@code WindowInsets.Type.ime()} / {@code isVisible} + * are unavailable — matching the query-side API-30 fallback in Rust. + */ + public static void install(final Activity activity) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return; + } + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + View decor = activity.getWindow().getDecorView(); + decor.setOnApplyWindowInsetsListener(new ImeInsetsListener()); + // Kick an immediate inset dispatch so the initial state is known. + decor.requestApplyInsets(); + } + }); + } + + @Override + public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { + boolean imeVisible = insets.isVisible(WindowInsets.Type.ime()); + if (imeVisible != lastImeVisible) { + lastImeVisible = imeVisible; + nativeOnImeInsetsChanged(imeVisible); + } + // Pass through so we neither consume nor alter the system's inset handling. + return v.onApplyWindowInsets(insets); + } + + private static native void nativeOnImeInsetsChanged(boolean imeVisible); +} diff --git a/patches/loki-file-access/build.rs b/patches/loki-file-access/build.rs new file mode 100644 index 00000000..9717a223 --- /dev/null +++ b/patches/loki-file-access/build.rs @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! Build script — compiles the Android Java shims into `classes.dex` for +//! Android targets. +//! +//! Shims (in `android/`): +//! - `FilePickerActivity.java` — Storage Access Framework trampoline. +//! - `ImeInsetsListener.java` — soft-keyboard (IME) visibility bridge. +//! +//! Requires: +//! - `ANDROID_HOME` or `ANDROID_SDK_ROOT` pointing to the Android SDK +//! - `javac` on PATH, in `JAVA_HOME/bin`, or in Android Studio's bundled JDK +//! +//! On non-Android targets this script does nothing. + +use std::path::PathBuf; + +/// Java shim source files (relative to `android/`) compiled into the DEX. +const JAVA_SHIMS: &[&str] = &["FilePickerActivity.java", "ImeInsetsListener.java"]; + +fn main() { + for shim in JAVA_SHIMS { + println!("cargo:rerun-if-changed=android/{shim}"); + } + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os != "android" { + return; + } + + let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let java_srcs: Vec = JAVA_SHIMS + .iter() + .map(|shim| manifest_dir.join("android").join(shim)) + .collect(); + let dex_out = out_dir.join("classes.dex"); + + match compile_to_dex(&java_srcs, &out_dir, &dex_out) { + Ok(()) => { + println!("cargo:warning=Java shim DEX: {}", dex_out.display()); + println!("cargo:warning=Inject into APK with: scripts/build-android.ps1 -Install"); + } + Err(e) => { + println!("cargo:warning=Java shim compile skipped: {e}"); + println!("cargo:warning=Run scripts/build-android.ps1 which compiles the DEX itself."); + } + } + + println!("cargo:rustc-env=LOKI_FILE_ACCESS_DEX={}", dex_out.display()); +} + +fn compile_to_dex( + java_srcs: &[PathBuf], + out_dir: &std::path::Path, + dex_out: &std::path::Path, +) -> Result<(), String> { + let android_home = std::env::var("ANDROID_HOME") + .or_else(|_| std::env::var("ANDROID_SDK_ROOT")) + .map_err(|_| "ANDROID_HOME not set".to_owned())?; + let android_home = PathBuf::from(android_home); + + let android_jar = find_android_jar(&android_home)?; + let d8 = find_d8(&android_home)?; + let javac = find_javac(); + + let classes_dir = out_dir.join("java_classes"); + std::fs::create_dir_all(&classes_dir).map_err(|e| format!("mkdir classes: {e}"))?; + + let mut javac_args: Vec = vec![ + "-source".into(), + "8".into(), + "-target".into(), + "8".into(), + "-classpath".into(), + android_jar.to_str().unwrap().to_owned(), + "-d".into(), + classes_dir.to_str().unwrap().to_owned(), + ]; + javac_args.extend(java_srcs.iter().map(|p| p.to_str().unwrap().to_owned())); + + let status = std::process::Command::new(&javac) + .args(&javac_args) + .status() + .map_err(|e| format!("javac exec failed: {e}"))?; + if !status.success() { + return Err(format!("javac exited {status}")); + } + + // Collect every produced `.class` file (including inner classes such as the + // anonymous `Runnable` in `ImeInsetsListener`) so d8 dexes all of them. + let class_files = collect_class_files(&classes_dir); + if class_files.is_empty() { + return Err("no .class files produced by javac".to_owned()); + } + + let dex_dir = out_dir.join("dex_out"); + std::fs::create_dir_all(&dex_dir).map_err(|e| format!("mkdir dex: {e}"))?; + + let mut d8_args: Vec = class_files + .iter() + .map(|p| p.to_str().unwrap().to_owned()) + .collect(); + d8_args.push("--output".into()); + d8_args.push(dex_dir.to_str().unwrap().to_owned()); + d8_args.push("--min-api".into()); + d8_args.push("26".into()); + + let status = std::process::Command::new(&d8) + .args(&d8_args) + .status() + .map_err(|e| format!("d8 exec failed: {e}"))?; + if !status.success() { + return Err(format!("d8 exited {status}")); + } + + std::fs::copy(dex_dir.join("classes.dex"), dex_out).map_err(|e| format!("copy dex: {e}"))?; + + Ok(()) +} + +/// Recursively collect all `.class` files under `dir`. +fn collect_class_files(dir: &std::path::Path) -> Vec { + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return out; + }; + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.is_dir() { + out.extend(collect_class_files(&path)); + } else if path.extension().is_some_and(|ext| ext == "class") { + out.push(path); + } + } + out +} + +fn find_android_jar(android_home: &std::path::Path) -> Result { + let platforms = android_home.join("platforms"); + for api in (26..=36).rev() { + let jar = platforms.join(format!("android-{api}")).join("android.jar"); + if jar.exists() { + return Ok(jar); + } + } + Err("android.jar not found under $ANDROID_HOME/platforms/android-*/".to_owned()) +} + +fn find_d8(android_home: &std::path::Path) -> Result { + let build_tools = android_home.join("build-tools"); + let mut entries: Vec<_> = std::fs::read_dir(&build_tools) + .map_err(|e| format!("read build-tools: {e}"))? + .filter_map(|e| e.ok()) + .collect(); + entries.sort_by_key(|e| std::cmp::Reverse(e.file_name())); + for entry in entries { + for name in &["d8.bat", "d8"] { + let d8 = entry.path().join(name); + if d8.exists() { + return Ok(d8); + } + } + } + Err("d8 not found under $ANDROID_HOME/build-tools/".to_owned()) +} + +fn find_javac() -> PathBuf { + if let Ok(java_home) = std::env::var("JAVA_HOME") { + for name in &["bin/javac", "bin/javac.exe"] { + let p = PathBuf::from(&java_home).join(name); + if p.exists() { + return p; + } + } + } + // Android Studio bundled JDK (Windows — Program Files location) + #[cfg(target_os = "windows")] + { + let pf = std::env::var("PROGRAMFILES").unwrap_or_else(|_| "C:\\Program Files".into()); + let p = PathBuf::from(pf).join("Android\\Android Studio\\jbr\\bin\\javac.exe"); + if p.exists() { + return p; + } + } + PathBuf::from(if cfg!(target_os = "windows") { + "javac.exe" + } else { + "javac" + }) +} diff --git a/patches/loki-file-access/docs/adr/0001-runtime-agnostic-future.md b/patches/loki-file-access/docs/adr/0001-runtime-agnostic-future.md new file mode 100644 index 00000000..784b7324 --- /dev/null +++ b/patches/loki-file-access/docs/adr/0001-runtime-agnostic-future.md @@ -0,0 +1,58 @@ +# ADR 0001: Runtime-Agnostic Future Implementation + +## Status + +Accepted + +## Context + +`loki-file-access` must present native file-picker dialogs and return the +result as a Rust `Future`. The crate targets a wide range of consumers: +Dioxus, egui, Iced, Xilem, and applications using `pollster::block_on` for +synchronous contexts. + +Each of these frameworks has its own executor (or uses none at all). Tying +the crate to a specific async runtime would force every consumer to depend on +that runtime, even if they never use it elsewhere. + +## Decision + +Implement a minimal one-shot future (`PickFuture`) using only `std` +primitives: + +- `Arc>>` holds the shared state. +- `PickState` contains an `Option` for the result and an + `Option` for the executor's waker. +- The `Future` implementation checks for a result on each `poll`, stores the + waker if pending, and returns `Poll::Ready` once a result is delivered. +- A `deliver()` function is called from the platform callback (JNI, Objective-C + delegate, JS event listener) to set the result and wake the executor. + +No Tokio, async-std, or other runtime crate is required. + +## Consequences + +### Positive + +- **Universal compatibility**: Works with any executor, including + `pollster::block_on`, Tokio, async-std, smol, and custom executors. +- **Zero runtime dependencies**: The crate's dependency footprint is minimal. +- **Simple mental model**: One future, one result, one waker — easy to audit. + +### Negative + +- **Slightly more code** than using `tokio::sync::oneshot` or a similar + runtime-provided channel. +- **No built-in timeout**: Consumers must implement their own timeout logic + if needed (e.g. `tokio::time::timeout` wrapping the future). +- **Mutex overhead**: Each `poll` and `deliver` call acquires a mutex lock. + This is negligible for file-picker operations (user-driven, infrequent). + +## Alternatives Rejected + +| Alternative | Reason for rejection | +|---|---| +| `tokio::sync::oneshot` | Adds a mandatory Tokio dependency. Consumers using other runtimes or `pollster` would be forced to pull in Tokio. | +| `async_channel` | Additional dependency for a single-use channel. The crate only needs one-shot delivery, not a full channel. | +| `flume` | Additional dependency. Same reasoning as `async_channel` — the problem is simpler than what `flume` solves. | +| `futures::channel::oneshot` | Adds the `futures` crate as a dependency. While lighter than Tokio, it is still an unnecessary dependency for this use case. | diff --git a/patches/loki-file-access/docs/adr/0002-mit-license.md b/patches/loki-file-access/docs/adr/0002-mit-license.md new file mode 100644 index 00000000..041aff34 --- /dev/null +++ b/patches/loki-file-access/docs/adr/0002-mit-license.md @@ -0,0 +1,58 @@ +# ADR 0002: MIT License + +## Status + +Accepted + +## Context + +`loki-file-access` is a general-purpose utility crate for cross-platform file +access. It is intended for broad adoption across the Rust ecosystem, including +use in projects under a variety of open-source and proprietary licenses. + +The two most common choices for Rust crates are MIT and Apache-2.0 (often +dual-licensed as `MIT OR Apache-2.0`). A license decision is needed. + +## Decision + +Use the **MIT license** exclusively (not dual-licensed with Apache-2.0). + +## Rationale + +- **Maximum compatibility**: MIT is compatible with a strictly wider range of + downstream projects than Apache-2.0. Some projects and organisations cannot + accept Apache-2.0 due to concerns about the patent clause (Section 3) — for + example, projects under GPLv2-only or certain corporate policies. MIT has + no patent clause, removing this friction. + +- **No strategic IP**: As a low-level utility crate with no novel algorithms or + patentable inventions, the patent protection offered by Apache-2.0 provides + negligible benefit to the project or its contributors. + +- **Simplicity**: A single license is easier to understand, audit, and comply + with than a dual-license arrangement. Downstream consumers do not need to + choose between two options or evaluate which one to apply. + +- **Ecosystem norms**: Many widely-used Rust crates (serde, rand, base64, etc.) + are MIT-licensed. Using the same license reduces cognitive overhead for + consumers evaluating dependency licenses. + +## Consequences + +### Positive + +- Downstream projects under **any** OSI-approved license can use this crate + without restriction. +- Contributors do not need to agree to a CLA or patent grant beyond what MIT + already provides. +- License compliance is straightforward: include the copyright notice and + permission notice. + +### Negative + +- No explicit patent grant from contributors. If a contributor holds a patent + that reads on the crate's functionality, MIT alone does not provide an + express license to those patent claims. This is an acceptable risk given the + crate's nature as a utility library. +- Cannot be relicensed to Apache-2.0 later without consent from all copyright + holders (though this is unlikely to be needed). diff --git a/patches/loki-file-access/src/api.rs b/patches/loki-file-access/src/api.rs new file mode 100644 index 00000000..54850aa1 --- /dev/null +++ b/patches/loki-file-access/src/api.rs @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! Public API surface for presenting file-picker dialogs. +//! +//! This module defines [`FilePicker`], [`PickOptions`], and [`SaveOptions`] — +//! the primary entry points for all file-picker operations. Platform-specific +//! behaviour is fully abstracted behind these types. + +use crate::error::PickerError; +use crate::token::FileAccessToken; + +/// Options for opening an existing file via a platform file-picker dialog. +/// +/// # Examples +/// +/// ``` +/// use loki_file_access::PickOptions; +/// +/// let opts = PickOptions { +/// mime_types: vec!["image/png".into(), "image/jpeg".into()], +/// filter_label: Some("Images".into()), +/// multi: false, +/// }; +/// ``` +#[derive(Debug, Clone, Default)] +pub struct PickOptions { + /// MIME types to filter in the picker dialog. + /// + /// An empty vector means all file types are shown. + pub mime_types: Vec, + + /// Display label for the file-type filter in the picker UI. + /// + /// Not all platforms support this (e.g. Android ignores it). + pub filter_label: Option, + + /// Whether the user may select multiple files. + /// + /// When `false`, at most one file is returned. + pub multi: bool, +} + +/// Options for saving a new file or overwriting an existing one. +/// +/// # Examples +/// +/// ``` +/// use loki_file_access::SaveOptions; +/// +/// let opts = SaveOptions { +/// mime_type: Some("text/plain".into()), +/// suggested_name: Some("notes.txt".into()), +/// }; +/// ``` +#[derive(Debug, Clone, Default)] +pub struct SaveOptions { + /// The MIME type of the file being saved. + /// + /// Used by some platforms (Android SAF) to pre-filter the save location. + pub mime_type: Option, + + /// Suggested filename including extension. + /// + /// The user may change this in the save dialog. + pub suggested_name: Option, +} + +/// Frontend-agnostic file picker that delegates to the native platform dialog. +/// +/// `FilePicker` has no state and is cheap to construct. All methods return +/// standard [`Future`] values that can be awaited from any async runtime, +/// including `pollster::block_on` for synchronous contexts. +/// +/// # Examples +/// +/// ```no_run +/// use loki_file_access::{FilePicker, PickOptions}; +/// +/// # async fn example() -> Result<(), Box> { +/// let picker = FilePicker::new(); +/// let token = picker +/// .pick_file_to_open(PickOptions::default()) +/// .await?; +/// +/// if let Some(token) = token { +/// println!("Selected: {}", token.display_name()); +/// } +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Clone, Default)] +pub struct FilePicker; + +impl FilePicker { + /// Create a new `FilePicker` instance. + #[must_use] + pub fn new() -> Self { + Self + } + + /// Present a platform dialog for the user to select a single file. + /// + /// Returns `Ok(Some(token))` if the user selected a file, or `Ok(None)` + /// if the user cancelled the dialog. The `multi` field of `options` is + /// ignored — use [`pick_files_to_open`](Self::pick_files_to_open) for + /// multi-selection. + /// + /// # Errors + /// + /// Returns [`PickerError`] if the platform dialog could not be presented. + #[must_use = "this returns a Result that may contain an error"] + pub async fn pick_file_to_open( + &self, + options: PickOptions, + ) -> Result, PickerError> { + let opts = PickOptions { + multi: false, + ..options + }; + crate::platform::pick_open_single(opts).await + } + + /// Present a platform dialog for the user to select multiple files. + /// + /// Returns a (possibly empty) vector of tokens. An empty vector means + /// the user cancelled the dialog. The `multi` field of `options` is + /// forced to `true`. + /// + /// # Errors + /// + /// Returns [`PickerError`] if the platform dialog could not be presented. + #[must_use = "this returns a Result that may contain an error"] + pub async fn pick_files_to_open( + &self, + options: PickOptions, + ) -> Result, PickerError> { + let opts = PickOptions { + multi: true, + ..options + }; + crate::platform::pick_open_multi(opts).await + } + + /// Present a platform dialog for the user to choose a save location. + /// + /// Returns `Ok(Some(token))` if the user confirmed a save location, or + /// `Ok(None)` if the user cancelled. + /// + /// # Platform notes + /// + /// On WASM, this triggers a browser download via a Blob URL rather than + /// presenting a traditional save dialog. The returned token wraps an + /// in-memory buffer. + /// + /// # Errors + /// + /// Returns [`PickerError`] if the platform dialog could not be presented. + #[must_use = "this returns a Result that may contain an error"] + pub async fn pick_file_to_save( + &self, + options: SaveOptions, + ) -> Result, PickerError> { + crate::platform::pick_save(options).await + } +} diff --git a/patches/loki-file-access/src/error.rs b/patches/loki-file-access/src/error.rs new file mode 100644 index 00000000..4977b04e --- /dev/null +++ b/patches/loki-file-access/src/error.rs @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! Error types for the `loki-file-access` crate. +//! +//! This module defines all error enums used across the public API surface: +//! +//! - [`PickerError`] — errors originating from the platform file-picker dialog. +//! - [`AccessError`] — errors when reading from or writing to a previously granted file. +//! - [`TokenParseError`] — errors when deserializing a stored [`crate::FileAccessToken`]. +//! +//! All enums are `#[non_exhaustive]` so that new variants can be added in +//! future minor versions without breaking downstream matches. + +/// Errors that can occur when presenting a file-picker dialog. +/// +/// Note that the user cancelling the dialog is **not** an error — it is +/// represented as `Ok(None)` on the single-file methods and `Ok(vec![])` on +/// multi-file methods. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum PickerError { + /// The platform returned an error from the native file-picker API. + #[error("platform file-picker error: {message}")] + Platform { + /// Human-readable description of the platform error. + message: String, + }, + + /// The current platform does not support the requested operation. + #[error("operation not supported on this platform: {operation}")] + Unsupported { + /// Description of the unsupported operation. + operation: String, + }, + + /// An internal synchronisation error occurred (e.g. a poisoned mutex). + #[error("internal synchronisation error: {message}")] + Internal { + /// Human-readable description of the internal error. + message: String, + }, +} + +/// Errors that can occur when opening or accessing a previously granted file. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum AccessError { + /// The permission grant for this file has been revoked by the user or OS. + #[error("file access permission has been revoked")] + PermissionRevoked, + + /// An I/O error occurred while reading from or writing to the file. + #[error("I/O error: {source}")] + Io { + /// The underlying I/O error. + #[from] + source: std::io::Error, + }, + + /// The file descriptor or handle returned by the platform was invalid. + #[error("invalid file descriptor returned by platform")] + InvalidDescriptor, + + /// The platform returned an error when attempting to open the file. + #[error("platform access error: {message}")] + Platform { + /// Human-readable description of the platform error. + message: String, + }, + + /// The requested operation is not supported on the current platform. + #[error("operation not supported on this platform: {operation}")] + Unsupported { + /// Description of the unsupported operation. + operation: String, + }, +} + +/// Errors that can occur when deserializing a stored [`crate::FileAccessToken`]. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum TokenParseError { + /// The base64 encoding of the token is invalid. + #[error("invalid base64 encoding: {message}")] + InvalidBase64 { + /// Description of the base64 decode error. + message: String, + }, + + /// The JSON payload inside the token is malformed. + #[error("invalid JSON in token: {message}")] + InvalidJson { + /// Description of the JSON parse error. + message: String, + }, + + /// The token contains an unrecognised platform variant. + #[error("unknown token variant: {variant}")] + UnknownVariant { + /// The variant tag that was not recognised. + variant: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn picker_error_platform_displays_message() { + let err = PickerError::Platform { + message: "dialog failed".into(), + }; + let msg = err.to_string(); + assert!(!msg.is_empty(), "display string must not be empty"); + assert!(msg.contains("dialog failed")); + } + + #[test] + fn picker_error_unsupported_displays_message() { + let err = PickerError::Unsupported { + operation: "save".into(), + }; + let msg = err.to_string(); + assert!(!msg.is_empty()); + assert!(msg.contains("save")); + } + + #[test] + fn picker_error_internal_displays_message() { + let err = PickerError::Internal { + message: "mutex poisoned".into(), + }; + let msg = err.to_string(); + assert!(!msg.is_empty()); + assert!(msg.contains("mutex poisoned")); + } + + #[test] + fn access_error_permission_revoked_displays_message() { + let err = AccessError::PermissionRevoked; + assert!(!err.to_string().is_empty()); + } + + #[test] + fn access_error_io_displays_message() { + let err = AccessError::Io { + source: std::io::Error::new(std::io::ErrorKind::NotFound, "gone"), + }; + assert!(!err.to_string().is_empty()); + } + + #[test] + fn access_error_invalid_descriptor_displays_message() { + let err = AccessError::InvalidDescriptor; + assert!(!err.to_string().is_empty()); + } + + #[test] + fn access_error_platform_displays_message() { + let err = AccessError::Platform { + message: "fd error".into(), + }; + let msg = err.to_string(); + assert!(!msg.is_empty()); + assert!(msg.contains("fd error")); + } + + #[test] + fn access_error_unsupported_displays_message() { + let err = AccessError::Unsupported { + operation: "delete".into(), + }; + let msg = err.to_string(); + assert!(!msg.is_empty()); + assert!(msg.contains("delete")); + } + + #[test] + fn token_parse_error_base64_displays_message() { + let err = TokenParseError::InvalidBase64 { + message: "bad padding".into(), + }; + let msg = err.to_string(); + assert!(!msg.is_empty()); + assert!(msg.contains("bad padding")); + } + + #[test] + fn token_parse_error_json_displays_message() { + let err = TokenParseError::InvalidJson { + message: "unexpected EOF".into(), + }; + let msg = err.to_string(); + assert!(!msg.is_empty()); + assert!(msg.contains("unexpected EOF")); + } + + #[test] + fn token_parse_error_unknown_variant_displays_message() { + let err = TokenParseError::UnknownVariant { + variant: "FuturePlatform".into(), + }; + let msg = err.to_string(); + assert!(!msg.is_empty()); + assert!(msg.contains("FuturePlatform")); + } +} diff --git a/patches/loki-file-access/src/future.rs b/patches/loki-file-access/src/future.rs new file mode 100644 index 00000000..cfe6b500 --- /dev/null +++ b/patches/loki-file-access/src/future.rs @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! Runtime-agnostic one-shot future for delivering file-picker results. +//! +//! [`PickFuture`] and [`PickState`] implement a simple single-value future +//! using only `std` primitives (`Arc`, `Mutex`, `Waker`). This avoids any +//! dependency on Tokio, async-std, or other async runtimes, making the crate +//! usable from **any** executor — including `pollster::block_on`, Dioxus, +//! egui, Iced, and Xilem. +//! +//! # Usage (crate-internal) +//! +//! 1. Create a shared `Arc>>`. +//! 2. Return a `PickFuture` wrapping that state to the caller. +//! 3. From the platform callback, call [`deliver`] with the result value. + +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Waker}; + +/// Shared state between the [`PickFuture`] and the platform callback that +/// delivers the result. +// Used by non-desktop platforms (Android, iOS) that drive callbacks through +// `deliver()`. On desktop the `rfd` crate's own async API is used instead. +#[allow(dead_code)] +pub(crate) struct PickState { + /// The result value, set exactly once by [`deliver`]. + pub result: Option, + /// The most recent [`Waker`] registered by the executor. + pub waker: Option, +} + +/// A future that resolves to a single value of type `T`. +/// +/// This is the core async primitive used by all platform picker +/// implementations to bridge callback-based native APIs into Rust futures. +// Used by non-desktop platforms; on desktop, `rfd`'s own async API suffices. +#[allow(dead_code)] +#[must_use = "futures do nothing unless polled"] +pub(crate) struct PickFuture { + /// Shared state with the callback side. + pub state: Arc>>, +} + +impl Future for PickFuture { + type Output = T; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let mut guard = match self.state.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + + if let Some(value) = guard.result.take() { + return Poll::Ready(value); + } + + guard.waker = Some(cx.waker().clone()); + Poll::Pending + } +} + +/// Deliver a result value to a [`PickFuture`] and wake the executor. +/// +/// This function is called from platform callbacks (JNI, Objective-C delegates, +/// JS event listeners, etc.) to complete the associated future. +/// +/// # Panics +/// +/// This function does not panic. If the mutex is poisoned, it recovers the +/// inner state and proceeds normally. +// Used by non-desktop platforms; on desktop, `rfd`'s own async API suffices. +#[allow(dead_code)] +pub(crate) fn deliver(state: &Arc>>, value: T) { + let waker = { + let mut guard = match state.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + guard.result = Some(value); + guard.waker.take() + }; + + if let Some(w) = waker { + w.wake(); + } +} + +/// Create a new `(PickFuture, Arc>>)` pair. +/// +/// The returned `Arc` should be passed to the platform callback side, while +/// the `PickFuture` is returned to the caller to be awaited. +// Used by non-desktop platforms; on desktop, `rfd`'s own async API suffices. +#[allow(dead_code)] +pub(crate) fn new_pick_future() -> (PickFuture, Arc>>) { + let state = Arc::new(Mutex::new(PickState { + result: None, + waker: None, + })); + let future = PickFuture { + state: Arc::clone(&state), + }; + (future, state) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::task::{RawWaker, RawWakerVTable}; + + /// Create a no-op waker for testing poll behaviour without an executor. + fn noop_waker() -> Waker { + fn noop(_: *const ()) {} + fn clone(p: *const ()) -> RawWaker { + RawWaker::new(p, &VTABLE) + } + const VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop); + // SAFETY: The noop waker functions are trivially safe — they perform + // no operations on the data pointer. + unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) } + } + + #[test] + fn future_returns_pending_before_delivery() { + let (mut future, _state) = new_pick_future::(); + let waker = noop_waker(); + let mut cx = Context::from_waker(&waker); + let pinned = Pin::new(&mut future); + assert!( + pinned.poll(&mut cx).is_pending(), + "future should be Pending before deliver()" + ); + } + + #[test] + fn future_returns_ready_after_delivery() { + let (future, state) = new_pick_future::(); + deliver(&state, 42); + let result = pollster::block_on(future); + assert_eq!(result, 42); + } + + #[test] + fn future_returns_ready_after_delivery_with_pollster() { + let (future, state) = new_pick_future::(); + deliver(&state, "hello".to_owned()); + let result = pollster::block_on(future); + assert_eq!(result, "hello"); + } + + #[test] + fn deliver_wakes_the_waker() { + use std::sync::atomic::{AtomicBool, Ordering}; + + static WOKEN: AtomicBool = AtomicBool::new(false); + + fn noop(_: *const ()) {} + fn wake(_: *const ()) { + WOKEN.store(true, Ordering::SeqCst); + } + fn clone_fn(p: *const ()) -> RawWaker { + RawWaker::new(p, &WAKE_VTABLE) + } + const WAKE_VTABLE: RawWakerVTable = RawWakerVTable::new(clone_fn, wake, wake, noop); + + WOKEN.store(false, Ordering::SeqCst); + + let (mut future, state) = new_pick_future::(); + + // SAFETY: The custom waker functions are trivially safe. + let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &WAKE_VTABLE)) }; + let mut cx = Context::from_waker(&waker); + let _ = Pin::new(&mut future).poll(&mut cx); + + deliver(&state, 99); + assert!( + WOKEN.load(Ordering::SeqCst), + "waker should have been called" + ); + } +} diff --git a/patches/loki-file-access/src/lib.rs b/patches/loki-file-access/src/lib.rs new file mode 100644 index 00000000..06bb5119 --- /dev/null +++ b/patches/loki-file-access/src/lib.rs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! # loki-file-access +//! +//! Cross-platform, frontend-agnostic file picker and capability-based file +//! access for Rust applications. +//! +//! This crate provides a unified API for presenting native file-picker dialogs +//! and accessing user-selected files across all major platforms: +//! +//! - **Desktop** (Windows, macOS, Linux, BSD) — via the [`rfd`](https://crates.io/crates/rfd) crate +//! - **Android** — via the Storage Access Framework with persistable URI permissions +//! - **iOS** — via `UIDocumentPickerViewController` with security-scoped bookmarks +//! - **WASM** — via `` with in-memory file buffers +//! +//! # Zero UI Framework Dependencies +//! +//! `loki-file-access` has **no UI framework dependencies**. It returns +//! standard [`Future`] values implemented with only `std` primitives (no Tokio, +//! no async-std required). It is usable from Dioxus, egui, Iced, Xilem, +//! `pollster::block_on`, or any other async or sync Rust context. +//! +//! # Quick Start +//! +//! ```no_run +//! use loki_file_access::{FilePicker, PickOptions}; +//! use std::io::Read; +//! +//! # async fn example() -> Result<(), Box> { +//! let picker = FilePicker::new(); +//! +//! // Pick a file to open +//! let token = picker +//! .pick_file_to_open(PickOptions { +//! mime_types: vec!["text/plain".into()], +//! ..Default::default() +//! }) +//! .await?; +//! +//! if let Some(token) = token { +//! let mut reader = token.open_read()?; +//! let mut contents = String::new(); +//! reader.read_to_string(&mut contents)?; +//! println!("File contents: {contents}"); +//! +//! // Serialize the token for later use +//! let stored = token.serialize(); +//! println!("Token: {stored}"); +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! # Capability Tokens +//! +//! Every picker operation returns a [`FileAccessToken`] — a serializable +//! capability that encapsulates all platform-specific state needed to re-open +//! the file. Tokens can be serialized to a URL-safe string for storage in a +//! recent-files list and deserialized to re-open files across app restarts. +//! +//! On Android, the token holds a content URI with a persistable permission +//! grant. On iOS, it holds a security-scoped bookmark. On desktop, it holds +//! a filesystem path. On WASM, it holds the file data in memory. + +pub mod api; +pub mod error; +pub(crate) mod future; +mod platform; +pub mod token; + +// Re-export public types at crate root for convenience. +pub use api::{FilePicker, PickOptions, SaveOptions}; +pub use error::{AccessError, PickerError, TokenParseError}; +pub use token::{FileAccessToken, PermissionStatus, ReadSeek, WriteSeek}; + +#[cfg(target_os = "android")] +pub use platform::{ + init_android, install_ime_listener, on_activity_result, query_insets_dp, + query_window_insets_dp, set_ime_visibility_listener, +}; diff --git a/patches/loki-file-access/src/platform/android/jni_activity.rs b/patches/loki-file-access/src/platform/android/jni_activity.rs new file mode 100644 index 00000000..288acc57 --- /dev/null +++ b/patches/loki-file-access/src/platform/android/jni_activity.rs @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! JNI helpers for launching `FilePickerActivity`. +//! +//! `FilePickerActivity` is a thin Java trampoline that works around the +//! `ANativeActivityCallbacks` limitation: NativeActivity has no +//! `onActivityResult` slot, but a plain `Activity` subclass does. +//! +//! Flow: +//! 1. NativeActivity calls `startActivity(Intent → FilePickerActivity)`. +//! 2. `FilePickerActivity.onCreate` calls `startActivityForResult(ACTION_OPEN_*)`. +//! 3. `FilePickerActivity.onActivityResult` receives the URI and calls +//! `nativeOnResult(uri)` — the pre-compiled JNI hook in the Rust binary. +//! 4. The Rust future resolves. + +use super::jni_common::{attach_err, jvm_err, platform_err}; +use crate::api::{PickOptions, SaveOptions}; +use crate::error::PickerError; + +// Fully-qualified Java class name for FilePickerActivity. +// NOTE: do NOT use this as a ComponentName package — that field identifies the +// APK, not the Java package. Use Intent.setClassName(Context, String) instead +// so the runtime APK package name is derived from the Application context. +const FPA_CLASS: &str = "io.github.appthere.lokifileaccess.FilePickerActivity"; + +// ── Public entry points ─────────────────────────────────────────────────────── + +/// Start `FilePickerActivity` to open one or more files. +pub(super) fn fire_open_file_picker( + options: &PickOptions, + allow_multiple: bool, +) -> Result<(), PickerError> { + let ctx = ndk_context::android_context(); + let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.map_err(jvm_err)?; + let mut env = vm.attach_current_thread().map_err(attach_err)?; + + let intent = build_fpa_intent(&mut env, "OPEN")?; + + let mimes = options.mime_types.join(","); + put_string_extra(&mut env, &intent, "mime_types", &mimes)?; + + // putExtra(String, boolean) — JNI signature: (Ljava/lang/String;Z) + let key = env + .new_string("allow_multiple") + .map_err(|e| platform_err("allow_multiple key", e))?; + env.call_method( + &intent, + "putExtra", + "(Ljava/lang/String;Z)Landroid/content/Intent;", + &[ + jni::objects::JValueGen::Object(&key), + jni::objects::JValueGen::Bool(u8::from(allow_multiple)), + ], + ) + .map_err(|e| platform_err("putExtra allow_multiple", e))?; + + start_activity(&mut env, &intent) +} + +/// Start `FilePickerActivity` to create/save a file. +pub(super) fn fire_create_file_picker(options: &SaveOptions) -> Result<(), PickerError> { + let ctx = ndk_context::android_context(); + let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.map_err(jvm_err)?; + let mut env = vm.attach_current_thread().map_err(attach_err)?; + + let intent = build_fpa_intent(&mut env, "CREATE")?; + + if let Some(ref mime) = options.mime_type { + put_string_extra(&mut env, &intent, "mime_type", mime)?; + } + if let Some(ref name) = options.suggested_name { + put_string_extra(&mut env, &intent, "suggested_name", name)?; + } + + start_activity(&mut env, &intent) +} + +// ── Private helpers ─────────────────────────────────────────────────────────── + +/// Build an explicit `Intent` targeting `FilePickerActivity` with a `mode` extra. +/// +/// Uses `Intent.setClassName(Context, String)` rather than constructing a +/// `ComponentName` directly. `ComponentName(pkg, cls)` uses `pkg` as the +/// *application* identifier (the APK package name), not the Java package. +/// Hardcoding the Java package `io.github.appthere.lokifileaccess` would cause +/// Android to return `START_CLASS_NOT_FOUND` (-92) because no installed APK has +/// that package name. `setClassName(Context, cls)` derives the package from the +/// Application context at runtime, correctly targeting this APK regardless of +/// what package name the host app uses. +fn build_fpa_intent<'a>( + env: &mut jni::JNIEnv<'a>, + mode: &str, +) -> Result, PickerError> { + // new Intent() + let intent_cls = env + .find_class("android/content/Intent") + .map_err(|e| platform_err("Intent class", e))?; + let intent = env + .new_object(&intent_cls, "()V", &[]) + .map_err(|e| platform_err("Intent()", e))?; + + // intent.setClassName(context, FPA_CLASS) — resolves the APK package from + // the Application context so the component is found in this app's process. + let ctx = ndk_context::android_context(); + // SAFETY: ndk_context stores the Application jobject initialised before android_main. + let context = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }; + let cls_str = env + .new_string(FPA_CLASS) + .map_err(|e| platform_err("fpa class string", e))?; + env.call_method( + &intent, + "setClassName", + "(Landroid/content/Context;Ljava/lang/String;)Landroid/content/Intent;", + &[ + jni::objects::JValueGen::Object(&context), + jni::objects::JValueGen::Object(&cls_str), + ], + ) + .map_err(|e| platform_err("setClassName", e))?; + + // intent.putExtra("mode", mode) + put_string_extra(env, &intent, "mode", mode)?; + + Ok(intent) +} + +/// Add a `String` extra to an `Intent`. +fn put_string_extra( + env: &mut jni::JNIEnv<'_>, + intent: &jni::objects::JObject<'_>, + key: &str, + value: &str, +) -> Result<(), PickerError> { + let k = env + .new_string(key) + .map_err(|e| platform_err("extra key", e))?; + let v = env + .new_string(value) + .map_err(|e| platform_err("extra value", e))?; + env.call_method( + intent, + "putExtra", + "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;", + &[ + jni::objects::JValueGen::Object(&k), + jni::objects::JValueGen::Object(&v), + ], + ) + .map_err(|e| platform_err("putExtra string", e))?; + Ok(()) +} + +/// Call `Context.startActivity(intent)` using the Application context. +/// +/// `ndk_context` provides the Application object set by android-activity before +/// `android_main` is called. Starting an Activity from a non-Activity context +/// requires `FLAG_ACTIVITY_NEW_TASK`, which is added to the intent here. +/// +/// `FilePickerActivity` is a transparent trampoline: it receives its own +/// `onActivityResult` from the SAF picker (within its own task) and delivers +/// the URI to Rust via `nativeOnResult`. No result needs to flow back to +/// NativeActivity, so the new-task restriction is not a problem. +fn start_activity( + env: &mut jni::JNIEnv<'_>, + intent: &jni::objects::JObject<'_>, +) -> Result<(), PickerError> { + // FLAG_ACTIVITY_NEW_TASK — required when calling startActivity from a + // non-Activity Context such as Application. + const FLAG_ACTIVITY_NEW_TASK: i32 = 0x1000_0000; + let flags_result = env.call_method( + intent, + "addFlags", + "(I)Landroid/content/Intent;", + &[jni::objects::JValueGen::Int(FLAG_ACTIVITY_NEW_TASK)], + ); + if let Err(e) = flags_result { + // Clear any pending JNI exception before returning so subsequent + // JNI calls on this env do not trigger an ART abort. + let _ = env.exception_clear(); + return Err(platform_err("addFlags", e)); + } + + let ctx = ndk_context::android_context(); + // SAFETY: ndk_context stores the Application jobject initialised by + // android-activity before android_main runs. Valid for the process lifetime. + let context = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }; + + let result = env.call_method( + &context, + "startActivity", + "(Landroid/content/Intent;)V", + &[jni::objects::JValueGen::Object(intent)], + ); + + if result.is_err() { + // Clear any pending JNI exception (e.g. ActivityNotFoundException) so + // subsequent JNI calls in this env do not trigger an ART abort. + let _ = env.exception_clear(); + } + + result + .map(|_| ()) + .map_err(|e| platform_err("startActivity", e)) +} diff --git a/patches/loki-file-access/src/platform/android/jni_common.rs b/patches/loki-file-access/src/platform/android/jni_common.rs new file mode 100644 index 00000000..ebe01da7 --- /dev/null +++ b/patches/loki-file-access/src/platform/android/jni_common.rs @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! Shared JNI error helpers used by the Android platform modules. +//! +//! Extracted here so both [`super::jni_intents`] and [`super::jni_activity`] +//! can import without circular dependencies. + +use crate::error::PickerError; + +// ── Error conversions ───────────────────────────────────────────────────────── + +pub(super) fn jvm_err(e: jni::errors::Error) -> PickerError { + PickerError::Platform { + message: format!("failed to get JavaVM: {e}"), + } +} + +pub(super) fn attach_err(e: jni::errors::Error) -> PickerError { + PickerError::Platform { + message: format!("failed to attach JNI thread: {e}"), + } +} + +pub(super) fn platform_err(ctx: &str, e: impl std::fmt::Display) -> PickerError { + PickerError::Platform { + message: format!("{ctx}: {e}"), + } +} diff --git a/patches/loki-file-access/src/platform/android/jni_fd.rs b/patches/loki-file-access/src/platform/android/jni_fd.rs new file mode 100644 index 00000000..35271efe --- /dev/null +++ b/patches/loki-file-access/src/platform/android/jni_fd.rs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! Android file-descriptor and permission-checking JNI helpers. +//! +//! Split from [`super::jni_intents`] to keep each file under 300 lines. + +use super::jni_common::{attach_err, jvm_err, platform_err}; +use crate::error::{AccessError, PickerError}; +use crate::token::PermissionStatus; + +/// Query `ContentResolver.getPersistedUriPermissions()` for a URI. +pub(in crate::platform) fn check_persisted_permission( + uri: &str, +) -> Result { + let ctx = ndk_context::android_context(); + let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.map_err(jvm_err)?; + let mut env = vm.attach_current_thread().map_err(attach_err)?; + + let resolver = super::jni_intents::get_content_resolver(&mut env, &ctx)?; + + let list = env + .call_method( + &resolver, + "getPersistedUriPermissions", + "()Ljava/util/List;", + &[], + ) + .map_err(|e| platform_err("getPersistedUriPermissions", e))? + .l() + .map_err(|e| platform_err("result", e))?; + + let size = env + .call_method(&list, "size", "()I", &[]) + .map_err(|e| platform_err("size", e))? + .i() + .map_err(|e| platform_err("size int", e))?; + + for i in 0..size { + let perm = env + .call_method( + &list, + "get", + "(I)Ljava/lang/Object;", + &[jni::objects::JValueGen::Int(i)], + ) + .map_err(|e| platform_err("get", e))? + .l() + .map_err(|e| platform_err("get object", e))?; + + let perm_uri = env + .call_method(&perm, "getUri", "()Landroid/net/Uri;", &[]) + .map_err(|e| platform_err("getUri", e))? + .l() + .map_err(|e| platform_err("getUri object", e))?; + + let s: String = env + .call_method(&perm_uri, "toString", "()Ljava/lang/String;", &[]) + .map_err(|e| platform_err("toString", e))? + .l() + .map_err(|e| platform_err("toString object", e)) + .and_then(|obj| { + let jstr: jni::objects::JString = obj.into(); + env.get_string(&jstr) + .map_err(|e| platform_err("read string", e)) + .map(|js| js.into()) + })?; + + if s == uri { + return Ok(PermissionStatus::Valid); + } + } + + Ok(PermissionStatus::Revoked) +} + +/// Open a file descriptor for a content URI via `ContentResolver`. +pub(in crate::platform) fn open_fd(uri: &str, mode: &str) -> Result { + let ctx = ndk_context::android_context(); + let vm = + unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.map_err(|_| access_err("get JavaVM"))?; + let mut env = vm + .attach_current_thread() + .map_err(|_| access_err("attach thread"))?; + + let uri_obj = parse_uri_for_access(&mut env, uri)?; + let mode_str = env + .new_string(mode) + .map_err(|_| access_err("mode string"))?; + + let activity = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }; + let resolver = env + .call_method( + &activity, + "getContentResolver", + "()Landroid/content/ContentResolver;", + &[], + ) + .map_err(|_| access_err("getContentResolver"))? + .l() + .map_err(|_| AccessError::InvalidDescriptor)?; + + let pfd = env + .call_method( + &resolver, + "openFileDescriptor", + "(Landroid/net/Uri;Ljava/lang/String;)Landroid/os/ParcelFileDescriptor;", + &[ + jni::objects::JValueGen::Object(&uri_obj), + jni::objects::JValueGen::Object(&mode_str), + ], + ) + .map_err(|_| access_err("openFileDescriptor"))? + .l() + .map_err(|_| AccessError::InvalidDescriptor)?; + + env.call_method(&pfd, "detachFd", "()I", &[]) + .map_err(|_| access_err("detachFd"))? + .i() + .map_err(|_| AccessError::InvalidDescriptor) +} + +/// Parse a URI string for access-error contexts. +fn parse_uri_for_access<'a>( + env: &mut jni::JNIEnv<'a>, + uri: &str, +) -> Result, AccessError> { + let cls = env + .find_class("android/net/Uri") + .map_err(|_| access_err("Uri class"))?; + let s = env.new_string(uri).map_err(|_| access_err("URI string"))?; + env.call_static_method( + &cls, + "parse", + "(Ljava/lang/String;)Landroid/net/Uri;", + &[jni::objects::JValueGen::Object(&s)], + ) + .map_err(|_| access_err("Uri.parse"))? + .l() + .map_err(|_| AccessError::InvalidDescriptor) +} + +fn access_err(ctx: &str) -> AccessError { + AccessError::Platform { + message: format!("{ctx} failed"), + } +} diff --git a/patches/loki-file-access/src/platform/android/jni_ime.rs b/patches/loki-file-access/src/platform/android/jni_ime.rs new file mode 100644 index 00000000..f38a622f --- /dev/null +++ b/patches/loki-file-access/src/platform/android/jni_ime.rs @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! JNI install of the Android soft-keyboard (IME) visibility listener. +//! +//! `ImeInsetsListener` (a Java shim) observes the decor view's window insets and +//! calls back into `nativeOnImeInsetsChanged` on every IME visibility change — +//! including the user-initiated dismiss / re-summon that the OS otherwise never +//! reports to a `NativeActivity`. +//! +//! Install flow: +//! 1. Load the shim through the *application* class loader — a JNI-attached +//! native thread's default `FindClass` loader resolves only framework +//! classes, so an app class must be reached via the activity's own loader. +//! 2. Bind the native callback with `RegisterNatives`, so the binding does not +//! depend on the host application's `.so` name (unlike symbol-name +//! resolution, which is class-loader-scoped on Android 7+). +//! 3. Call the shim's static `install`, which registers the decor-view listener +//! on the UI thread. + +use std::ffi::c_void; +use std::sync::{Mutex, OnceLock}; + +use jni::objects::{JClass, JObject, JValueGen}; +use jni::sys::jboolean; +use jni::{JNIEnv, NativeMethod}; + +/// Fully-qualified name of the Java shim (for `ClassLoader.loadClass`). +const IME_CLASS_DOT: &str = "io.github.appthere.lokifileaccess.ImeInsetsListener"; + +/// Closure invoked (on the Android UI thread) whenever the soft keyboard's +/// visibility changes. `true` = keyboard now visible, `false` = collapsed. +type ImeCallback = Box; +static IME_CALLBACK: OnceLock>> = OnceLock::new(); + +fn ime_callback() -> &'static Mutex> { + IME_CALLBACK.get_or_init(|| Mutex::new(None)) +} + +/// Register the closure invoked on every soft-keyboard visibility change. +/// +/// Call once before [`install_ime_listener`]. A later call replaces the +/// previous closure. +pub fn set_ime_visibility_listener(callback: ImeCallback) { + if let Ok(mut guard) = ime_callback().lock() { + *guard = Some(callback); + } +} + +/// Install the decor-view IME inset listener for `activity_ptr` +/// (`AndroidApp::activity_as_ptr()`). +/// +/// Returns `true` when the Java `install` was invoked. Returns `false` on a null +/// pointer or any JNI failure; the Java side additionally no-ops below API 30, +/// matching the query-side fallback. Installing twice simply replaces the decor +/// view's listener. +pub fn install_ime_listener(activity_ptr: *mut c_void) -> bool { + if activity_ptr.is_null() { + return false; + } + let ctx = ndk_context::android_context(); + // SAFETY: ndk_context stores the JVM pointer initialised by android-activity + // before android_main; valid for the process lifetime. + let vm = match unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) } { + Ok(vm) => vm, + Err(_) => return false, + }; + let mut env = match vm.attach_current_thread() { + Ok(env) => env, + Err(_) => return false, + }; + let ok = install_with_env(&mut env, activity_ptr).is_some(); + // Clear any pending exception (e.g. from a failed class load) so the calling + // thread stays usable. + let _ = env.exception_clear(); + ok +} + +fn install_with_env(env: &mut JNIEnv<'_>, activity_ptr: *mut c_void) -> Option<()> { + // SAFETY: `activity_ptr` is the global-ref activity jobject owned by + // AndroidApp, valid for the activity's lifetime. + let activity = unsafe { JObject::from_raw(activity_ptr.cast()) }; + + let class = load_app_class(env, &activity)?; + register_native(env, &class)?; + + // ImeInsetsListener.install(activity) + env.call_static_method( + &class, + "install", + "(Landroid/app/Activity;)V", + &[JValueGen::Object(&activity)], + ) + .ok()?; + Some(()) +} + +/// Load `ImeInsetsListener` through the application class loader. +/// +/// A native thread's default class loader resolves only framework classes, so +/// we reach the app class via the activity's own `getClassLoader().loadClass`. +fn load_app_class<'a>(env: &mut JNIEnv<'a>, activity: &JObject<'_>) -> Option> { + let activity_class = env.get_object_class(activity).ok()?; + let loader = env + .call_method( + &activity_class, + "getClassLoader", + "()Ljava/lang/ClassLoader;", + &[], + ) + .ok()? + .l() + .ok()?; + let name = env.new_string(IME_CLASS_DOT).ok()?; + let class_obj = env + .call_method( + &loader, + "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;", + &[JValueGen::Object(&name)], + ) + .ok()? + .l() + .ok()?; + Some(JClass::from(class_obj)) +} + +/// Bind `nativeOnImeInsetsChanged` to [`ime_insets_changed`] via +/// `RegisterNatives`, so the callback resolves regardless of the host +/// application's native-library name. +fn register_native(env: &mut JNIEnv<'_>, class: &JClass<'_>) -> Option<()> { + let methods = [NativeMethod { + name: "nativeOnImeInsetsChanged".into(), + sig: "(Z)V".into(), + fn_ptr: ime_insets_changed as *mut c_void, + }]; + env.register_native_methods(class, &methods).ok() +} + +/// JNI callback invoked by `ImeInsetsListener.onApplyWindowInsets` on the +/// Android UI thread whenever the soft keyboard's visibility changes. +extern "system" fn ime_insets_changed(_env: JNIEnv<'_>, _class: JClass<'_>, ime_visible: jboolean) { + let visible = ime_visible != 0; + if let Ok(guard) = ime_callback().lock() + && let Some(callback) = guard.as_ref() + { + callback(visible); + } +} diff --git a/patches/loki-file-access/src/platform/android/jni_insets.rs b/patches/loki-file-access/src/platform/android/jni_insets.rs new file mode 100644 index 00000000..31804249 --- /dev/null +++ b/patches/loki-file-access/src/platform/android/jni_insets.rs @@ -0,0 +1,294 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! JNI query for Android system-bar heights. +//! +//! Uses `Resources.getDimensionPixelSize` with the well-known system resource +//! identifiers `status_bar_height` and `navigation_bar_height`. These resource +//! identifiers are available immediately at process start — before the window +//! is laid out — making them safe to query from `android_main`. +//! +//! Returns heights in density-independent pixels (dp / CSS px) so callers can +//! apply them directly as CSS `padding` values. + +use jni::JNIEnv; +use jni::objects::{JObject, JValueGen}; + +/// Query Android system-bar heights from OS resources. +/// +/// Returns `(top_dp, bottom_dp)` where: +/// - `top_dp` is the status-bar height (top of screen) +/// - `bottom_dp` is the navigation-bar height (bottom of screen; 0 when using +/// full gesture navigation with no visible bar) +/// +/// Falls back to `(24.0, 0.0)` on any JNI failure so the status bar area is +/// always reserved even if the exact height cannot be determined. +pub(super) fn query_insets_dp() -> (f32, f32) { + // Clear any stale JNI exception from the fallback path so the caller's + // thread remains usable. Any pending exception is cleared at the end of + // do_query regardless of the outcome. + do_query().unwrap_or((24.0, 0.0)) +} + +// ── Implementation ──────────────────────────────────────────────────────────── + +fn do_query() -> Option<(f32, f32)> { + let ctx = ndk_context::android_context(); + // SAFETY: ndk_context stores the JVM pointer initialised by android-activity + // before android_main is called. It is valid for the process lifetime. + let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.ok()?; + let mut env = vm.attach_current_thread().ok()?; + + let result = query_with_env(&mut env); + + // Clear any pending JNI exception so the calling thread stays usable. + let _ = env.exception_clear(); + + result +} + +fn query_with_env(env: &mut JNIEnv<'_>) -> Option<(f32, f32)> { + let ctx = ndk_context::android_context(); + // SAFETY: ndk_context stores the Application jobject; Application IS a + // Context and provides Resources, so this is safe for resource queries. + let context = unsafe { JObject::from_raw(ctx.context().cast()) }; + + // context.getResources() → android.content.res.Resources + let resources = env + .call_method( + &context, + "getResources", + "()Landroid/content/res/Resources;", + &[], + ) + .ok()? + .l() + .ok()?; + + // resources.getDisplayMetrics() → android.util.DisplayMetrics + let metrics = env + .call_method( + &resources, + "getDisplayMetrics", + "()Landroid/util/DisplayMetrics;", + &[], + ) + .ok()? + .l() + .ok()?; + + // DisplayMetrics.density: float — e.g. 1.0 (mdpi), 2.0 (xhdpi), 3.0 (xxhdpi) + let density = env.get_field(&metrics, "density", "F").ok()?.f().ok()?; + if density <= 0.0 { + return None; + } + + let top_dp = dimen_dp(env, &resources, "status_bar_height", density).unwrap_or(24.0); + let bottom_dp = dimen_dp(env, &resources, "navigation_bar_height", density).unwrap_or(0.0); + + Some((top_dp, bottom_dp)) +} + +// ── Orientation-aware window insets (edge-to-edge) ────────────────────────────── + +/// Query the actual per-side safe-area insets from the activity's window, in dp. +/// +/// Returns `(top, bottom, left, right)` from +/// `decorView.getRootWindowInsets().getInsets(systemBars | displayCutout | ime)` +/// — the real, orientation-aware insets for an edge-to-edge window (e.g. in +/// landscape the navigation bar / cutout move to a side, so `left`/`right` +/// become non-zero and `top` shrinks). Unlike [`query_insets_dp`], this is not +/// orientation-independent. +/// +/// The mask also includes the soft-keyboard (IME) inset, so when the keyboard +/// is visible the returned `bottom` grows to the keyboard height (the +/// `getInsets` union takes the per-side max, and `ime()` only contributes a +/// bottom inset). Re-querying this after the keyboard is shown/hidden — see the +/// IME-settle re-sync in `blitz-shell` — lets the app reserve a bottom safe +/// area for the keyboard on a `NativeActivity` whose surface does not resize. +/// +/// `activity_ptr` is the activity `jobject` from +/// `android_activity::AndroidApp::activity_as_ptr()` — the `ndk_context` context +/// is the *Application*, which has no window, so the activity must be passed in. +/// +/// Returns `None` (caller should fall back to [`query_insets_dp`]) when the view +/// is not yet attached (`getRootWindowInsets` is null), on API < 30 +/// (`getInsets(int)` unavailable), or on any JNI failure. +pub(super) fn query_window_insets_dp( + activity_ptr: *mut std::ffi::c_void, +) -> Option<(f32, f32, f32, f32)> { + if activity_ptr.is_null() { + return None; + } + let ctx = ndk_context::android_context(); + // SAFETY: ndk_context stores the JVM pointer initialised by android-activity + // before android_main; valid for the process lifetime. + let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.ok()?; + let mut env = vm.attach_current_thread().ok()?; + let result = window_insets_with_env(&mut env, activity_ptr); + // Clear any pending exception (e.g. NoSuchMethodError on API < 30) so the + // calling thread stays usable. + let _ = env.exception_clear(); + result +} + +fn window_insets_with_env( + env: &mut JNIEnv<'_>, + activity_ptr: *mut std::ffi::c_void, +) -> Option<(f32, f32, f32, f32)> { + // SAFETY: `activity_ptr` is a global reference jobject owned by AndroidApp, + // valid for the activity's lifetime. + let activity = unsafe { JObject::from_raw(activity_ptr.cast()) }; + + // activity.getWindow().getDecorView().getRootWindowInsets() + let window = env + .call_method(&activity, "getWindow", "()Landroid/view/Window;", &[]) + .ok()? + .l() + .ok()?; + let decor = env + .call_method(&window, "getDecorView", "()Landroid/view/View;", &[]) + .ok()? + .l() + .ok()?; + let insets_obj = env + .call_method( + &decor, + "getRootWindowInsets", + "()Landroid/view/WindowInsets;", + &[], + ) + .ok()? + .l() + .ok()?; + if insets_obj.as_raw().is_null() { + return None; // view not attached yet + } + + // mask = WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout() + // | WindowInsets.Type.ime() (all static int methods, API 30+). + let type_cls = env.find_class("android/view/WindowInsets$Type").ok()?; + let system_bars = env + .call_static_method(&type_cls, "systemBars", "()I", &[]) + .ok()? + .i() + .ok()?; + let cutout = env + .call_static_method(&type_cls, "displayCutout", "()I", &[]) + .ok()? + .i() + .ok()?; + // Fold in the soft-keyboard (IME) inset so `bottom` reserves space for the + // keyboard when it is visible. `getInsets` returns the per-side union and + // `ime()` contributes only a bottom inset, so top/left/right are unaffected + // while the keyboard is hidden (its bottom inset is then 0). `ime()` is API + // 30+, the same level as `getInsets(int)`, so the existing `.ok()?` + // fallback to `query_insets_dp` already covers API < 30. + let ime = env + .call_static_method(&type_cls, "ime", "()I", &[]) + .ok()? + .i() + .ok()?; + let mask = system_bars | cutout | ime; + + // insets = windowInsets.getInsets(mask) → android.graphics.Insets (API 30+) + let insets = env + .call_method( + &insets_obj, + "getInsets", + "(I)Landroid/graphics/Insets;", + &[JValueGen::Int(mask)], + ) + .ok()? + .l() + .ok()?; + let left = env.get_field(&insets, "left", "I").ok()?.i().ok()?; + let top = env.get_field(&insets, "top", "I").ok()?.i().ok()?; + let right = env.get_field(&insets, "right", "I").ok()?.i().ok()?; + let bottom = env.get_field(&insets, "bottom", "I").ok()?.i().ok()?; + + let density = display_density(env)?; + if density <= 0.0 { + return None; + } + Some(( + top as f32 / density, + bottom as f32 / density, + left as f32 / density, + right as f32 / density, + )) +} + +/// Display density (e.g. 2.625) from the Application's `DisplayMetrics`. +fn display_density(env: &mut JNIEnv<'_>) -> Option { + let ctx = ndk_context::android_context(); + // SAFETY: ndk_context stores the Application jobject; it provides Resources. + let context = unsafe { JObject::from_raw(ctx.context().cast()) }; + let resources = env + .call_method( + &context, + "getResources", + "()Landroid/content/res/Resources;", + &[], + ) + .ok()? + .l() + .ok()?; + let metrics = env + .call_method( + &resources, + "getDisplayMetrics", + "()Landroid/util/DisplayMetrics;", + &[], + ) + .ok()? + .l() + .ok()?; + env.get_field(&metrics, "density", "F").ok()?.f().ok() +} + +/// Look up one Android system dimension resource and convert physical pixels → dp. +fn dimen_dp( + env: &mut JNIEnv<'_>, + resources: &JObject<'_>, + name: &str, + density: f32, +) -> Option { + let name_jstr = env.new_string(name).ok()?; + let type_jstr = env.new_string("dimen").ok()?; + let pkg_jstr = env.new_string("android").ok()?; + + // resources.getIdentifier(name, "dimen", "android") → int resource ID + let res_id: i32 = env + .call_method( + resources, + "getIdentifier", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I", + &[ + JValueGen::Object(&name_jstr), + JValueGen::Object(&type_jstr), + JValueGen::Object(&pkg_jstr), + ], + ) + .ok()? + .i() + .ok()?; + + if res_id == 0 { + return Some(0.0); + } + + // resources.getDimensionPixelSize(resId) → int physical pixels + let px: i32 = env + .call_method( + resources, + "getDimensionPixelSize", + "(I)I", + &[JValueGen::Int(res_id)], + ) + .ok()? + .i() + .ok()?; + + Some(px as f32 / density) +} diff --git a/patches/loki-file-access/src/platform/android/jni_intents.rs b/patches/loki-file-access/src/platform/android/jni_intents.rs new file mode 100644 index 00000000..0eafcd3b --- /dev/null +++ b/patches/loki-file-access/src/platform/android/jni_intents.rs @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! JNI helpers for post-pick SAF operations. +//! +//! Covers `ContentResolver.takePersistableUriPermission`, URI parsing, and the +//! content resolver accessor used by [`super::jni_fd`]. Intent launching is +//! handled by [`super::jni_activity`] (via `FilePickerActivity` trampoline). + +use super::jni_common::{attach_err, jvm_err, platform_err}; +use crate::error::PickerError; + +// ── SAF post-pick operations ────────────────────────────────────────────────── + +/// Call `ContentResolver.takePersistableUriPermission` for a content URI. +/// +/// Grants READ | WRITE persistable permission so the URI survives app restarts. +pub(super) fn take_persistable_uri_permission(uri: &str) -> Result<(), PickerError> { + let ctx = ndk_context::android_context(); + let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.map_err(jvm_err)?; + let mut env = vm.attach_current_thread().map_err(attach_err)?; + + let uri_obj = parse_uri(&mut env, uri)?; + let resolver = get_content_resolver(&mut env, &ctx)?; + + // FLAG_GRANT_READ_URI_PERMISSION (1) | FLAG_GRANT_WRITE_URI_PERMISSION (2) + env.call_method( + &resolver, + "takePersistableUriPermission", + "(Landroid/net/Uri;I)V", + &[ + jni::objects::JValueGen::Object(&uri_obj), + jni::objects::JValueGen::Int(3), + ], + ) + .map_err(|e| { + // A provider may throw SecurityException ("No persistable permission + // grants found"). Clear the pending Java exception so callers that + // tolerate this failure can keep making JNI calls on this thread. + let _ = env.exception_clear(); + platform_err("takePersistableUriPermission", e) + })?; + + Ok(()) +} + +// ── Display name query ──────────────────────────────────────────────────────── + +/// Query the human-readable display name for a content URI from ContentResolver. +/// +/// Uses `OpenableColumns.DISPLAY_NAME` (`"_display_name"`) via a cursor query. +/// Falls back to the last URI path segment (percent-decoded) on any JNI failure. +pub(super) fn query_display_name(uri_str: &str) -> String { + query_display_name_inner(uri_str).unwrap_or_else(|| { + let raw = uri_str.rsplit('/').next().unwrap_or("unnamed"); + percent_decode_last_segment(raw) + }) +} + +fn query_display_name_inner(uri_str: &str) -> Option { + let ctx = ndk_context::android_context(); + let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.ok()?; + let mut env = vm.attach_current_thread().ok()?; + + let uri_obj = parse_uri(&mut env, uri_str).ok()?; + let resolver = get_content_resolver(&mut env, &ctx).ok()?; + + // projection = new String[]{"_display_name"} + let str_cls = env.find_class("java/lang/String").ok()?; + let col_str = env.new_string("_display_name").ok()?; + let projection = env.new_object_array(1, &str_cls, &col_str).ok()?; + + // ContentResolver.query(uri, projection, null, null, null) → Cursor + // A misbehaving SAF provider may throw instead of returning null. Clear any + // pending JNI exception before returning so subsequent calls are not poisoned. + let null_obj = jni::objects::JObject::null(); + let query_result = env.call_method( + &resolver, + "query", + "(Landroid/net/Uri;[Ljava/lang/String;Ljava/lang/String;\ + [Ljava/lang/String;Ljava/lang/String;)Landroid/database/Cursor;", + &[ + jni::objects::JValueGen::Object(&uri_obj), + jni::objects::JValueGen::Object(&projection), + jni::objects::JValueGen::Object(&null_obj), + jni::objects::JValueGen::Object(&null_obj), + jni::objects::JValueGen::Object(&null_obj), + ], + ); + let cursor = match query_result { + Ok(v) => v.l().ok()?, + Err(_) => { + let _ = env.exception_clear(); + return None; + } + }; + + if cursor.is_null() { + return None; + } + + // Extract the display name, then close the cursor unconditionally. + // read_display_name uses ok()? internally, which can exit without clearing + // a pending JNI exception from moveToFirst/getColumnIndex/getString. + // Calling close() while an exception is pending violates the JNI spec and + // risks an ART abort, so we clear any pending exception first. + let result = read_display_name(&mut env, &cursor); + let _ = env.exception_clear(); + let _ = env.call_method(&cursor, "close", "()V", &[]); + result +} + +/// Read `_display_name` from an already-moved-to-first cursor. +/// Separated from the main function so that every return path is guaranteed +/// to reach `cursor.close()` in the caller — no `?` can skip it. +fn read_display_name( + env: &mut jni::JNIEnv<'_>, + cursor: &jni::objects::JObject<'_>, +) -> Option { + let moved = env + .call_method(cursor, "moveToFirst", "()Z", &[]) + .ok()? + .z() + .ok()?; + + if !moved { + return None; + } + + // Column index is always 0 when the single-column projection {"_display_name"} + // is honoured by the provider. Use getColumnIndex as a defensive check. + let col_name = env.new_string("_display_name").ok()?; + let col_idx = env + .call_method( + cursor, + "getColumnIndex", + "(Ljava/lang/String;)I", + &[jni::objects::JValueGen::Object(&col_name)], + ) + .ok()? + .i() + .ok()?; + + if col_idx < 0 { + return None; + } + + let s_obj = env + .call_method( + cursor, + "getString", + "(I)Ljava/lang/String;", + &[jni::objects::JValueGen::Int(col_idx)], + ) + .ok()? + .l() + .ok()?; + + if s_obj.is_null() { + return None; + } + + let jstr: jni::objects::JString = s_obj.into(); + env.get_string(&jstr).ok().map(|js| String::from(js)) +} + +/// Minimal percent-decoder for the last URI path segment used as fallback. +/// +/// Decodes `%XX` sequences by accumulating raw bytes and then interpreting the +/// entire buffer as UTF-8. This correctly handles multi-byte UTF-8 sequences +/// such as `%C3%A9` (é) — decoding each byte individually via `char::from(u8)` +/// would produce mojibake for any non-ASCII character. +/// +/// Invalid or incomplete `%XX` sequences (e.g. `%GG`, `%2`, `%`) are emitted +/// literally rather than dropped. In URI path segments `+` is a literal plus +/// sign, not a space — only `%20` encodes a space. +fn percent_decode_last_segment(s: &str) -> String { + let mut buf: Vec = Vec::with_capacity(s.len()); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hi = (bytes[i + 1] as char).to_digit(16); + let lo = (bytes[i + 2] as char).to_digit(16); + if let (Some(h), Some(l)) = (hi, lo) { + buf.push((h * 16 + l) as u8); + i += 3; + continue; + } + } + // Not a valid %XX sequence — emit the byte as-is. + buf.push(bytes[i]); + i += 1; + } + // from_utf8 avoids a Cow allocation on valid UTF-8 (the common case for + // URI path segments). The lossy fallback is only reached for malformed + // byte sequences, which should not occur in well-formed content URIs. + let decoded = String::from_utf8(buf) + .unwrap_or_else(|e| String::from_utf8_lossy(&e.into_bytes()).into_owned()); + if decoded.is_empty() { + "unnamed".to_string() + } else { + decoded + } +} + +// ── Shared helpers ──────────────────────────────────────────────────────────── + +/// Parse a URI string into a `Uri` JNI object via `Uri.parse(String)`. +pub(super) fn parse_uri<'a>( + env: &mut jni::JNIEnv<'a>, + uri: &str, +) -> Result, PickerError> { + let cls = env + .find_class("android/net/Uri") + .map_err(|e| platform_err("Uri class", e))?; + let s = env + .new_string(uri) + .map_err(|e| platform_err("URI string", e))?; + env.call_static_method( + &cls, + "parse", + "(Ljava/lang/String;)Landroid/net/Uri;", + &[jni::objects::JValueGen::Object(&s)], + ) + .map_err(|e| platform_err("Uri.parse", e))? + .l() + .map_err(|e| platform_err("Uri.parse object", e)) +} + +/// Get the `ContentResolver` from the ndk_context Application/Activity. +pub(super) fn get_content_resolver<'a>( + env: &mut jni::JNIEnv<'a>, + ctx: &ndk_context::AndroidContext, +) -> Result, PickerError> { + // SAFETY: ndk_context stores a valid Application jobject. + let context = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }; + env.call_method( + &context, + "getContentResolver", + "()Landroid/content/ContentResolver;", + &[], + ) + .map_err(|e| platform_err("getContentResolver", e))? + .l() + .map_err(|e| platform_err("getContentResolver object", e)) +} diff --git a/patches/loki-file-access/src/platform/android/mod.rs b/patches/loki-file-access/src/platform/android/mod.rs new file mode 100644 index 00000000..443921b3 --- /dev/null +++ b/patches/loki-file-access/src/platform/android/mod.rs @@ -0,0 +1,384 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! Android file-picker implementation using the Storage Access Framework (SAF). +//! +//! File access is mediated through content URIs and `ContentResolver`, +//! ensuring that the app never accesses files via filesystem paths (unreliable +//! on modern Android). +//! +//! # Persistence +//! +//! After the user selects a file, `ContentResolver.takePersistableUriPermission()` +//! is called with READ | WRITE flags. This ensures the URI grant survives app +//! restarts and device reboots. +//! +//! # NativeActivity integration +//! +//! Call [`init_android`] from `android_main` before launching Dioxus: +//! +//! ```rust,no_run +//! fn android_main(android_app: android_activity::AndroidApp) { +//! // init_android is a no-op kept for API compatibility; ndk_context +//! // (initialised by android-activity before android_main) provides the +//! // Application context used by all JNI calls. +//! unsafe { loki_file_access::init_android(std::ptr::null_mut()); } +//! blitz_shell::set_android_app(android_app); +//! dioxus::launch(App); +//! } +//! ``` +//! +//! # Result delivery +//! +//! `ANativeActivityCallbacks` has no `onActivityResult` slot, so results cannot +//! be delivered directly to NativeActivity. Instead, NativeActivity calls +//! `startActivity(Intent → FilePickerActivity)`, which is a transparent Java +//! trampoline that runs its own `startActivityForResult(ACTION_OPEN_DOCUMENT)`. +//! `FilePickerActivity.onActivityResult` delivers the URI via the pre-compiled +//! JNI hook `Java_io_github_appthere_lokifileaccess_FilePickerActivity_nativeOnResult`. +//! +//! **Prerequisite**: `FilePickerActivity` must be declared in `AndroidManifest.xml` +//! and its compiled `classes.dex` injected into the APK (see `scripts/build-android.ps1`). + +mod jni_activity; +mod jni_common; +mod jni_fd; +mod jni_ime; +mod jni_insets; +mod jni_intents; + +use std::sync::{Arc, Mutex, OnceLock}; + +use crate::api::{PickOptions, SaveOptions}; +use crate::error::{AccessError, PickerError}; +use crate::future::{deliver, new_pick_future}; +use crate::token::{FileAccessToken, PermissionStatus, ReadSeek, TokenInner, WriteSeek}; + +/// Pending pick state shared between the intent launcher and the JNI callback. +/// +/// The payload is `Vec`: empty means cancelled, non-empty contains the +/// selected content URIs (one for single-pick, one or more for multi-pick). +static PENDING_PICK: OnceLock>>>>>> = + OnceLock::new(); + +fn pending_pick() -> &'static Mutex>>>>> { + PENDING_PICK.get_or_init(|| Mutex::new(None)) +} + +// ── Public Android initialisation ───────────────────────────────────────────── + +/// Initialise the file-access layer. +/// +/// Must be called from `android_main` before launching Dioxus. The parameter +/// is accepted for API compatibility but is no longer stored — `startActivity` +/// now uses the Application context from `ndk_context` directly, which is set +/// up by `android-activity` before `android_main` is called. +/// +/// # Safety +/// +/// The caller is responsible for ensuring `android_main` setup (including +/// `ndk_context` initialisation by `android-activity`) is complete before +/// any file-picker calls are made. +pub unsafe fn init_android(_activity_as_ptr: *mut std::ffi::c_void) { + // No-op: ndk_context provides the Application object used by all JNI calls. +} + +/// Query Android system-bar heights from OS resources. +/// +/// Returns `(top_dp, bottom_dp)` — heights in density-independent pixels for +/// the status bar (top) and navigation bar (bottom). Safe to call immediately +/// after [`init_android`] before the window is laid out. +pub fn query_insets_dp() -> (f32, f32) { + jni_insets::query_insets_dp() +} + +/// Query orientation-aware safe-area insets from the activity window, in dp. +/// +/// Returns `(top, bottom, left, right)` from the real window insets (system bars +/// + display cutout), which — unlike [`query_insets_dp`] — change with +/// orientation. `activity_ptr` is `AndroidApp::activity_as_ptr()`. Returns +/// `None` before the window is laid out / on API < 30; callers fall back to +/// [`query_insets_dp`]. +pub fn query_window_insets_dp(activity_ptr: *mut std::ffi::c_void) -> Option<(f32, f32, f32, f32)> { + jni_insets::query_window_insets_dp(activity_ptr) +} + +// ── Soft-keyboard (IME) visibility signal ───────────────────────────────────── + +pub use jni_ime::{install_ime_listener, set_ime_visibility_listener}; + +// ── JNI result callback (called from Java FilePickerActivity) ───────────────── + +/// Delivers the selected URI (or `null` for cancellation) to the pending Rust future. +/// +/// Called by `FilePickerActivity.onActivityResult` via JNI. The method is +/// declared `private native void nativeOnResult(String)` in the Java class +/// `io.github.appthere.lokifileaccess.FilePickerActivity`, so the JVM resolves +/// it to this exported symbol automatically once the native library is loaded. +/// +/// **The native library is loaded by NativeActivity before `FilePickerActivity` +/// is ever created**, so `System.loadLibrary` is not needed in the Java class. +/// +/// # Safety +/// +/// Must be called from a JNI-attached Java thread. +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_io_github_appthere_lokifileaccess_FilePickerActivity_nativeOnResult( + mut env: jni::JNIEnv<'_>, + _this: jni::objects::JObject<'_>, + uri: jni::objects::JString<'_>, +) { + // The Java side sends a '\n'-delimited string of content URIs. + // Null or an empty string means the user cancelled. + let uris: Vec = if uri.is_null() { + Vec::new() + } else { + env.get_string(&uri) + .ok() + .map(|s| { + let joined: String = s.into(); + joined + .split('\n') + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default() + }; + on_activity_result(uris); +} + +// ── Pick / save entry points ────────────────────────────────────────────────── + +/// Pick a single file for reading via `ACTION_OPEN_DOCUMENT`. +pub(crate) async fn pick_open_single( + options: PickOptions, +) -> Result, PickerError> { + let uris = launch_open_intent(&options, false).await?; + match uris.into_iter().next() { + None => Ok(None), + Some(uri_str) => { + jni_intents::take_persistable_uri_permission(&uri_str)?; + let display_name = jni_intents::query_display_name(&uri_str); + Ok(Some(FileAccessToken { + inner: TokenInner::Android { + uri: uri_str, + display_name, + mime_type: None, + }, + })) + } + } +} + +/// Pick multiple files for reading via `ACTION_OPEN_DOCUMENT`. +pub(crate) async fn pick_open_multi( + options: PickOptions, +) -> Result, PickerError> { + let uris = launch_open_intent(&options, true).await?; + let mut tokens = Vec::with_capacity(uris.len()); + for uri_str in uris { + // Skip URIs whose persistable grant fails (e.g. a cloud provider that + // revoked the grant between SAF delivery and this call, or a URI that + // exceeds Android's per-app persisted-permission quota). Aborting the + // entire batch with `?` would orphan grants already taken for earlier + // URIs — those cannot be un-granted, silently consuming quota. + if jni_intents::take_persistable_uri_permission(&uri_str).is_err() { + tracing::warn!("loki-file-access: skipping URI with failed permission grant"); + continue; + } + let display_name = jni_intents::query_display_name(&uri_str); + tokens.push(FileAccessToken { + inner: TokenInner::Android { + uri: uri_str, + display_name, + mime_type: None, + }, + }); + } + Ok(tokens) +} + +/// Pick a save location via `ACTION_CREATE_DOCUMENT`. +pub(crate) async fn pick_save( + options: SaveOptions, +) -> Result, PickerError> { + let uris = launch_create_intent(&options).await?; + match uris.into_iter().next() { + None => Ok(None), + Some(uri_str) => { + // Best-effort: persist the grant so the document can be reopened + // (e.g. from a recents list) after an app restart. By this point + // `ACTION_CREATE_DOCUMENT` has already created the document and + // granted this session read/write access, so a provider that + // refuses persistable grants (SecurityException) must NOT abort + // the save — failing here stranded a freshly-created blank file + // and surfaced an error while the write itself would have + // succeeded. + if let Err(e) = jni_intents::take_persistable_uri_permission(&uri_str) { + tracing::warn!( + "takePersistableUriPermission failed for created document \ + (continuing; reopening after restart may require re-picking): {e}" + ); + } + // The user may have renamed the file in the create dialog — query + // the real display name (it drives format detection on export); + // fall back to the suggestion only if the query yields nothing. + let display_name = match jni_intents::query_display_name(&uri_str) { + name if !name.is_empty() => name, + _ => options + .suggested_name + .clone() + .unwrap_or_else(|| "untitled".into()), + }; + Ok(Some(FileAccessToken { + inner: TokenInner::Android { + uri: uri_str, + display_name, + mime_type: options.mime_type.clone(), + }, + })) + } + } +} + +/// Open a content URI for reading. +pub(crate) fn open_read(inner: &TokenInner) -> Result, AccessError> { + match inner { + TokenInner::Android { uri, .. } => { + let fd = jni_fd::open_fd(uri, "r")?; + // SAFETY: `open_fd` returns a valid file descriptor from + // Android's `ContentResolver.openFileDescriptor` after detaching it. + // The caller takes ownership; it must not be double-closed. + let file: std::fs::File = unsafe { std::os::fd::FromRawFd::from_raw_fd(fd) }; + Ok(Box::new(file)) + } + _ => Err(AccessError::Platform { + message: "non-Android token on Android platform".into(), + }), + } +} + +/// Open a content URI for writing. +pub(crate) fn open_write(inner: &TokenInner) -> Result, AccessError> { + match inner { + TokenInner::Android { uri, .. } => { + let fd = jni_fd::open_fd(uri, "w")?; + // SAFETY: Same invariant as `open_read` — see above. + let file: std::fs::File = unsafe { std::os::fd::FromRawFd::from_raw_fd(fd) }; + Ok(Box::new(file)) + } + _ => Err(AccessError::Platform { + message: "non-Android token on Android platform".into(), + }), + } +} + +/// Delete the file referenced by a token. +/// +/// Deleting a SAF document URI requires `DocumentsContract.deleteDocument`, +/// which is not yet wired through JNI. Return an explicit unsupported error +/// rather than silently succeeding. +pub(crate) fn delete(_inner: &TokenInner) -> Result<(), AccessError> { + Err(AccessError::Unsupported { + operation: "delete (Android SAF content-URI deletion not implemented)".into(), + }) +} + +/// Check whether a persistable URI permission is still held. +pub(crate) fn check_permission(inner: &TokenInner) -> PermissionStatus { + match inner { + TokenInner::Android { uri, .. } => { + jni_fd::check_persisted_permission(uri).unwrap_or(PermissionStatus::Unknown) + } + _ => PermissionStatus::Unknown, + } +} + +/// Deliver the selected URIs (or an empty Vec for cancellation) to the pending future. +pub fn on_activity_result(uris: Vec) { + let guard = match pending_pick().lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + if let Some(ref state) = *guard { + deliver(state, uris); + } +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +/// Seconds to wait before treating a non-returning file picker as abandoned. +const PICKER_TIMEOUT_SECS: u64 = 600; + +/// Spawn a background thread that cancels the pick after [`PICKER_TIMEOUT_SECS`]. +/// +/// If `nativeOnResult` fires before the deadline, the PickState already has a +/// result (`result.is_some()`). The thread then exits without overwriting it, +/// so a real URI is never silently replaced by a spurious cancellation. +/// +/// This guards against Android 12+ silently blocking `startActivity` when the +/// app has no foreground window: without this thread, `future.await` hangs +/// forever. The spawned thread exits immediately after the normal pick +/// completes, so the OS thread count stays bounded in typical usage. +fn spawn_timeout_guard(state: Arc>>>) { + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(PICKER_TIMEOUT_SECS)); + // Only deliver if the pick has not already resolved. Overwriting a + // real result here would silently cancel a successful file open. + let waker = { + let mut guard = match state.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + if guard.result.is_some() { + return; // Real result already delivered; nothing to do. + } + guard.result = Some(Vec::new()); + guard.waker.take() + }; + if let Some(w) = waker { + w.wake(); + } + }); +} + +fn store_pending( + state: Arc>>>, +) -> Result<(), PickerError> { + let mut guard = pending_pick().lock().map_err(|e| PickerError::Internal { + message: e.to_string(), + })?; + *guard = Some(state); + Ok(()) +} + +/// Launch `FilePickerActivity` to open a file and await the result. +/// +/// Returns an empty `Vec` on cancellation, or one URI per selected file. +async fn launch_open_intent( + options: &PickOptions, + allow_multiple: bool, +) -> Result, PickerError> { + let (future, state) = new_pick_future::>(); + // Clone the Arc before moving `state` into store_pending so the timeout + // thread can deliver an empty result if nativeOnResult never fires. + let timeout_state = Arc::clone(&state); + store_pending(state)?; + jni_activity::fire_open_file_picker(options, allow_multiple)?; + spawn_timeout_guard(timeout_state); + Ok(future.await) +} + +/// Launch `FilePickerActivity` to save a file and await the result. +/// +/// Returns an empty `Vec` on cancellation, or a single-element `Vec` on success. +async fn launch_create_intent(options: &SaveOptions) -> Result, PickerError> { + let (future, state) = new_pick_future::>(); + let timeout_state = Arc::clone(&state); + store_pending(state)?; + jni_activity::fire_create_file_picker(options)?; + spawn_timeout_guard(timeout_state); + Ok(future.await) +} diff --git a/patches/loki-file-access/src/platform/desktop/filters.rs b/patches/loki-file-access/src/platform/desktop/filters.rs new file mode 100644 index 00000000..14131183 --- /dev/null +++ b/patches/loki-file-access/src/platform/desktop/filters.rs @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! MIME-type-to-extension mapping and extension validation for `rfd` filters. +//! +//! Kept in its own file to stay within the 300-line ceiling for each source +//! file while allowing `super` to use both helpers without qualification. + +/// Returns `true` if `ext` is safe to pass as an `rfd` filter extension. +/// +/// Windows `IFileOpenDialog::SetFileTypes` requires extension strings that +/// contain no dots, slashes, or whitespace. MIME subtype fallbacks for +/// `vnd.*` types (e.g. `vnd.openxmlformats-officedocument.wordprocessingml.document`) +/// contain dots and would produce a filter pattern that matches no real files, +/// causing the dialog to display only folder entries. +pub(super) fn is_valid_extension(ext: &str) -> bool { + !ext.is_empty() + && ext + .bytes() + .all(|b| b != b'.' && b != b'/' && b != b'\\' && !b.is_ascii_whitespace()) +} + +/// Convert MIME types to file extensions for the `rfd` filter. +/// +/// Returns one extension string per input MIME type. Results that are not +/// valid Windows `SetFileTypes` extension strings (containing dots, slashes, +/// or whitespace) should be discarded by the caller via [`is_valid_extension`] +/// before passing to `rfd::AsyncFileDialog::add_filter`. +/// +/// For MIME types not in the static table the subtype component is returned +/// as-is; [`is_valid_extension`] will reject any subtype that contains dots +/// (e.g. all `vnd.*` types not listed here). +pub(super) fn mime_types_to_extensions(mime_types: &[String]) -> Vec { + mime_types + .iter() + .map(|mime| match mime.as_str() { + // Plain text / markup + "text/plain" => "txt".into(), + "text/html" => "html".into(), + "text/css" => "css".into(), + "text/csv" => "csv".into(), + "text/markdown" | "text/x-markdown" => "md".into(), + "text/rtf" | "application/rtf" => "rtf".into(), + // Data formats + "application/json" => "json".into(), + "application/xml" | "text/xml" => "xml".into(), + "application/pdf" => "pdf".into(), + // Archives + "application/zip" => "zip".into(), + "application/x-tar" => "tar".into(), + "application/gzip" | "application/x-gzip" => "gz".into(), + "application/x-bzip2" => "bz2".into(), + "application/x-7z-compressed" => "7z".into(), + // Images + "image/png" => "png".into(), + "image/jpeg" => "jpg".into(), + "image/gif" => "gif".into(), + "image/svg+xml" => "svg".into(), + "image/webp" => "webp".into(), + "image/tiff" => "tiff".into(), + "image/bmp" => "bmp".into(), + // Audio / video + "audio/mpeg" => "mp3".into(), + "audio/wav" | "audio/x-wav" => "wav".into(), + "audio/ogg" => "ogg".into(), + "audio/flac" => "flac".into(), + "video/mp4" => "mp4".into(), + "video/webm" => "webm".into(), + "video/x-matroska" => "mkv".into(), + // Microsoft Office (legacy binary formats) + "application/msword" => "doc".into(), + "application/vnd.ms-excel" => "xls".into(), + "application/vnd.ms-powerpoint" => "ppt".into(), + // Microsoft Office Open XML + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => { + "docx".into() + } + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx".into(), + "application/vnd.openxmlformats-officedocument.presentationml.presentation" => { + "pptx".into() + } + // OpenDocument formats + "application/vnd.oasis.opendocument.text" => "odt".into(), + "application/vnd.oasis.opendocument.spreadsheet" => "ods".into(), + "application/vnd.oasis.opendocument.presentation" => "odp".into(), + // E-book / other documents + "application/epub+zip" => "epub".into(), + other => { + // Fall back to the subtype component; callers must discard + // results that fail `is_valid_extension` (e.g. vnd.* subtypes + // not listed above that contain dots). + other.split('/').nth(1).unwrap_or(other).to_owned() + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_mime_types_produce_valid_extensions() { + let cases = [ + ("text/plain", "txt"), + ("application/pdf", "pdf"), + ("application/msword", "doc"), + ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "docx", + ), + ("application/vnd.ms-excel", "xls"), + ( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "xlsx", + ), + ("application/vnd.oasis.opendocument.text", "odt"), + ("image/svg+xml", "svg"), + ("application/epub+zip", "epub"), + ]; + for (mime, expected_ext) in cases { + let result = mime_types_to_extensions(&[mime.to_owned()]); + assert_eq!(result[0], expected_ext, "wrong ext for {mime}"); + assert!( + is_valid_extension(&result[0]), + "ext '{}' for '{mime}' failed is_valid_extension", + result[0] + ); + } + } + + #[test] + fn unlisted_vnd_mime_type_produces_invalid_extension() { + // An unlisted vnd.* type hits the fallback and produces a dotted + // subtype that must be rejected by is_valid_extension. + let result = mime_types_to_extensions(&["application/vnd.example.format".to_owned()]); + assert!( + !is_valid_extension(&result[0]), + "dotted fallback must be invalid" + ); + } + + #[test] + fn is_valid_extension_rejects_dots_and_slashes() { + assert!(!is_valid_extension("")); + assert!(!is_valid_extension("vnd.foo.bar")); + assert!(!is_valid_extension("foo/bar")); + assert!(!is_valid_extension("foo bar")); + assert!(is_valid_extension("docx")); + assert!(is_valid_extension("txt")); + assert!(is_valid_extension("7z")); + } +} diff --git a/patches/loki-file-access/src/platform/desktop/mod.rs b/patches/loki-file-access/src/platform/desktop/mod.rs new file mode 100644 index 00000000..cba1dda8 --- /dev/null +++ b/patches/loki-file-access/src/platform/desktop/mod.rs @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! Desktop file-picker implementation (Windows, macOS, Linux, BSD). +//! +//! This module wraps [`rfd::AsyncFileDialog`] and simply `.await`s its +//! futures. `rfd` carries its own internal dispatch mechanism and does **not** +//! require Tokio or async-std; any executor that correctly yields between polls +//! works (Dioxus, egui, Iced, `pollster::block_on`, etc.). +//! +//! ## Why `.await` instead of `pollster::block_on` +//! +//! On **macOS**, `rfd::AsyncFileDialog` presents `NSOpenPanel` by dispatching +//! to the main thread via Grand Central Dispatch (`dispatch_async` on the main +//! queue). If the caller blocks its own thread with `pollster::block_on` +//! *and that thread is the main thread* (the typical case in Dioxus Native), +//! GCD can never execute the dispatch — the dialog never appears and the app +//! hangs with a spinning beach ball. +//! +//! Co-operatively awaiting the future with `.await` yields to the executor, +//! keeping the main-thread run loop free to process GCD events. On +//! **Windows** and **Linux** there is no main-thread constraint, so `.await` +//! is equally correct there. +//! +//! ## Linux fallback +//! +//! On Linux, rfd uses the XDG Desktop Portal via D-Bus. When the portal is +//! unavailable (e.g. ChromeOS Crostini), rfd returns `None`. In that case +//! the picker falls back to `zenity` (a GNOME subprocess dialog) if it is +//! installed. The zenity call runs on a dedicated thread so it does not block +//! the async executor. + +mod filters; +#[cfg(target_os = "linux")] +mod zenity; + +use filters::{is_valid_extension, mime_types_to_extensions}; + +use crate::api::{PickOptions, SaveOptions}; +use crate::error::{AccessError, PickerError}; +use crate::token::{FileAccessToken, PermissionStatus, ReadSeek, TokenInner, WriteSeek}; + +/// On Linux, check the `LOKI_FILE_ACCESS_BACKEND` environment variable. +/// +/// Setting it to `"none"` disables the picker and surfaces a clear error +/// instead of a silent no-op. This is a developer escape hatch for +/// environments where neither the XDG Desktop Portal nor zenity is available. +/// Valid values: `"auto"` (default), `"none"`. +#[cfg(target_os = "linux")] +fn check_backend_env() -> Result<(), PickerError> { + if std::env::var("LOKI_FILE_ACCESS_BACKEND").as_deref() == Ok("none") { + return Err(PickerError::Platform { + message: "file picker disabled via LOKI_FILE_ACCESS_BACKEND=none. \ + On Linux, the XDG Desktop Portal or zenity must be available. \ + Unset LOKI_FILE_ACCESS_BACKEND or set it to \"auto\" to re-enable." + .into(), + }); + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +#[inline(always)] +fn check_backend_env() -> Result<(), PickerError> { + Ok(()) +} + +/// Pick a single file for reading. +/// +/// Converts `options.mime_types` to file-extension filters understood by the +/// native dialog. On Windows, extensions that contain dots or other +/// characters invalid for `IFileOpenDialog::SetFileTypes` are silently +/// dropped; if all extensions are invalid the filter is omitted entirely so +/// that all files remain visible. +/// +/// On Linux, if the XDG Desktop Portal is unavailable, falls back to zenity. +/// +/// # Errors +/// +/// Returns [`PickerError`] if the platform dialog could not be presented. +/// +/// # Examples +/// +/// ```no_run +/// # async fn example() -> Result<(), loki_file_access::PickerError> { +/// use loki_file_access::{FilePicker, PickOptions}; +/// let token = FilePicker::new() +/// .pick_file_to_open(PickOptions { +/// mime_types: vec!["application/pdf".into()], +/// ..Default::default() +/// }) +/// .await?; +/// # Ok(()) } +/// ``` +pub(crate) async fn pick_open_single( + options: PickOptions, +) -> Result, PickerError> { + check_backend_env()?; + + // Capture filter data before building the rfd dialog so the zenity fallback + // thread closure can take ownership of the same values. + let filter_label = options + .filter_label + .as_deref() + .unwrap_or("Files") + .to_owned(); + let filter_exts: Vec = if !options.mime_types.is_empty() { + mime_types_to_extensions(&options.mime_types) + .into_iter() + .filter(|e| is_valid_extension(e)) + .collect() + } else { + vec![] + }; + + let mut dialog = rfd::AsyncFileDialog::new(); + if !filter_exts.is_empty() { + let ext_refs: Vec<&str> = filter_exts.iter().map(String::as_str).collect(); + dialog = dialog.add_filter(&filter_label, &ext_refs); + } + + match dialog.pick_file().await { + Some(h) => { + let path = h.path().to_path_buf(); + let display_name = file_name_from_path(&path); + Ok(Some(FileAccessToken { + inner: TokenInner::Desktop { path, display_name }, + })) + } + None => { + #[cfg(target_os = "linux")] + { + if zenity::is_zenity_available() { + tracing::debug!("XDG Desktop Portal unavailable; falling back to zenity"); + let (fut, state) = crate::future::new_pick_future(); + std::thread::spawn(move || { + let result = + zenity::pick_file("Open File".into(), filter_label, filter_exts).map( + |opt| { + opt.map(|path| { + let display_name = file_name_from_path(&path); + FileAccessToken { + inner: TokenInner::Desktop { path, display_name }, + } + }) + }, + ); + crate::future::deliver(&state, result); + }); + return fut.await; + } + tracing::warn!( + "File picker returned no result. \ + On ChromeOS Crostini or minimal Linux environments, \ + install zenity for file dialog support: sudo apt install zenity" + ); + } + Ok(None) + } + } +} + +/// Pick multiple files for reading. +/// +/// Applies the same extension-validation logic as [`pick_open_single`]: +/// invalid extensions (containing dots etc.) are dropped, and if none remain +/// the filter is omitted so all files stay visible. +/// +/// On Linux, if the XDG Desktop Portal is unavailable, falls back to zenity. +/// +/// # Errors +/// +/// Returns [`PickerError`] if the platform dialog could not be presented. +/// +/// # Examples +/// +/// ```no_run +/// # async fn example() -> Result<(), loki_file_access::PickerError> { +/// use loki_file_access::{FilePicker, PickOptions}; +/// let tokens = FilePicker::new() +/// .pick_files_to_open(PickOptions { +/// mime_types: vec!["image/png".into(), "image/jpeg".into()], +/// ..Default::default() +/// }) +/// .await?; +/// # Ok(()) } +/// ``` +pub(crate) async fn pick_open_multi( + options: PickOptions, +) -> Result, PickerError> { + check_backend_env()?; + + let filter_label = options + .filter_label + .as_deref() + .unwrap_or("Files") + .to_owned(); + let filter_exts: Vec = if !options.mime_types.is_empty() { + mime_types_to_extensions(&options.mime_types) + .into_iter() + .filter(|e| is_valid_extension(e)) + .collect() + } else { + vec![] + }; + + let mut dialog = rfd::AsyncFileDialog::new(); + if !filter_exts.is_empty() { + let ext_refs: Vec<&str> = filter_exts.iter().map(String::as_str).collect(); + dialog = dialog.add_filter(&filter_label, &ext_refs); + } + + match dialog.pick_files().await { + Some(list) => { + let tokens = list + .into_iter() + .map(|h| { + let path = h.path().to_path_buf(); + let display_name = file_name_from_path(&path); + FileAccessToken { + inner: TokenInner::Desktop { path, display_name }, + } + }) + .collect(); + Ok(tokens) + } + None => { + #[cfg(target_os = "linux")] + { + if zenity::is_zenity_available() { + tracing::debug!("XDG Desktop Portal unavailable; falling back to zenity"); + let (fut, state) = crate::future::new_pick_future(); + std::thread::spawn(move || { + let result = + zenity::pick_files("Open Files".into(), filter_label, filter_exts).map( + |paths| { + paths + .into_iter() + .map(|path| { + let display_name = file_name_from_path(&path); + FileAccessToken { + inner: TokenInner::Desktop { path, display_name }, + } + }) + .collect() + }, + ); + crate::future::deliver(&state, result); + }); + return fut.await; + } + tracing::warn!( + "File picker returned no result. \ + On ChromeOS Crostini or minimal Linux environments, \ + install zenity for file dialog support: sudo apt install zenity" + ); + } + Ok(vec![]) + } + } +} + +/// Pick a save location. +/// +/// Applies the same extension-validation logic as the open-picker functions: +/// the MIME type is converted to an extension, and `add_filter` is only called +/// if the resulting extension is valid for the native dialog. +/// +/// On Linux, if the XDG Desktop Portal is unavailable, falls back to zenity. +/// +/// # Errors +/// +/// Returns [`PickerError`] if the platform dialog could not be presented. +/// +/// # Examples +/// +/// ```no_run +/// # async fn example() -> Result<(), loki_file_access::PickerError> { +/// use loki_file_access::{FilePicker, SaveOptions}; +/// let token = FilePicker::new() +/// .pick_file_to_save(SaveOptions { +/// mime_type: Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document".into()), +/// suggested_name: Some("report.docx".into()), +/// }) +/// .await?; +/// # Ok(()) } +/// ``` +pub(crate) async fn pick_save( + options: SaveOptions, +) -> Result, PickerError> { + check_backend_env()?; + + let suggested_name = options.suggested_name; + let filter_label = "File".to_owned(); + let filter_exts: Vec = if let Some(ref mime) = options.mime_type { + mime_types_to_extensions(std::slice::from_ref(mime)) + .into_iter() + .filter(|e| is_valid_extension(e)) + .collect() + } else { + vec![] + }; + + let mut dialog = rfd::AsyncFileDialog::new(); + if let Some(ref name) = suggested_name { + dialog = dialog.set_file_name(name); + } + if !filter_exts.is_empty() { + let ext_refs: Vec<&str> = filter_exts.iter().map(String::as_str).collect(); + dialog = dialog.add_filter(&filter_label, &ext_refs); + } + + match dialog.save_file().await { + Some(h) => { + let path = h.path().to_path_buf(); + let display_name = file_name_from_path(&path); + Ok(Some(FileAccessToken { + inner: TokenInner::Desktop { path, display_name }, + })) + } + None => { + #[cfg(target_os = "linux")] + { + if zenity::is_zenity_available() { + tracing::debug!("XDG Desktop Portal unavailable; falling back to zenity"); + let (fut, state) = crate::future::new_pick_future(); + std::thread::spawn(move || { + let result = zenity::pick_save( + "Save File".into(), + suggested_name, + filter_label, + filter_exts, + ) + .map(|opt| { + opt.map(|path| { + let display_name = file_name_from_path(&path); + FileAccessToken { + inner: TokenInner::Desktop { path, display_name }, + } + }) + }); + crate::future::deliver(&state, result); + }); + return fut.await; + } + tracing::warn!( + "Save-file picker returned no result. \ + On ChromeOS Crostini or minimal Linux environments, \ + install zenity for file dialog support: sudo apt install zenity" + ); + } + Ok(None) + } + } +} + +/// Open a token for reading. +pub(crate) fn open_read(inner: &TokenInner) -> Result, AccessError> { + match inner { + TokenInner::Desktop { path, .. } => { + let file = std::fs::File::open(path)?; + Ok(Box::new(file)) + } + _ => Err(AccessError::Platform { + message: "non-desktop token on desktop platform".into(), + }), + } +} + +/// Open a token for writing. +pub(crate) fn open_write(inner: &TokenInner) -> Result, AccessError> { + match inner { + TokenInner::Desktop { path, .. } => { + let file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(path)?; + Ok(Box::new(file)) + } + _ => Err(AccessError::Platform { + message: "non-desktop token on desktop platform".into(), + }), + } +} + +/// Delete the file referenced by a token. +pub(crate) fn delete(inner: &TokenInner) -> Result<(), AccessError> { + match inner { + TokenInner::Desktop { path, .. } => { + std::fs::remove_file(path)?; + Ok(()) + } + _ => Err(AccessError::Platform { + message: "non-desktop token on desktop platform".into(), + }), + } +} + +/// Check permission status for a token. +pub(crate) fn check_permission(inner: &TokenInner) -> PermissionStatus { + match inner { + TokenInner::Desktop { path, .. } => { + if path.exists() { + PermissionStatus::Valid + } else { + PermissionStatus::Revoked + } + } + _ => PermissionStatus::Unknown, + } +} + +/// Extract a display name from a filesystem path. +fn file_name_from_path(path: &std::path::Path) -> String { + path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unnamed") + .to_owned() +} diff --git a/patches/loki-file-access/src/platform/desktop/zenity.rs b/patches/loki-file-access/src/platform/desktop/zenity.rs new file mode 100644 index 00000000..1e037b0a --- /dev/null +++ b/patches/loki-file-access/src/platform/desktop/zenity.rs @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! Zenity subprocess fallback for Linux environments where the XDG Desktop +//! Portal is unavailable (e.g. ChromeOS Crostini). +//! +//! Requires `zenity` to be installed: `sudo apt install zenity` + +use std::path::PathBuf; +use std::process::Command; + +use crate::error::PickerError; + +/// Returns `true` if zenity is installed and executable. +pub(super) fn is_zenity_available() -> bool { + Command::new("zenity") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Open a single-file picker via zenity. +/// +/// Returns `Ok(Some(path))` on selection, `Ok(None)` on cancel. +pub(super) fn pick_file( + title: String, + filter_label: String, + filter_exts: Vec, +) -> Result, PickerError> { + let mut cmd = build_open_cmd(&filter_label, &filter_exts); + cmd.arg("--title").arg(title); + run_single(cmd) +} + +/// Open a multi-file picker via zenity. +/// +/// Returns `Ok(vec)` of selected paths, empty on cancel. +pub(super) fn pick_files( + title: String, + filter_label: String, + filter_exts: Vec, +) -> Result, PickerError> { + let mut cmd = build_open_cmd(&filter_label, &filter_exts); + cmd.arg("--multiple"); + cmd.arg("--separator").arg("\n"); + cmd.arg("--title").arg(title); + run_multi(cmd) +} + +/// Open a save-file dialog via zenity. +/// +/// Returns `Ok(Some(path))` on selection, `Ok(None)` on cancel. +pub(super) fn pick_save( + title: String, + default_name: Option, + filter_label: String, + filter_exts: Vec, +) -> Result, PickerError> { + let mut cmd = build_open_cmd(&filter_label, &filter_exts); + cmd.arg("--save"); + cmd.arg("--confirm-overwrite"); + cmd.arg("--title").arg(title); + if let Some(name) = default_name { + cmd.arg("--filename").arg(name); + } + run_single(cmd) +} + +/// Build a base `zenity --file-selection` command with an optional file filter. +fn build_open_cmd(filter_label: &str, filter_exts: &[String]) -> Command { + let mut cmd = Command::new("zenity"); + cmd.arg("--file-selection"); + if !filter_exts.is_empty() { + // zenity --file-filter format: "Label | *.ext1 *.ext2" + let pattern = filter_exts + .iter() + .map(|e| format!("*.{}", e.trim_start_matches('.'))) + .collect::>() + .join(" "); + cmd.arg("--file-filter") + .arg(format!("{filter_label} | {pattern}")); + } + cmd +} + +/// Run zenity and parse a single path from stdout. +/// +/// Exit code 1 means the user cancelled — returned as `Ok(None)`, not an error. +fn run_single(mut cmd: Command) -> Result, PickerError> { + let output = cmd.output().map_err(|e| PickerError::Platform { + message: format!("failed to launch zenity: {e}"), + })?; + if !output.status.success() { + return Ok(None); + } + let path = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + if path.is_empty() { + Ok(None) + } else { + Ok(Some(PathBuf::from(path))) + } +} + +/// Run zenity and parse newline-separated paths from stdout. +/// +/// Exit code 1 means the user cancelled — returned as `Ok(vec![])`, not an error. +fn run_multi(mut cmd: Command) -> Result, PickerError> { + let output = cmd.output().map_err(|e| PickerError::Platform { + message: format!("failed to launch zenity: {e}"), + })?; + if !output.status.success() { + return Ok(vec![]); + } + let paths = String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|l| !l.is_empty()) + .map(PathBuf::from) + .collect(); + Ok(paths) +} diff --git a/patches/loki-file-access/src/platform/ios/bookmark.rs b/patches/loki-file-access/src/platform/ios/bookmark.rs new file mode 100644 index 00000000..02bb6fc7 --- /dev/null +++ b/patches/loki-file-access/src/platform/ios/bookmark.rs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! iOS security-scoped bookmark helpers. +//! +//! This submodule handles creating, resolving, and managing security-scoped +//! bookmarks for iOS file access. It also provides the [`ScopedBookmarkFile`] +//! RAII guard that calls `stopAccessingSecurityScopedResource()` on drop. + +use crate::error::{AccessError, PickerError}; +use crate::token::{FileAccessToken, PermissionStatus, ReadSeek, TokenInner, WriteSeek}; + +/// Create a [`FileAccessToken`] from an iOS file URL string. +/// +/// This function calls `startAccessingSecurityScopedResource()`, creates a +/// bookmark via `NSURL.bookmarkData(...)`, then calls +/// `stopAccessingSecurityScopedResource()`. +pub(super) fn token_from_url(url: &str) -> Result { + // In a full implementation: + // 1. Create NSURL from the string + // 2. Call startAccessingSecurityScopedResource() + // 3. Create bookmark data via bookmarkData(options:...) + // 4. Call stopAccessingSecurityScopedResource() + // 5. Extract the display name from the URL's lastPathComponent + let display_name = url.rsplit('/').next().unwrap_or("unnamed").to_owned(); + + Ok(FileAccessToken { + inner: TokenInner::Ios { + bookmark: url.as_bytes().to_vec(), + display_name, + mime_type: None, + }, + }) +} + +/// Open a security-scoped bookmark for reading. +/// +/// Resolves the bookmark to a URL, starts accessing the security-scoped +/// resource, and opens the file. Returns a [`ScopedBookmarkFile`] that +/// stops access on drop. +pub(super) fn open_read_bookmark(bookmark: &[u8]) -> Result, AccessError> { + // In a full implementation: + // 1. Resolve bookmark via NSURL.init(resolvingBookmarkData:...) + // 2. Call startAccessingSecurityScopedResource() + // 3. Open the file path for reading + // 4. Wrap in ScopedBookmarkFile + let _url = String::from_utf8(bookmark.to_vec()).map_err(|_| AccessError::Platform { + message: "invalid bookmark data".into(), + })?; + + Err(AccessError::Platform { + message: "iOS bookmark resolution requires Objective-C runtime".into(), + }) +} + +/// Open a security-scoped bookmark for writing. +pub(super) fn open_write_bookmark(bookmark: &[u8]) -> Result, AccessError> { + let _url = String::from_utf8(bookmark.to_vec()).map_err(|_| AccessError::Platform { + message: "invalid bookmark data".into(), + })?; + + Err(AccessError::Platform { + message: "iOS bookmark resolution requires Objective-C runtime".into(), + }) +} + +/// Check whether a bookmark can still be resolved. +pub(super) fn check_bookmark(bookmark: &[u8]) -> PermissionStatus { + // In a full implementation this would attempt to resolve the bookmark + // and check whether it is stale. + if bookmark.is_empty() { + PermissionStatus::Revoked + } else { + PermissionStatus::Unknown + } +} + +/// RAII guard that calls `stopAccessingSecurityScopedResource()` on drop. +/// +/// Wraps a `std::fs::File` and the URL handle so that the security scope +/// is released when the file is closed. +#[allow(dead_code)] // Used in full iOS implementation, placeholder here +pub(super) struct ScopedBookmarkFile { + /// The underlying file handle. + file: std::fs::File, + /// The URL string, retained for `stopAccessingSecurityScopedResource`. + url: String, +} + +impl std::io::Read for ScopedBookmarkFile { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.file.read(buf) + } +} + +impl std::io::Write for ScopedBookmarkFile { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.file.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.file.flush() + } +} + +impl std::io::Seek for ScopedBookmarkFile { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + self.file.seek(pos) + } +} + +impl Drop for ScopedBookmarkFile { + fn drop(&mut self) { + // In a full implementation: + // Resolve self.url back to NSURL and call + // stopAccessingSecurityScopedResource(). + } +} diff --git a/patches/loki-file-access/src/platform/ios/mod.rs b/patches/loki-file-access/src/platform/ios/mod.rs new file mode 100644 index 00000000..825cdba7 --- /dev/null +++ b/patches/loki-file-access/src/platform/ios/mod.rs @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! iOS file-picker implementation using `UIDocumentPickerViewController`. +//! +//! This module presents the system document picker via UIKit and manages +//! security-scoped bookmarks for persistent file access. Each selected URL +//! is converted to a bookmark that can be stored and resolved across app +//! restarts, as required by Apple's security-scoped resource model. +//! +//! # Security-Scoped Resources +//! +//! iOS requires calling `startAccessingSecurityScopedResource()` before +//! opening a bookmarked URL and `stopAccessingSecurityScopedResource()` when +//! done. The [`ScopedBookmarkFile`] RAII guard handles this automatically. + +mod bookmark; + +use std::sync::Arc; + +use crate::api::{PickOptions, SaveOptions}; +use crate::error::{AccessError, PickerError}; +use crate::future::{deliver, new_pick_future}; +use crate::token::{FileAccessToken, PermissionStatus, ReadSeek, TokenInner, WriteSeek}; + +/// Pick a single file for reading. +pub(crate) async fn pick_open_single( + options: PickOptions, +) -> Result, PickerError> { + let urls = present_picker(&options, false).await?; + match urls.into_iter().next() { + None => Ok(None), + Some(url) => { + let token = bookmark::token_from_url(&url)?; + Ok(Some(token)) + } + } +} + +/// Pick multiple files for reading. +pub(crate) async fn pick_open_multi( + options: PickOptions, +) -> Result, PickerError> { + let urls = present_picker(&options, true).await?; + urls.iter() + .map(|url| bookmark::token_from_url(url)) + .collect() +} + +/// Pick a save location. +pub(crate) async fn pick_save( + options: SaveOptions, +) -> Result, PickerError> { + let url = present_save_picker(&options).await?; + match url { + None => Ok(None), + Some(u) => { + let token = bookmark::token_from_url(&u)?; + Ok(Some(token)) + } + } +} + +/// Open a bookmarked file for reading. +pub(crate) fn open_read(inner: &TokenInner) -> Result, AccessError> { + match inner { + TokenInner::Ios { bookmark, .. } => bookmark::open_read_bookmark(bookmark), + _ => Err(AccessError::Platform { + message: "non-iOS token on iOS platform".into(), + }), + } +} + +/// Open a bookmarked file for writing. +pub(crate) fn open_write(inner: &TokenInner) -> Result, AccessError> { + match inner { + TokenInner::Ios { bookmark, .. } => bookmark::open_write_bookmark(bookmark), + _ => Err(AccessError::Platform { + message: "non-iOS token on iOS platform".into(), + }), + } +} + +/// Delete the file referenced by a token. +/// +/// Deleting a security-scoped bookmarked file is not currently implemented. +/// Return an explicit unsupported error rather than silently succeeding. +pub(crate) fn delete(_inner: &TokenInner) -> Result<(), AccessError> { + Err(AccessError::Unsupported { + operation: "delete (iOS bookmarked-file deletion not implemented)".into(), + }) +} + +/// Check whether a bookmark is still resolvable. +pub(crate) fn check_permission(inner: &TokenInner) -> PermissionStatus { + match inner { + TokenInner::Ios { bookmark, .. } => bookmark::check_bookmark(bookmark), + _ => PermissionStatus::Unknown, + } +} + +/// Present the document picker for opening files. +async fn present_picker( + _options: &PickOptions, + _allow_multiple: bool, +) -> Result, PickerError> { + let (future, state) = new_pick_future::>(); + let state_clone = Arc::clone(&state); + + // The delegate callback will call `deliver` with the selected URLs. + // In a full implementation this would use `objc2::declare_class!` to + // create a `UIDocumentPickerDelegate` and present the picker on the + // root view controller. The delegate's + // `documentPicker:didPickDocumentsAtURLs:` method would extract the + // URL strings and call `deliver(&state_clone, urls)`. + // + // Placeholder: deliver an empty result to avoid hanging. + deliver(&state_clone, vec![]); + + Ok(future.await) +} + +/// Present the document picker for saving a file. +async fn present_save_picker(_options: &SaveOptions) -> Result, PickerError> { + let (future, state) = new_pick_future::>(); + let state_clone = Arc::clone(&state); + + // Same placeholder approach as `present_picker`. + deliver(&state_clone, None); + + Ok(future.await) +} diff --git a/patches/loki-file-access/src/platform/mod.rs b/patches/loki-file-access/src/platform/mod.rs new file mode 100644 index 00000000..050a44db --- /dev/null +++ b/patches/loki-file-access/src/platform/mod.rs @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! Platform dispatch layer. +//! +//! This module selects the appropriate platform implementation at compile time +//! using `cfg` attributes and re-exports the four operations that the public +//! API delegates to: +//! +//! - [`pick_open_single`] — pick one file for reading +//! - [`pick_open_multi`] — pick multiple files for reading +//! - [`pick_save`] — pick a save location +//! - [`open_read`] / [`open_write`] — open a token for I/O +//! - [`check_permission`] — query whether a token is still valid + +#[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] +mod desktop; +#[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] +pub(crate) use desktop::{ + check_permission, delete, open_read, open_write, pick_open_multi, pick_open_single, pick_save, +}; + +#[cfg(target_os = "android")] +mod android; +#[cfg(target_os = "android")] +pub(crate) use android::{ + check_permission, delete, open_read, open_write, pick_open_multi, pick_open_single, pick_save, +}; +#[cfg(target_os = "android")] +pub use android::{ + init_android, install_ime_listener, on_activity_result, query_insets_dp, + query_window_insets_dp, set_ime_visibility_listener, +}; + +#[cfg(target_os = "ios")] +mod ios; +#[cfg(target_os = "ios")] +pub(crate) use ios::{ + check_permission, delete, open_read, open_write, pick_open_multi, pick_open_single, pick_save, +}; + +#[cfg(target_arch = "wasm32")] +mod wasm; +#[cfg(target_arch = "wasm32")] +pub(crate) use wasm::{ + check_permission, delete, open_read, open_write, pick_open_multi, pick_open_single, pick_save, +}; + +// Ensure a compile error on unsupported platforms rather than silent failure. +#[cfg(not(any( + target_os = "android", + target_os = "ios", + target_arch = "wasm32", + target_os = "windows", + target_os = "macos", + target_os = "linux", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly", +)))] +compile_error!( + "loki-file-access: unsupported target platform. \ + Supported: Windows, macOS, Linux, BSD, Android, iOS, WASM." +); diff --git a/patches/loki-file-access/src/platform/wasm.rs b/patches/loki-file-access/src/platform/wasm.rs new file mode 100644 index 00000000..38f00386 --- /dev/null +++ b/patches/loki-file-access/src/platform/wasm.rs @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! WASM file-picker implementation using ``. +//! +//! This module creates a hidden `` element in the DOM, +//! configures it with MIME-type filters and multi-select options, and listens +//! for the `change` event to capture the user's selection. Selected files are +//! read entirely into memory as `Vec`. +//! +//! # Save behaviour +//! +//! WASM has no traditional save dialog. [`pick_save`] triggers a browser +//! download via a Blob URL. The returned [`crate::FileAccessToken`] wraps an +//! in-memory buffer that the caller can write to before the download is +//! initiated. This is a fundamental platform limitation documented in the +//! public [`crate::FilePicker::pick_file_to_save`] doc comment. +//! +//! # Persistence +//! +//! WASM tokens hold file data in memory and do not survive page reloads. +//! Serialising a WASM token preserves the data, but restoring it on a new +//! page load gives back the original bytes — there is no way to re-acquire +//! filesystem access. + +use crate::api::{PickOptions, SaveOptions}; +use crate::error::{AccessError, PickerError}; +use crate::token::{FileAccessToken, PermissionStatus, ReadSeek, TokenInner, WriteSeek}; + +/// Pick a single file for reading. +pub(crate) async fn pick_open_single( + options: PickOptions, +) -> Result, PickerError> { + let tokens = pick_files(options, false).await?; + Ok(tokens.into_iter().next()) +} + +/// Pick multiple files for reading. +pub(crate) async fn pick_open_multi( + options: PickOptions, +) -> Result, PickerError> { + pick_files(options, true).await +} + +/// Trigger a browser download. +/// +/// On WASM there is no save dialog — this creates an empty in-memory buffer +/// token. The caller should write data into it, then call a platform-specific +/// download trigger (e.g. creating a Blob URL and clicking an anchor element). +pub(crate) async fn pick_save( + options: SaveOptions, +) -> Result, PickerError> { + let name = options.suggested_name.unwrap_or_else(|| "download".into()); + Ok(Some(FileAccessToken { + inner: TokenInner::Wasm { + data: Vec::new(), + name, + mime_type: options.mime_type, + }, + })) +} + +/// Open a WASM token for reading (reads from the in-memory buffer). +pub(crate) fn open_read(inner: &TokenInner) -> Result, AccessError> { + match inner { + TokenInner::Wasm { data, .. } => Ok(Box::new(std::io::Cursor::new(data.clone()))), + _ => Err(AccessError::Platform { + message: "non-WASM token on WASM platform".into(), + }), + } +} + +/// Open a WASM token for writing (writes to an in-memory buffer). +pub(crate) fn open_write(inner: &TokenInner) -> Result, AccessError> { + match inner { + TokenInner::Wasm { data, .. } => Ok(Box::new(std::io::Cursor::new(data.clone()))), + _ => Err(AccessError::Platform { + message: "non-WASM token on WASM platform".into(), + }), + } +} + +/// Delete the file referenced by a token. +/// +/// WASM tokens wrap an in-memory buffer with no backing filesystem, so there +/// is nothing to delete. Return an explicit unsupported error. +pub(crate) fn delete(_inner: &TokenInner) -> Result<(), AccessError> { + Err(AccessError::Unsupported { + operation: "delete (WASM in-memory tokens have no backing file)".into(), + }) +} + +/// WASM tokens are always valid while the page is loaded. +pub(crate) fn check_permission(inner: &TokenInner) -> PermissionStatus { + match inner { + TokenInner::Wasm { .. } => PermissionStatus::Valid, + _ => PermissionStatus::Unknown, + } +} + +/// Create and trigger a hidden `` element. +async fn pick_files( + options: PickOptions, + multiple: bool, +) -> Result, PickerError> { + use wasm_bindgen::JsCast as _; + use wasm_bindgen_futures::JsFuture; + + let window = web_sys::window().ok_or_else(|| PickerError::Platform { + message: "no global window object".into(), + })?; + let document = window.document().ok_or_else(|| PickerError::Platform { + message: "no document object".into(), + })?; + + let input: web_sys::HtmlInputElement = document + .create_element("input") + .map_err(|e| PickerError::Platform { + message: format!("create_element failed: {e:?}"), + })? + .dyn_into() + .map_err(|_| PickerError::Platform { + message: "element is not HtmlInputElement".into(), + })?; + + input.set_type("file"); + + if !options.mime_types.is_empty() { + input.set_accept(&options.mime_types.join(",")); + } + + if multiple { + input.set_multiple(true); + } + + // Create a promise that resolves when the change event fires. + let promise = js_sys::Promise::new(&mut |resolve, _reject| { + let resolve_clone = resolve.clone(); + let cb = wasm_bindgen::closure::Closure::once_into_js(move || { + let _ = resolve_clone.call0(&wasm_bindgen::JsValue::NULL); + }); + let _ = input.add_event_listener_with_callback("change", cb.as_ref().unchecked_ref()); + }); + + // Trigger the file dialog. + input.click(); + + // Wait for the user to select files. + JsFuture::from(promise) + .await + .map_err(|e| PickerError::Platform { + message: format!("file input promise rejected: {e:?}"), + })?; + + // Read selected files. + let file_list = match input.files() { + Some(fl) => fl, + None => return Ok(vec![]), + }; + + let mut tokens = Vec::new(); + for i in 0..file_list.length() { + let file = match file_list.get(i) { + Some(f) => f, + None => continue, + }; + let name = file.name(); + let mime = file.type_(); + let mime_type = if mime.is_empty() { None } else { Some(mime) }; + + let array_buf_promise = file.array_buffer(); + let array_buf = + JsFuture::from(array_buf_promise) + .await + .map_err(|e| PickerError::Platform { + message: format!("arrayBuffer() failed: {e:?}"), + })?; + let uint8 = js_sys::Uint8Array::new(&array_buf); + let data = uint8.to_vec(); + + tokens.push(FileAccessToken { + inner: TokenInner::Wasm { + data, + name, + mime_type, + }, + }); + } + + Ok(tokens) +} diff --git a/patches/loki-file-access/src/token.rs b/patches/loki-file-access/src/token.rs new file mode 100644 index 00000000..b048a909 --- /dev/null +++ b/patches/loki-file-access/src/token.rs @@ -0,0 +1,412 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! Capability token for accessing user-selected files. +//! +//! [`FileAccessToken`] is the central type returned by every picker operation. +//! It encapsulates all platform-specific state needed to re-open a file, +//! including Android URIs, iOS security-scoped bookmarks, desktop paths, and +//! in-memory WASM data. +//! +//! Tokens are serializable to a URL-safe base64-encoded JSON string via +//! [`FileAccessToken::serialize`] and [`FileAccessToken::deserialize`], making +//! them suitable for persisting in a recent-files list or application database. + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use std::path::PathBuf; + +use crate::error::{AccessError, TokenParseError}; + +/// Status of the permission grant associated with a [`FileAccessToken`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum PermissionStatus { + /// The token's permission is still valid and the file can be opened. + Valid, + /// The permission has been revoked by the user or the operating system. + Revoked, + /// The permission status cannot be determined on this platform. + Unknown, +} + +/// Internal representation of platform-specific token data. +/// +/// This enum is serialized to JSON and then base64-encoded for storage. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) enum TokenInner { + /// Desktop file identified by filesystem path. + Desktop { + /// Absolute path to the file. + path: PathBuf, + /// User-visible file name. + display_name: String, + }, + /// Android file identified by a content URI. + Android { + /// Content URI string (e.g. `content://...`). + uri: String, + /// User-visible file name from the document provider. + display_name: String, + /// MIME type reported by the document provider. + mime_type: Option, + }, + /// iOS file identified by a security-scoped bookmark. + Ios { + /// Opaque bookmark data created by `NSURL.bookmarkData(...)`. + bookmark: Vec, + /// User-visible file name. + display_name: String, + /// MIME type (often inferred from the file extension). + mime_type: Option, + }, + /// WASM file held entirely in memory. + Wasm { + /// Complete file contents. + data: Vec, + /// Original file name from the `` element. + name: String, + /// MIME type reported by the browser. + mime_type: Option, + }, +} + +/// A serializable capability token representing access to a user-selected file. +/// +/// Obtain instances from [`crate::FilePicker`] methods. Serialize via +/// [`serialize`](Self::serialize) for storage; deserialize to reopen files +/// across app restarts. +#[derive(Debug, Clone)] +pub struct FileAccessToken { + pub(crate) inner: TokenInner, +} + +impl FileAccessToken { + /// Open the file for reading. Returns `Read + Seek`. + /// + /// # Errors + /// + /// Returns [`AccessError`] if permission is revoked or the file cannot be opened. + #[must_use = "this returns a Result that may contain an error"] + pub fn open_read(&self) -> Result, AccessError> { + crate::platform::open_read(&self.inner) + } + + /// Open the file for writing. Returns `Write + Seek`. + /// + /// # Errors + /// + /// Returns [`AccessError`] if permission is revoked or the file cannot be opened. + #[must_use = "this returns a Result that may contain an error"] + pub fn open_write(&self) -> Result, AccessError> { + crate::platform::open_write(&self.inner) + } + + /// Delete the underlying file. + /// + /// On desktop this removes the file from the filesystem. On platforms + /// where deletion is not yet implemented (Android, iOS, WASM) this returns + /// [`AccessError::Unsupported`] rather than silently succeeding. + /// + /// # Errors + /// + /// Returns [`AccessError`] if the file cannot be deleted or deletion is + /// not supported on the current platform. + #[must_use = "this returns a Result that may contain an error"] + pub fn delete(&self) -> Result<(), AccessError> { + crate::platform::delete(&self.inner) + } + + /// Copy the full contents of this file into `dest`. + /// + /// Reads all bytes from `self` via [`open_read`](Self::open_read) and writes + /// them to `dest` via [`open_write`](Self::open_write). This works across + /// every platform because it relies only on the token I/O primitives, not + /// on filesystem paths. + /// + /// # Errors + /// + /// Returns [`AccessError`] if either token cannot be opened or an I/O error + /// occurs while transferring bytes. + #[must_use = "this returns a Result that may contain an error"] + pub fn copy_bytes_to(&self, dest: &FileAccessToken) -> Result<(), AccessError> { + use std::io::{Read as _, Write as _}; + + let mut reader = self.open_read()?; + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes)?; + + let mut writer = dest.open_write()?; + writer.write_all(&bytes)?; + writer.flush()?; + Ok(()) + } + + /// Returns the user-visible display name of the file (typically the filename). + #[must_use] + pub fn display_name(&self) -> &str { + match &self.inner { + TokenInner::Desktop { display_name, .. } + | TokenInner::Android { display_name, .. } + | TokenInner::Ios { display_name, .. } => display_name, + TokenInner::Wasm { name, .. } => name, + } + } + + /// Returns the MIME type of the file, if known. Desktop returns `None`. + #[must_use] + pub fn mime_type(&self) -> Option<&str> { + match &self.inner { + TokenInner::Desktop { .. } => None, + TokenInner::Android { mime_type, .. } + | TokenInner::Ios { mime_type, .. } + | TokenInner::Wasm { mime_type, .. } => mime_type.as_deref(), + } + } + + /// Check whether the permission grant for this file is still valid. + #[must_use] + pub fn check_permission(&self) -> PermissionStatus { + crate::platform::check_permission(&self.inner) + } + + /// Serialize the token to a URL-safe base64-encoded string for storage. + #[must_use] + pub fn serialize(&self) -> String { + // Serialization of the inner enum to JSON should not fail for our + // data types (no maps with non-string keys, no infinite floats). + // However, we handle the error path gracefully by returning an + // empty-object JSON fallback, which will fail on deserialization + // with a clear error rather than panicking here. + let json = match serde_json::to_string(&self.inner) { + Ok(j) => j, + Err(_) => return URL_SAFE_NO_PAD.encode(b"{}"), + }; + URL_SAFE_NO_PAD.encode(json.as_bytes()) + } + + /// Deserialize a token from a string previously returned by [`serialize`](Self::serialize). + /// + /// # Errors + /// + /// Returns [`TokenParseError`] if the string is malformed. + pub fn deserialize(s: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(s) + .map_err(|e| TokenParseError::InvalidBase64 { + message: e.to_string(), + })?; + + let json = String::from_utf8(bytes).map_err(|e| TokenParseError::InvalidBase64 { + message: e.to_string(), + })?; + + let inner: TokenInner = + serde_json::from_str(&json).map_err(|e| TokenParseError::InvalidJson { + message: e.to_string(), + })?; + + Ok(Self { inner }) + } +} + +/// Trait object combining [`std::io::Read`] and [`std::io::Seek`]. +pub trait ReadSeek: std::io::Read + std::io::Seek + Send {} +impl ReadSeek for T {} + +/// Trait object combining [`std::io::Write`] and [`std::io::Seek`]. +pub trait WriteSeek: std::io::Write + std::io::Seek + Send {} +impl WriteSeek for T {} + +impl std::fmt::Display for FileAccessToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.serialize()) + } +} + +impl std::str::FromStr for FileAccessToken { + type Err = TokenParseError; + + fn from_str(s: &str) -> Result { + Self::deserialize(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_desktop_token() { + let token = FileAccessToken { + inner: TokenInner::Desktop { + path: PathBuf::from("/tmp/test.txt"), + display_name: "test.txt".into(), + }, + }; + let serialized = token.serialize(); + let restored = FileAccessToken::deserialize(&serialized).unwrap(); + assert_eq!(restored.display_name(), "test.txt"); + assert!(restored.mime_type().is_none()); + } + + #[test] + fn round_trip_android_token() { + let token = FileAccessToken { + inner: TokenInner::Android { + uri: "content://com.example/doc/1".into(), + display_name: "photo.jpg".into(), + mime_type: Some("image/jpeg".into()), + }, + }; + let serialized = token.serialize(); + let restored = FileAccessToken::deserialize(&serialized).unwrap(); + assert_eq!(restored.display_name(), "photo.jpg"); + assert_eq!(restored.mime_type(), Some("image/jpeg")); + } + + #[test] + fn round_trip_ios_token() { + let token = FileAccessToken { + inner: TokenInner::Ios { + bookmark: vec![0xDE, 0xAD, 0xBE, 0xEF], + display_name: "notes.pdf".into(), + mime_type: Some("application/pdf".into()), + }, + }; + let serialized = token.serialize(); + let restored = FileAccessToken::deserialize(&serialized).unwrap(); + assert_eq!(restored.display_name(), "notes.pdf"); + assert_eq!(restored.mime_type(), Some("application/pdf")); + } + + #[test] + fn round_trip_wasm_token() { + let token = FileAccessToken { + inner: TokenInner::Wasm { + data: vec![1, 2, 3, 4, 5], + name: "data.bin".into(), + mime_type: Some("application/octet-stream".into()), + }, + }; + let serialized = token.serialize(); + let restored = FileAccessToken::deserialize(&serialized).unwrap(); + assert_eq!(restored.display_name(), "data.bin"); + assert_eq!(restored.mime_type(), Some("application/octet-stream")); + } + + #[test] + fn deserialize_invalid_base64_returns_error() { + let result = FileAccessToken::deserialize("not!valid!base64!!!"); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + TokenParseError::InvalidBase64 { .. } + )); + } + + #[test] + fn deserialize_invalid_json_returns_error() { + let bad = URL_SAFE_NO_PAD.encode(b"not json"); + let result = FileAccessToken::deserialize(&bad); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + TokenParseError::InvalidJson { .. } + )); + } + + // The following tests exercise the platform-routed operations. They only + // run on desktop targets, where the desktop backend is compiled in. + #[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] + fn desktop_token_for(path: &std::path::Path) -> FileAccessToken { + FileAccessToken { + inner: TokenInner::Desktop { + path: path.to_path_buf(), + display_name: path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unnamed") + .to_owned(), + }, + } + } + + #[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] + #[test] + fn desktop_delete_removes_file() { + let dir = std::env::temp_dir(); + let path = dir.join(format!("loki_delete_test_{}.txt", std::process::id())); + std::fs::write(&path, b"hello").expect("write temp file"); + assert!(path.exists()); + + let token = desktop_token_for(&path); + token.delete().expect("delete should succeed"); + assert!(!path.exists(), "file must be gone after delete"); + } + + #[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] + #[test] + fn desktop_delete_missing_file_is_io_error() { + let dir = std::env::temp_dir(); + let path = dir.join(format!("loki_delete_missing_{}.txt", std::process::id())); + let _ = std::fs::remove_file(&path); + + let token = desktop_token_for(&path); + let err = token + .delete() + .expect_err("deleting a missing file must error"); + assert!(matches!(err, AccessError::Io { .. })); + } + + #[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] + #[test] + fn desktop_copy_bytes_to_duplicates_contents() { + let dir = std::env::temp_dir(); + let pid = std::process::id(); + let src_path = dir.join(format!("loki_copy_src_{pid}.txt")); + let dest_path = dir.join(format!("loki_copy_dest_{pid}.txt")); + std::fs::write(&src_path, b"copy me").expect("write source"); + let _ = std::fs::remove_file(&dest_path); + + let src = desktop_token_for(&src_path); + let dest = desktop_token_for(&dest_path); + src.copy_bytes_to(&dest).expect("copy should succeed"); + + let copied = std::fs::read(&dest_path).expect("read dest"); + assert_eq!(copied, b"copy me"); + + let _ = std::fs::remove_file(&src_path); + let _ = std::fs::remove_file(&dest_path); + } + + // On non-desktop targets the unsupported-platform path is taken: deleting + // any token must surface AccessError::Unsupported rather than succeed. + #[cfg(any(target_os = "android", target_os = "ios", target_arch = "wasm32"))] + #[test] + fn non_desktop_delete_is_unsupported() { + let token = FileAccessToken { + inner: TokenInner::Desktop { + path: PathBuf::from("/tmp/whatever.txt"), + display_name: "whatever.txt".into(), + }, + }; + let err = token + .delete() + .expect_err("delete must be unsupported off-desktop"); + assert!(matches!(err, AccessError::Unsupported { .. })); + } + + #[test] + fn display_and_from_str_round_trip() { + let token = FileAccessToken { + inner: TokenInner::Desktop { + path: PathBuf::from("/tmp/x.txt"), + display_name: "x.txt".into(), + }, + }; + let s = token.to_string(); + let restored: FileAccessToken = s.parse().unwrap(); + assert_eq!(restored.display_name(), "x.txt"); + } +} diff --git a/patches/loki-file-access/tests/desktop.rs b/patches/loki-file-access/tests/desktop.rs new file mode 100644 index 00000000..40974ce2 --- /dev/null +++ b/patches/loki-file-access/tests/desktop.rs @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! Integration tests for desktop file access. +//! +//! These tests are cfg-gated to run only on desktop platforms (not Android, +//! iOS, or WASM) where direct filesystem path access is available. + +#![cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] + +use loki_file_access::{FileAccessToken, PermissionStatus}; +use std::io::{Read, Seek, Write}; + +#[test] +fn open_read_from_temp_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.txt"); + std::fs::write(&path, "hello world").unwrap(); + + let token = + FileAccessToken::deserialize(&create_desktop_token(&path, "test.txt").serialize()).unwrap(); + + let mut reader = token.open_read().unwrap(); + let mut contents = String::new(); + reader.read_to_string(&mut contents).unwrap(); + assert_eq!(contents, "hello world"); +} + +#[test] +fn open_write_to_temp_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("output.txt"); + std::fs::write(&path, "").unwrap(); + + let token = create_desktop_token(&path, "output.txt"); + + { + let mut writer = token.open_write().unwrap(); + writer.write_all(b"written data").unwrap(); + } + + let contents = std::fs::read_to_string(&path).unwrap(); + assert_eq!(contents, "written data"); +} + +#[test] +fn check_permission_returns_valid_for_existing_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("exists.txt"); + std::fs::write(&path, "data").unwrap(); + + let token = create_desktop_token(&path, "exists.txt"); + assert_eq!(token.check_permission(), PermissionStatus::Valid); +} + +#[test] +fn check_permission_returns_revoked_for_missing_file() { + let token = create_desktop_token( + std::path::Path::new("/tmp/nonexistent_loki_test_file_12345.txt"), + "nonexistent.txt", + ); + assert_eq!(token.check_permission(), PermissionStatus::Revoked); +} + +#[test] +fn token_round_trip_serialization() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("roundtrip.txt"); + std::fs::write(&path, "round-trip content").unwrap(); + + let original = create_desktop_token(&path, "roundtrip.txt"); + let serialized = original.serialize(); + let restored = FileAccessToken::deserialize(&serialized).unwrap(); + + assert_eq!(restored.display_name(), "roundtrip.txt"); + + let mut reader = restored.open_read().unwrap(); + let mut contents = String::new(); + reader.read_to_string(&mut contents).unwrap(); + assert_eq!(contents, "round-trip content"); +} + +#[test] +fn display_name_returns_filename() { + let token = create_desktop_token(std::path::Path::new("/tmp/example.csv"), "example.csv"); + assert_eq!(token.display_name(), "example.csv"); +} + +#[test] +fn mime_type_is_none_for_desktop() { + let token = create_desktop_token(std::path::Path::new("/tmp/file.txt"), "file.txt"); + assert!(token.mime_type().is_none()); +} + +#[test] +fn open_read_seek_rewind() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("seek.txt"); + std::fs::write(&path, "abcdef").unwrap(); + + let token = create_desktop_token(&path, "seek.txt"); + let mut reader = token.open_read().unwrap(); + + let mut buf = [0u8; 3]; + reader.read_exact(&mut buf).unwrap(); + assert_eq!(&buf, b"abc"); + + reader.seek(std::io::SeekFrom::Start(0)).unwrap(); + reader.read_exact(&mut buf).unwrap(); + assert_eq!(&buf, b"abc"); +} + +/// Helper to construct a desktop `FileAccessToken` directly. +/// +/// In production code, tokens are obtained from `FilePicker` methods. +/// For testing we construct them via the token's internal serialization format. +fn create_desktop_token(path: &std::path::Path, name: &str) -> FileAccessToken { + use base64::Engine as _; + + // Build the JSON payload that TokenInner::Desktop serializes to, + // then encode it to match FileAccessToken::serialize(). + let json = serde_json::json!({ + "Desktop": { + "path": path.to_str().unwrap(), + "display_name": name + } + }); + let encoded = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.to_string().as_bytes()); + FileAccessToken::deserialize(&encoded).unwrap() +} From c35afe91cb85ece4a887dbe086a78a7663f41178 Mon Sep 17 00:00:00 2001 From: Kevin Carlson Date: Mon, 13 Jul 2026 06:35:53 -0400 Subject: [PATCH 2/3] Mobile selection: drag the teardrop handles to adjust the selection A touch that starts within 32 CSS px of a selection handle's teardrop enters the new TouchPhase::HandleDrag: the opposite endpoint is normalised into the anchor at grab time, every touchmove hit-tests the finger position into the selection focus (handles may cross), and touchend keeps the adjusted selection instead of collapsing to a tap-caret. - editing/selection_handles.rs: maps a selection endpoint back to its window-relative grab point (exact inverse of hit_test_document's origin/zoom/page-slot transform, teardrop offset mirroring the painter's geometry) and decides which handle a touch grabs. Unit-tested by round-tripping through the real hit test, opposite-endpoint selection, and zoom scaling. - Touch handlers moved from editor_pointer.rs to editor_pointer_touch.rs (300-line ceiling), gaining make_touchstart_handler with the grab check and a shared paginated_hit helper; the inline mousedown/mouseup closures in editor_canvas.rs became editor_pointer.rs factories to keep the canvas file at its ratchet baseline. Works on any touch input in paginated mode (handles paint on Android; the grab math is platform-neutral). Needs on-device verification alongside the handle painting from the previous commit. Co-Authored-By: Claude Fable 5 --- loki-text/src/editing/mod.rs | 1 + loki-text/src/editing/selection_handles.rs | 119 ++++++++ .../src/editing/selection_handles_tests.rs | 171 +++++++++++ loki-text/src/editing/touch.rs | 7 +- loki-text/src/routes/editor/editor_canvas.rs | 46 ++- loki-text/src/routes/editor/editor_pointer.rs | 191 ++---------- .../src/routes/editor/editor_pointer_touch.rs | 273 ++++++++++++++++++ loki-text/src/routes/editor/mod.rs | 1 + 8 files changed, 613 insertions(+), 196 deletions(-) create mode 100644 loki-text/src/editing/selection_handles.rs create mode 100644 loki-text/src/editing/selection_handles_tests.rs create mode 100644 loki-text/src/routes/editor/editor_pointer_touch.rs diff --git a/loki-text/src/editing/mod.rs b/loki-text/src/editing/mod.rs index 6e34f284..6a279f57 100644 --- a/loki-text/src/editing/mod.rs +++ b/loki-text/src/editing/mod.rs @@ -18,6 +18,7 @@ pub mod reflow_nav; pub mod relayout; pub mod saved_state; pub mod selected_object; +pub mod selection_handles; pub mod spell; pub mod state; pub mod touch; diff --git a/loki-text/src/editing/selection_handles.rs b/loki-text/src/editing/selection_handles.rs new file mode 100644 index 00000000..0f4caa71 --- /dev/null +++ b/loki-text/src/editing/selection_handles.rs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Selection-handle grab geometry (mobile drag-to-adjust). +//! +//! The renderer paints teardrop handles below the selection edges (paginated +//! mode, Android). To let the user drag them, a touch start must be matched +//! against the handles' on-screen positions — this module maps a selection +//! endpoint back to its window-relative grab point (the inverse of +//! [`hit_test_document`]'s transform) and decides whether a touch grabs a +//! handle. +//! +//! [`hit_test_document`]: super::hit_test::hit_test_document + +use loki_layout::PaginatedLayout; + +use super::cursor::DocumentPosition; + +/// Radius (CSS px) around a handle's grab point within which a touch starts a +/// handle drag. Generous — a fingertip target, not a stylus one. +pub const HANDLE_GRAB_RADIUS_PX: f32 = 32.0; + +/// Vertical distance (layout points) from the selection edge's line bottom to +/// the teardrop circle centre. Mirrors the painter's geometry in +/// `loki-vello/src/scene_cursor.rs` (`HANDLE_STEM_HEIGHT` 24 + `HANDLE_CIRCLE_RADIUS` 8). +const HANDLE_GRAB_OFFSET_PT: f32 = 32.0; + +/// CSS pixels per layout point (before zoom). +const PT_TO_PX: f32 = 96.0 / 72.0; + +/// The window-relative (client CSS px) grab point of the selection handle for +/// the endpoint `pos`: the teardrop circle centre below that caret's line. +/// +/// `canvas_origin`, `scroll_offset`, `page_height_px`, `page_gap_px` and +/// `zoom` are the same values the forward hit-test uses, so grab points land +/// exactly where the handles are painted. Returns `None` when the position is +/// not on the layout (stale) or sits in a table cell (no handles painted +/// there). +#[allow(clippy::too_many_arguments)] +pub fn handle_grab_point( + layout: &PaginatedLayout, + pos: &DocumentPosition, + canvas_origin: (f32, f32), + scroll_offset: f32, + page_height_px: f32, + page_gap_px: f32, + zoom: f32, +) -> Option<(f32, f32)> { + if !pos.path.is_empty() { + return None; // table-cell selections paint no handles + } + let zoom = if zoom > 0.0 { zoom } else { 1.0 }; + let page = layout.pages.get(pos.page_index)?; + let editing_data = page.editing_data.as_ref()?; + let para = editing_data + .paragraphs + .iter() + .find(|p| p.block_index == pos.paragraph_index && p.path.is_empty())?; + let rect = para.layout.cursor_rect(pos.byte_offset)?; + + // Paragraph-local → page-local (content origin + margins), in points. + let page_x_pt = rect.x + para.origin.0 + page.margins.left; + let page_y_pt = rect.y + rect.height + para.origin.1 + page.margins.top + HANDLE_GRAB_OFFSET_PT; + + // Page-local points → canvas CSS px (pages stacked with an unscaled gap), + // then canvas → client. Exact inverse of `hit_test_document`. + let pt_to_px = PT_TO_PX * zoom; + let slot_px = page_height_px * zoom + page_gap_px; + let canvas_x_px = page_x_pt * pt_to_px; + let canvas_y_px = pos.page_index as f32 * slot_px + page_y_pt * pt_to_px; + Some(( + canvas_x_px + canvas_origin.0, + canvas_y_px + canvas_origin.1 - scroll_offset, + )) +} + +/// If `touch` (client CSS px) grabs one of the selection's two handles, +/// returns the **other** endpoint — the one that stays fixed. The caller +/// normalises the selection to `anchor = fixed`, so the drag always moves the +/// focus, and the handles may freely cross. +#[allow(clippy::too_many_arguments)] +pub fn grab_fixed_endpoint( + layout: &PaginatedLayout, + anchor: &DocumentPosition, + focus: &DocumentPosition, + touch: (f32, f32), + canvas_origin: (f32, f32), + scroll_offset: f32, + page_height_px: f32, + page_gap_px: f32, + zoom: f32, +) -> Option { + let near = |p: &DocumentPosition| { + handle_grab_point( + layout, + p, + canvas_origin, + scroll_offset, + page_height_px, + page_gap_px, + zoom, + ) + .is_some_and(|(gx, gy)| { + let (dx, dy) = (touch.0 - gx, touch.1 - gy); + dx.hypot(dy) <= HANDLE_GRAB_RADIUS_PX + }) + }; + if near(anchor) { + return Some(focus.clone()); + } + if near(focus) { + return Some(anchor.clone()); + } + None +} + +#[cfg(test)] +#[path = "selection_handles_tests.rs"] +mod tests; diff --git a/loki-text/src/editing/selection_handles_tests.rs b/loki-text/src/editing/selection_handles_tests.rs new file mode 100644 index 00000000..eeb5f1de --- /dev/null +++ b/loki-text/src/editing/selection_handles_tests.rs @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use std::sync::Arc; + +use loki_layout::{ + FontResources, LayoutColor, LayoutInsets, LayoutPage, LayoutSize, PageEditingData, + PageParagraphData, PaginatedLayout, ParagraphLayout, ResolvedParaProps, StyleSpan, + layout_paragraph, +}; + +use super::*; + +const PAGE_H: f32 = 842.0; +const MARGIN: f32 = 72.0; +const GAP_PX: f32 = 16.0; + +fn para(text: &str) -> Arc { + let mut resources = FontResources::new(); + Arc::new(layout_paragraph( + &mut resources, + text, + &[StyleSpan { + range: 0..text.len(), + font_name: None, + font_size: 12.0, + bold: false, + weight: 400, + italic: false, + color: LayoutColor::BLACK, + underline: None, + strikethrough: None, + line_height: None, + vertical_align: None, + highlight_color: None, + letter_spacing: None, + word_spacing: None, + font_variant: None, + shadow: false, + link_url: None, + math: None, + scale: None, + kerning: None, + baseline_shift: None, + language: None, + }], + &ResolvedParaProps::default(), + 400.0, + 1.0, + true, + )) +} + +fn layout_with(paragraph: Arc) -> PaginatedLayout { + PaginatedLayout { + page_size: LayoutSize::new(595.0, PAGE_H), + pages: vec![Arc::new(LayoutPage { + page_number: 1, + page_size: LayoutSize::new(595.0, PAGE_H), + margins: LayoutInsets { + top: MARGIN, + right: MARGIN, + bottom: MARGIN, + left: MARGIN, + }, + content_items: vec![], + header_items: vec![], + footer_items: vec![], + comment_items: vec![], + header_height: 0.0, + footer_height: 0.0, + editing_data: Some(PageEditingData { + paragraphs: vec![PageParagraphData { + block_index: 0, + path: Vec::new(), + layout: paragraph, + origin: (0.0, 0.0), + rotation: None, + }], + }), + })], + } +} + +#[test] +fn grab_point_round_trips_through_the_hit_test_transform() { + let p = para("alpha beta gamma delta"); + let layout = layout_with(Arc::clone(&p)); + let pos = DocumentPosition::top_level(0, 0, 6); + let origin = (100.0, 50.0); + let (gx, gy) = handle_grab_point(&layout, &pos, origin, 0.0, PAGE_H, GAP_PX, 1.0) + .expect("grab point resolves"); + + // The grab point sits below the caret's line: hit-testing slightly ABOVE + // it (back inside the line) must resolve to (approximately) the same + // byte offset — proving the transform is the hit-test's inverse. + let back = crate::editing::hit_test::hit_test_document( + gx, + gy - (32.0 + 6.0) * (96.0 / 72.0), // undo the teardrop offset + half line + origin, + 0.0, + &layout, + 595.0, + PAGE_H, + GAP_PX, + 1.0, + ) + .expect("hit test resolves"); + assert_eq!(back.paragraph_index, 0); + assert!( + (back.byte_offset as i64 - 6).abs() <= 1, + "round trip should land on the same offset, got {}", + back.byte_offset + ); +} + +#[test] +fn grabbing_a_handle_returns_the_opposite_endpoint() { + let p = para("alpha beta gamma delta"); + let layout = layout_with(Arc::clone(&p)); + let anchor = DocumentPosition::top_level(0, 0, 0); + let focus = DocumentPosition::top_level(0, 0, 10); + let origin = (0.0, 0.0); + + let anchor_grab = handle_grab_point(&layout, &anchor, origin, 0.0, PAGE_H, GAP_PX, 1.0) + .expect("anchor grab point"); + let fixed = grab_fixed_endpoint( + &layout, + &anchor, + &focus, + anchor_grab, + origin, + 0.0, + PAGE_H, + GAP_PX, + 1.0, + ) + .expect("grabbing the anchor handle"); + assert_eq!(fixed, focus, "the focus stays fixed"); + + // A touch far from both handles grabs nothing. + assert!( + grab_fixed_endpoint( + &layout, + &anchor, + &focus, + (anchor_grab.0 + 500.0, anchor_grab.1 + 500.0), + origin, + 0.0, + PAGE_H, + GAP_PX, + 1.0, + ) + .is_none() + ); +} + +#[test] +fn zoom_scales_the_grab_point() { + let p = para("alpha beta gamma delta"); + let layout = layout_with(Arc::clone(&p)); + let pos = DocumentPosition::top_level(0, 0, 6); + let (x1, _) = + handle_grab_point(&layout, &pos, (0.0, 0.0), 0.0, PAGE_H, GAP_PX, 1.0).expect("1x"); + let (x2, _) = + handle_grab_point(&layout, &pos, (0.0, 0.0), 0.0, PAGE_H, GAP_PX, 2.0).expect("2x"); + assert!( + (x2 - 2.0 * x1).abs() < 0.01, + "grab x must scale with zoom: {x1} vs {x2}" + ); +} diff --git a/loki-text/src/editing/touch.rs b/loki-text/src/editing/touch.rs index f2ff145a..623b2cf5 100644 --- a/loki-text/src/editing/touch.rs +++ b/loki-text/src/editing/touch.rs @@ -31,6 +31,9 @@ pub enum TouchPhase { /// Touch has been stationary for at least [`LONG_PRESS_MS`] — word /// selection active. LongPress, + /// Touch started on a selection handle — every move drags the selection + /// focus (the fixed end was normalised into the anchor at grab time). + HandleDrag, } /// Tracks the state of an in-progress touch interaction. @@ -82,7 +85,9 @@ impl TouchInteractionState { TouchPhase::Scroll { .. } => { self.phase = TouchPhase::Scroll { last_y: new_pos.1 }; } - TouchPhase::LongPress => {} + // Sticky phases: a long press stays a long press; a handle drag + // stays a handle drag regardless of distance or duration. + TouchPhase::LongPress | TouchPhase::HandleDrag => {} } false } diff --git a/loki-text/src/routes/editor/editor_canvas.rs b/loki-text/src/routes/editor/editor_canvas.rs index 538c0406..0c0072a3 100644 --- a/loki-text/src/routes/editor/editor_canvas.rs +++ b/loki-text/src/routes/editor/editor_canvas.rs @@ -31,7 +31,6 @@ use std::sync::Arc; use appthere_ui::tokens; -use dioxus::html::input_data::MouseButton; use dioxus::prelude::*; use loki_app_shell::spell::SpellService; use loki_doc_model::document::Document; @@ -41,8 +40,9 @@ use loki_renderer::{DocumentView, RendererCursorPos, TileContext, ViewMode}; use super::editor_canvas_loading::loading_view; use super::editor_error_view::EditorErrorView; use super::editor_keydown::make_keydown_handler; -use super::editor_pointer::{ - make_mousemove_handler, make_touchend_handler, make_touchmove_handler, +use super::editor_pointer::{make_mousedown_handler, make_mousemove_handler, make_mouseup_handler}; +use super::editor_pointer_touch::{ + make_touchend_handler, make_touchmove_handler, make_touchstart_handler, }; use super::editor_scrollbar::{ CanvasMounted, ScrollMetrics, ThumbDrag, horizontal_scrollbar, vertical_scrollbar, @@ -114,8 +114,8 @@ pub(super) fn render_canvas_area( doc_state_keydown: std::sync::Arc>, doc_state_render: std::sync::Arc>, doc_state_scroll: std::sync::Arc>, - mut is_dragging: Signal, - mut drag_origin: Signal>, + is_dragging: Signal, + drag_origin: Signal>, touch_state: Signal>, mut scroll_offset: Signal, mut scroll_metrics: Signal, @@ -228,17 +228,7 @@ pub(super) fn render_canvas_area( } }, - // Outer div records drag origin for the LEFT button only. Right-click - // is handled per-tile (`on_tile_context` on `DocumentView`), which has - // accurate `element_coordinates`; ignore it here so it does not start a - // spurious drag. - onmousedown: move |evt: MouseEvent| { - if evt.trigger_button() == Some(MouseButton::Secondary) { - return; - } - let c = evt.client_coordinates(); - drag_origin.set(Some((c.x as f32, c.y as f32))); - }, + onmousedown: make_mousedown_handler(drag_origin), onmousemove: make_mousemove_handler( doc_state_mousemove, @@ -252,20 +242,18 @@ pub(super) fn render_canvas_area( zoom_percent, ), - onmouseup: move |_| { - is_dragging.set(false); - drag_origin.set(None); - }, + onmouseup: make_mouseup_handler(is_dragging, drag_origin), - ontouchstart: move |evt: TouchEvent| { - let touches = evt.touches(); - let Some(first) = touches.first() else { return }; - let c = first.client_coordinates(); - touch_state.clone().set(Some(TouchInteractionState::new( - 0, - (c.x as f32, c.y as f32), - ))); - }, + ontouchstart: make_touchstart_handler( + std::sync::Arc::clone(&doc_state_touch), + touch_state, + cursor_state, + scroll_offset, + scroll_metrics, + view_mode, + zoom_percent, + page_gap_px, + ), ontouchmove: make_touchmove_handler( doc_state_touch, diff --git a/loki-text/src/routes/editor/editor_pointer.rs b/loki-text/src/routes/editor/editor_pointer.rs index aea35d69..4bac6ea7 100644 --- a/loki-text/src/routes/editor/editor_pointer.rs +++ b/loki-text/src/routes/editor/editor_pointer.rs @@ -1,30 +1,29 @@ // SPDX-License-Identifier: Apache-2.0 -//! Mouse and touch event handler factories for the document canvas. +//! Mouse event handler factories for the document canvas (touch handlers +//! live in `editor_pointer_touch.rs`). use std::sync::{Arc, Mutex}; use appthere_ui::tokens; +use dioxus::html::input_data::MouseButton; use dioxus::prelude::*; -use loki_doc_model::loro_bridge::derive_loro_cursor; -use loki_doc_model::loro_mutation::get_block_text; use loki_renderer::ViewMode; use loki_renderer::render_layout::{ reflow_layout_content_width_pt, reflow_tile_width_px, reflow_type_scale, }; -use crate::editing::cursor::{CursorState, DocumentPosition}; +use crate::editing::cursor::CursorState; use crate::editing::hit_test::{hit_test_document, reflow_hit_test_window}; use crate::editing::state::{DocumentState, ensure_reflow_layout}; -use crate::editing::touch::{TouchInteractionState, TouchPhase, word_boundaries_at}; use crate::editing::viewport::Viewport; use crate::routes::editor::editor_scrollbar::ScrollMetrics; /// Resolves a window-relative tap to a reflow document position, using the same /// continuous layout width as the painted view. Returns `None` outside reflow /// mode or when the canvas has not been measured yet. -fn reflow_tap_position( +pub(super) fn reflow_tap_position( doc_state: &Arc>, client_pos: (f32, f32), viewport: Viewport, @@ -127,169 +126,29 @@ pub(super) fn make_mousemove_handler( } } -/// Builds the `ontouchmove` handler for touch drag and long-press word selection. -#[allow(clippy::too_many_arguments)] -pub(super) fn make_touchmove_handler( - doc_state: Arc>, - mut touch_state: Signal>, - scroll_offset: Signal, - loro_doc: Signal>, - mut cursor_state: Signal, - page_gap_px: f32, - view_mode: Signal, - scroll_metrics: Signal, - zoom_percent: Signal, -) -> impl FnMut(TouchEvent) { - move |evt: TouchEvent| { - let Some(mut ts) = touch_state() else { return }; - let touches = evt.touches(); - let Some(first) = touches.first() else { return }; - let c = first.client_coordinates(); - let new_pos = (c.x as f32, c.y as f32); - let became_scroll = ts.update_move(new_pos); - if became_scroll { - if let TouchPhase::Scroll { last_y } = ts.phase { - // The scroll container is driven by blitz-shell's native scroll - // mechanism; we update scroll_offset here so hit_test_document - // stays accurate. - // TODO(partial-render): replace with direct node.scroll_offset - // once Blitz exposes it. - let _ = last_y; - } - } else if ts.phase == TouchPhase::LongPress { - let start = ts.start_pos; - // Resolve the long-press to a (page, paragraph, byte) position via the - // view mode's hit-test path (reflow has no paginated layout). - let resolved: Option<(usize, usize, usize)> = if view_mode() == ViewMode::Reflow { - reflow_tap_position( - &doc_state, - start, - Viewport::new(scroll_metrics.peek().client_width), - scroll_offset(), - ) - .map(|(para, byte)| (0, para, byte)) - } else { - let (layout_opt, page_width_px, page_height_px) = { - let Ok(state) = doc_state.lock() else { return }; - ( - state.paginated_layout.clone(), - state.page_width_px, - state.page_height_px, - ) - }; - layout_opt.and_then(|layout| { - let zoom = zoom_percent() as f32 / 100.0; - let viewport = Viewport::new(scroll_metrics.peek().client_width); - let x_off = viewport.centred_origin_x(page_width_px * zoom); - let origin = (x_off, tokens::TOOLBAR_HEIGHT_TOP + tokens::SPACE_6); - hit_test_document( - start.0, - start.1, - origin, - scroll_offset(), - &layout, - page_width_px, - page_height_px, - page_gap_px, - zoom, - ) - .map(|p| (p.page_index, p.paragraph_index, p.byte_offset)) - }) - }; - if let Some((page, para, byte)) = resolved { - let ldoc_guard = loro_doc.read(); - if let Some(ldoc) = ldoc_guard.as_ref() { - let text = get_block_text(ldoc, para); - if let Some((ws, we)) = word_boundaries_at(&text, byte) { - let mut cs = cursor_state.write(); - cs.anchor = Some(DocumentPosition::top_level(page, para, ws)); - cs.focus = Some(DocumentPosition::top_level(page, para, we)); - } - } - } +/// Builds the `onmousedown` handler: records the drag origin for the LEFT +/// button only. Right-click is handled per-tile (`on_tile_context` on +/// `DocumentView`), which has accurate `element_coordinates`; it is ignored +/// here so it does not start a spurious drag. +pub(super) fn make_mousedown_handler( + mut drag_origin: Signal>, +) -> impl FnMut(MouseEvent) { + move |evt: MouseEvent| { + if evt.trigger_button() == Some(MouseButton::Secondary) { + return; } - touch_state.set(Some(ts)); + let c = evt.client_coordinates(); + drag_origin.set(Some((c.x as f32, c.y as f32))); } } -/// Builds the `ontouchend` handler for tap cursor placement. -#[allow(clippy::too_many_arguments)] -pub(super) fn make_touchend_handler( - doc_state: Arc>, - mut touch_state: Signal>, - scroll_offset: Signal, - loro_doc: Signal>, - mut cursor_state: Signal, - page_gap_px: f32, - view_mode: Signal, - scroll_metrics: Signal, - zoom_percent: Signal, -) -> impl FnMut(TouchEvent) { - move |_evt: TouchEvent| { - let Some(ts) = touch_state() else { return }; - match ts.phase { - // Reflow mode has no paginated layout: hit-test the continuous flow. - TouchPhase::Indeterminate | TouchPhase::Tap if view_mode() == ViewMode::Reflow => { - if let Some((para, byte)) = reflow_tap_position( - &doc_state, - ts.start_pos, - Viewport::new(scroll_metrics.peek().client_width), - scroll_offset(), - ) { - let loro_cursor = loro_doc - .read() - .as_ref() - .and_then(|ldoc| derive_loro_cursor(ldoc, para, byte)); - let pos = DocumentPosition::top_level(0, para, byte); - let mut cs = cursor_state.write(); - cs.loro_cursor = loro_cursor; - cs.anchor = Some(pos.clone()); - cs.focus = Some(pos); - } - } - TouchPhase::Indeterminate | TouchPhase::Tap => { - // Short tap — place cursor via the same hit-test path as a mouse click. - let (layout_opt, page_width_px, page_height_px) = { - let Ok(state) = doc_state.lock() else { - touch_state.set(None); - return; - }; - ( - state.paginated_layout.clone(), - state.page_width_px, - state.page_height_px, - ) - }; - if let Some(layout) = layout_opt { - let zoom = zoom_percent() as f32 / 100.0; - let viewport = Viewport::new(scroll_metrics.peek().client_width); - let x_off = viewport.centred_origin_x(page_width_px * zoom); - let origin = (x_off, tokens::TOOLBAR_HEIGHT_TOP + tokens::SPACE_6); - if let Some(pos) = hit_test_document( - ts.start_pos.0, - ts.start_pos.1, - origin, - scroll_offset(), - &layout, - page_width_px, - page_height_px, - page_gap_px, - zoom, - ) { - let loro_cursor = loro_doc.read().as_ref().and_then(|ldoc| { - derive_loro_cursor(ldoc, pos.paragraph_index, pos.byte_offset) - }); - let mut cs = cursor_state.write(); - cs.loro_cursor = loro_cursor; - cs.anchor = Some(pos.clone()); - cs.focus = Some(pos); - } - } - } - // Scroll and long-press states are already handled incrementally - // in ontouchmove. - TouchPhase::Scroll { .. } | TouchPhase::LongPress => {} - } - touch_state.set(None); +/// Builds the `onmouseup` handler: ends a drag-selection gesture. +pub(super) fn make_mouseup_handler( + mut is_dragging: Signal, + mut drag_origin: Signal>, +) -> impl FnMut(MouseEvent) { + move |_evt: MouseEvent| { + is_dragging.set(false); + drag_origin.set(None); } } diff --git a/loki-text/src/routes/editor/editor_pointer_touch.rs b/loki-text/src/routes/editor/editor_pointer_touch.rs new file mode 100644 index 00000000..97a4bb03 --- /dev/null +++ b/loki-text/src/routes/editor/editor_pointer_touch.rs @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Touch event handler factories for the document canvas (split from +//! `editor_pointer.rs` for the 300-line ceiling). +//! +//! Gestures: tap places the caret; a moved touch scrolls; a stationary touch +//! long-presses into word selection; and a touch that starts on a selection +//! handle drags that selection edge ([`TouchPhase::HandleDrag`] — the fixed +//! end is normalised into the anchor at grab time, so every move just updates +//! the focus, and the handles may cross). + +use std::sync::{Arc, Mutex}; + +use appthere_ui::tokens; +use dioxus::prelude::*; +use loki_doc_model::loro_bridge::derive_loro_cursor; +use loki_doc_model::loro_mutation::get_block_text; +use loki_renderer::ViewMode; + +use super::editor_pointer::reflow_tap_position; +use crate::editing::cursor::{CursorState, DocumentPosition}; +use crate::editing::hit_test::hit_test_document; +use crate::editing::selection_handles::grab_fixed_endpoint; +use crate::editing::state::DocumentState; +use crate::editing::touch::{TouchInteractionState, TouchPhase, word_boundaries_at}; +use crate::editing::viewport::Viewport; +use crate::routes::editor::editor_scrollbar::ScrollMetrics; + +/// Resolves a window-relative point to a paginated document position, using +/// the same origin/zoom transform as the mouse handlers. `None` when no +/// layout is loaded or the point misses every page. +fn paginated_hit( + doc_state: &Arc>, + point: (f32, f32), + scroll_offset: f32, + scroll_metrics: Signal, + zoom_percent: Signal, + page_gap_px: f32, +) -> Option { + let (layout_opt, page_width_px, page_height_px) = { + let state = doc_state.lock().ok()?; + ( + state.paginated_layout.clone(), + state.page_width_px, + state.page_height_px, + ) + }; + let layout = layout_opt?; + let zoom = zoom_percent() as f32 / 100.0; + let viewport = Viewport::new(scroll_metrics.peek().client_width); + let x_off = viewport.centred_origin_x(page_width_px * zoom); + let origin = (x_off, tokens::TOOLBAR_HEIGHT_TOP + tokens::SPACE_6); + hit_test_document( + point.0, + point.1, + origin, + scroll_offset, + &layout, + page_width_px, + page_height_px, + page_gap_px, + zoom, + ) +} + +/// Builds the `ontouchstart` handler: begins gesture classification, and — +/// when a range selection is active and the touch lands on one of its +/// teardrop handles — starts a handle drag instead. +#[allow(clippy::too_many_arguments)] +pub(super) fn make_touchstart_handler( + doc_state: Arc>, + mut touch_state: Signal>, + mut cursor_state: Signal, + scroll_offset: Signal, + scroll_metrics: Signal, + view_mode: Signal, + zoom_percent: Signal, + page_gap_px: f32, +) -> impl FnMut(TouchEvent) { + move |evt: TouchEvent| { + let touches = evt.touches(); + let Some(first) = touches.first() else { return }; + let c = first.client_coordinates(); + let pos = (c.x as f32, c.y as f32); + let mut state = TouchInteractionState::new(0, pos); + + // Handle grab — paginated mode only (handles paint there). + if *view_mode.peek() != ViewMode::Reflow && cursor_state.peek().has_selection() { + let grabbed = { + let cs = cursor_state.peek(); + let (Some(anchor), Some(focus)) = (cs.anchor.clone(), cs.focus.clone()) else { + touch_state.set(Some(state)); + return; + }; + let layout_and_dims = doc_state.lock().ok().map(|s| { + ( + s.paginated_layout.clone(), + s.page_width_px, + s.page_height_px, + ) + }); + match layout_and_dims { + Some((Some(layout), page_width_px, page_height_px)) => { + let zoom = *zoom_percent.peek() as f32 / 100.0; + let viewport = Viewport::new(scroll_metrics.peek().client_width); + let x_off = viewport.centred_origin_x(page_width_px * zoom); + let origin = (x_off, tokens::TOOLBAR_HEIGHT_TOP + tokens::SPACE_6); + grab_fixed_endpoint( + &layout, + &anchor, + &focus, + pos, + origin, + *scroll_offset.peek(), + page_height_px, + page_gap_px, + zoom, + ) + } + _ => None, + } + }; + if let Some(fixed) = grabbed { + state.phase = TouchPhase::HandleDrag; + cursor_state.write().anchor = Some(fixed); + } + } + touch_state.set(Some(state)); + } +} + +/// Builds the `ontouchmove` handler for scroll, long-press word selection, and +/// handle dragging. +#[allow(clippy::too_many_arguments)] +pub(super) fn make_touchmove_handler( + doc_state: Arc>, + mut touch_state: Signal>, + scroll_offset: Signal, + loro_doc: Signal>, + mut cursor_state: Signal, + page_gap_px: f32, + view_mode: Signal, + scroll_metrics: Signal, + zoom_percent: Signal, +) -> impl FnMut(TouchEvent) { + move |evt: TouchEvent| { + let Some(mut ts) = touch_state() else { return }; + let touches = evt.touches(); + let Some(first) = touches.first() else { return }; + let c = first.client_coordinates(); + let new_pos = (c.x as f32, c.y as f32); + let became_scroll = ts.update_move(new_pos); + if became_scroll { + if let TouchPhase::Scroll { last_y } = ts.phase { + // The scroll container is driven by blitz-shell's native scroll + // mechanism; we update scroll_offset here so hit_test_document + // stays accurate. + // TODO(partial-render): replace with direct node.scroll_offset + // once Blitz exposes it. + let _ = last_y; + } + } else if ts.phase == TouchPhase::HandleDrag { + // Drag the selection focus under the finger (the grabbed edge). + if let Some(p) = paginated_hit( + &doc_state, + new_pos, + scroll_offset(), + scroll_metrics, + zoom_percent, + page_gap_px, + ) { + cursor_state.write().focus = Some(p); + } + } else if ts.phase == TouchPhase::LongPress { + let start = ts.start_pos; + // Resolve the long-press to a (page, paragraph, byte) position via the + // view mode's hit-test path (reflow has no paginated layout). + let resolved: Option<(usize, usize, usize)> = if view_mode() == ViewMode::Reflow { + reflow_tap_position( + &doc_state, + start, + Viewport::new(scroll_metrics.peek().client_width), + scroll_offset(), + ) + .map(|(para, byte)| (0, para, byte)) + } else { + paginated_hit( + &doc_state, + start, + scroll_offset(), + scroll_metrics, + zoom_percent, + page_gap_px, + ) + .map(|p| (p.page_index, p.paragraph_index, p.byte_offset)) + }; + if let Some((page, para, byte)) = resolved { + let ldoc_guard = loro_doc.read(); + if let Some(ldoc) = ldoc_guard.as_ref() { + let text = get_block_text(ldoc, para); + if let Some((ws, we)) = word_boundaries_at(&text, byte) { + let mut cs = cursor_state.write(); + cs.anchor = Some(DocumentPosition::top_level(page, para, ws)); + cs.focus = Some(DocumentPosition::top_level(page, para, we)); + } + } + } + } + touch_state.set(Some(ts)); + } +} + +/// Builds the `ontouchend` handler for tap cursor placement. +#[allow(clippy::too_many_arguments)] +pub(super) fn make_touchend_handler( + doc_state: Arc>, + mut touch_state: Signal>, + scroll_offset: Signal, + loro_doc: Signal>, + mut cursor_state: Signal, + page_gap_px: f32, + view_mode: Signal, + scroll_metrics: Signal, + zoom_percent: Signal, +) -> impl FnMut(TouchEvent) { + move |_evt: TouchEvent| { + let Some(ts) = touch_state() else { return }; + match ts.phase { + // Reflow mode has no paginated layout: hit-test the continuous flow. + TouchPhase::Indeterminate | TouchPhase::Tap if view_mode() == ViewMode::Reflow => { + if let Some((para, byte)) = reflow_tap_position( + &doc_state, + ts.start_pos, + Viewport::new(scroll_metrics.peek().client_width), + scroll_offset(), + ) { + let loro_cursor = loro_doc + .read() + .as_ref() + .and_then(|ldoc| derive_loro_cursor(ldoc, para, byte)); + let pos = DocumentPosition::top_level(0, para, byte); + let mut cs = cursor_state.write(); + cs.loro_cursor = loro_cursor; + cs.anchor = Some(pos.clone()); + cs.focus = Some(pos); + } + } + TouchPhase::Indeterminate | TouchPhase::Tap => { + // Short tap — place cursor via the same hit-test path as a mouse click. + if let Some(pos) = paginated_hit( + &doc_state, + ts.start_pos, + scroll_offset(), + scroll_metrics, + zoom_percent, + page_gap_px, + ) { + let loro_cursor = loro_doc.read().as_ref().and_then(|ldoc| { + derive_loro_cursor(ldoc, pos.paragraph_index, pos.byte_offset) + }); + let mut cs = cursor_state.write(); + cs.loro_cursor = loro_cursor; + cs.anchor = Some(pos.clone()); + cs.focus = Some(pos); + } + } + // Scroll and long-press are handled incrementally in ontouchmove; + // a handle drag simply ends, keeping the adjusted selection. + TouchPhase::Scroll { .. } | TouchPhase::LongPress | TouchPhase::HandleDrag => {} + } + touch_state.set(None); + } +} diff --git a/loki-text/src/routes/editor/mod.rs b/loki-text/src/routes/editor/mod.rs index 9a9d8fca..fd0e8a5c 100644 --- a/loki-text/src/routes/editor/mod.rs +++ b/loki-text/src/routes/editor/mod.rs @@ -36,6 +36,7 @@ mod editor_metadata; mod editor_metadata_panel; mod editor_path_sync; mod editor_pointer; +mod editor_pointer_touch; mod editor_publish; mod editor_responsive; mod editor_ribbon; From 98acc095082b262ba12db46c1a0e18b6b4da7163 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 12:23:56 +0000 Subject: [PATCH 3/3] CI: baseline deliberate suppressions in new fit-and-finish files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `lint` job failed on `check-suppressions.py`: the fit-and-finish / mobile-selection batch added six new production files carrying narrowly-scoped, deliberate suppressions, and resolved the debt in a seventh (a stale baseline entry). Each flagged item is intended semantics, not a swallowed bug: - `#[allow(clippy::too_many_arguments)]` on geometry/handler factories (status_bar_chips, selection_handles, editor_pointer_touch) — the narrowly-scoped allow CLAUDE.md's tech-debt notes accept (flow_cell_blocks precedent). - `let _ = tx.send(..)` on oneshot channels (editor_fonts, editor_save_banner) — the receiver may have dropped; discarding is idiomatic. - `let _ = fs::write / create_dir_all / spawn(..)` in window_geometry — documented silent-fail persistence (losing a window size is harmless). - `let _ = slot.set(..) / available_font_families()` in font_handle — OnceLock::set cannot fail here; the other is a lazy-index warm-up touch. - `let _ = last_y;` in editor_pointer_touch — an explicit discard carrying a TODO(partial-render). Per the ratchet's own contract, a genuinely-needed suppression is a deliberate `--update` + justification. Regenerated with `scripts/check-suppressions.py --update`; also drops the now-0/0 editor_ribbon_color.rs entry and tracks the editor_pointer.rs → editor_pointer_touch.rs split. Verified the remaining lint gates (fmt, clippy --all-features -D warnings, and the other Python gates) that never ran because bash -e stopped at the suppression gate. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UB6u6gsqSCBceTb9Y9U4Ba --- scripts/suppressions-baseline.txt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/suppressions-baseline.txt b/scripts/suppressions-baseline.txt index 042b86e1..49d21f81 100644 --- a/scripts/suppressions-baseline.txt +++ b/scripts/suppressions-baseline.txt @@ -3,7 +3,7 @@ # scripts/check-suppressions.py: neither count may GROW; a file must be # removed once both reach 0. New files must start at 0/0. # Regenerate with: scripts/check-suppressions.py --update -# Totals: 452 `let _ =`, 169 `#[allow]` across 134 files. +# Totals: 461 `let _ =`, 172 `#[allow]` across 140 files. 66 1 loki-ooxml/src/docx/write/document_drawing.rs 47 0 loki-ooxml/src/docx/write/styles.rs @@ -29,6 +29,7 @@ 4 1 loki-text/src/routes/editor/editor_style.rs 3 1 loki-app-shell/src/recent_documents.rs 4 0 loki-app-shell/src/spell/personal_dict.rs +4 0 loki-layout/src/font_handle.rs 4 0 loki-layout/src/para_cache.rs 1 3 loki-odf/src/odt/reader/styles.rs 0 4 loki-ooxml/src/docx/model/fields.rs @@ -38,7 +39,8 @@ 0 4 loki-opc/src/compat/part_names.rs 0 4 loki-opc/src/content_types/parse.rs 0 4 loki-text/src/routes/editor/editor_keydown_backspace.rs -1 3 loki-text/src/routes/editor/editor_pointer.rs +1 3 loki-text/src/routes/editor/editor_pointer_touch.rs +3 0 loki-app-shell/src/window_geometry.rs 1 2 loki-odf/src/odt/reader/document.rs 0 3 loki-ooxml/src/docx/model/numbering.rs 0 3 loki-opc/src/compat/content_types.rs @@ -60,11 +62,13 @@ 0 2 loki-spreadsheet/src/lib.rs 2 0 loki-spreadsheet/src/routes/editor/editor_inner.rs 1 1 loki-text/src/editing/hit_test.rs +0 2 loki-text/src/editing/selection_handles.rs 1 1 loki-text/src/routes/editor/editor_canvas.rs 0 2 loki-text/src/routes/editor/editor_keydown_text.rs 2 0 loki-text/src/routes/editor/editor_layout_task.rs 2 0 loki-text/src/routes/editor/editor_spell.rs 0 1 appthere-conformance/src/golden/ciede.rs +0 1 appthere-ui/src/components/status_bar_chips.rs 1 0 appthere-ui/src/responsive/ribbon_collapse.rs 1 0 appthere-ui/src/safe_area.rs 0 1 appthere-ui/src/tokens/colors.rs @@ -127,12 +131,14 @@ 0 1 loki-text/build.rs 0 1 loki-text/src/lib.rs 0 1 loki-text/src/routes/editor/editor_docked_panels.rs +1 0 loki-text/src/routes/editor/editor_fonts.rs 1 0 loki-text/src/routes/editor/editor_insert_panel.rs 0 1 loki-text/src/routes/editor/editor_keydown.rs 1 0 loki-text/src/routes/editor/editor_keydown_enter.rs -0 1 loki-text/src/routes/editor/editor_ribbon_color.rs +0 1 loki-text/src/routes/editor/editor_pointer.rs 0 1 loki-text/src/routes/editor/editor_ribbon_layout.rs 0 1 loki-text/src/routes/editor/editor_ribbon_review.rs +1 0 loki-text/src/routes/editor/editor_save_banner.rs 1 0 loki-text/src/routes/editor/editor_save_callbacks.rs 0 1 loki-text/src/routes/editor/editor_style_editor/body.rs 0 1 loki-text/src/routes/editor/editor_style_editor/mod.rs