diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d3a14e98..55d61d66 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -57,6 +57,10 @@ jobs: - name: Viewport-dimension guard (no assumed screen sizes) # Spec 01 audit A-1. No bare 1280-class literals in editor input/viewport paths. run: python3 scripts/check-no-hardcoded-viewport-dims.py + - name: Arc::get_mut ban in loki-layout (copy-on-write must not be fallible) + # Spec 09 L9-016. Arc::get_mut returns None whenever the layout is shared — + # always, for a cached one — so it silently skips the mutation. Use make_mut. + run: python3 scripts/check-arc-get-mut.py - name: Format check run: cargo fmt --all --check - name: Clippy (workspace, all features, warnings denied) @@ -65,6 +69,58 @@ jobs: # gen_templates bin) carries a scoped file-level allow. run: cargo clippy --workspace --all-features -- -D warnings -D clippy::unwrap_used -D clippy::expect_used + # Spec 08 L08-014: every supported target builds in CI. Android is a supported + # target and was NOT built here, so merge cce9772 could leave `loki-text` with + # two `android_main` definitions (E0428) for three weeks without any job going + # red — both copies sit behind `#[cfg(target_os = "android")]`, which the jobs + # above never compile. This job is the gate that closes that class. + # + # `cargo check`, not `cargo build`: it type-checks the cfg'd code (which is all + # that was ever wrong) without linking a cdylib, and needs no SDK, d8 or + # keystore. The NDK is still required because `ring` (via reqwest/rustls) + # compiles C in its build script even under `check`. + android-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install toolchain (+ Android target) + # Pinned — see the lint job and rust-toolchain.toml. + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.97.1" + targets: aarch64-linux-android + - uses: Swatinem/rust-cache@v2 + with: + # Distinct cache: this job's artifacts are for a different target triple + # and must not share a key with the host build. + key: android + - name: Point cargo and cc-rs at the runner's preinstalled NDK + # ubuntu-latest ships the Android NDK; no download step needed. API 26 is + # loki-text's min_sdk_version (Cargo.toml [package.metadata.android.sdk]). + # Fail loudly if the layout ever changes rather than falling back to a + # host compiler and producing a meaningless pass. + run: | + NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_ROOT:-}}" + BIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin" + CLANG="$BIN/aarch64-linux-android26-clang" + if [ ! -x "$CLANG" ]; then + echo "::error::Android NDK clang not found at $CLANG" + ls "$BIN" 2>/dev/null | head -20 + exit 1 + fi + { + echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$CLANG" + echo "CC_aarch64_linux_android=$CLANG" + echo "AR_aarch64_linux_android=$BIN/llvm-ar" + } >> "$GITHUB_ENV" + - name: Check loki-text for Android + # Default features, matching what an APK actually ships. + # TODO(android-ci): add `-p loki-spreadsheet -p loki-presentation` once + # those two crates build again (Spec 08 R11) — they share this entry point + # via `loki_app_shell::android_main!`, so they are exposed to the same + # class of break and are currently unguarded. + run: cargo check --target aarch64-linux-android -p loki-text + build-and-test: runs-on: ubuntu-latest steps: diff --git a/Cargo.lock b/Cargo.lock index 89570744..cdef1534 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -410,6 +410,8 @@ name = "appthere-ui" version = "0.1.0" dependencies = [ "dioxus", + "futures-channel", + "futures-util", "loki-i18n", ] diff --git a/appthere-ui/Cargo.toml b/appthere-ui/Cargo.toml index 4e75b08d..5213570e 100644 --- a/appthere-ui/Cargo.toml +++ b/appthere-ui/Cargo.toml @@ -13,3 +13,9 @@ path = "src/lib.rs" [dependencies] dioxus = { workspace = true } loki-i18n = { path = "../loki-i18n" } +# Smooth-scroll animation ticks: a worker thread sleeps and signals the UI task +# through an mpsc channel, because dioxus-native has no async timer and Blitz +# no per-element animation clock (Spec 08 S0.1 / scroll::animate). +futures-channel = "0.3" +# StreamExt::next, to drain those ticks on the UI thread. +futures-util = { version = "0.3", default-features = false } diff --git a/appthere-ui/src/device_profile.rs b/appthere-ui/src/device_profile.rs new file mode 100644 index 00000000..da0a396a --- /dev/null +++ b/appthere-ui/src/device_profile.rs @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Runtime device capabilities (Spec 08 T1.6, ADR L08-011). +//! +//! # Form factor is a runtime property +//! +//! An Android build may be running on a phone or on laptop-class hardware with +//! a desktop shell, a mouse, a hardware keyboard and desktop-tier RAM. So no +//! behaviour may be gated on `cfg!(target_os = ...)`; platform-specific *API +//! selection* is fine, platform-specific *behaviour* is not. Spike S0.6 +//! enumerated the 11 sites that break that rule today +//! (`docs/spikes/S0.6-device-capability-probe.md` §2a); they migrate here. +//! +//! # This extends the responsive context, it does not replace it +//! +//! Viewport size and its [`Breakpoint`](crate::responsive::Breakpoint) already +//! live in [`crate::responsive`], measured from one source (Spec 01 audit A-1, +//! Spec 03 D4). Nothing here duplicates them — a consumer that wants "how wide +//! is the window" still reads the breakpoint. This carries the properties the +//! viewport cannot express: what is pointing at it, what is plugged into it, +//! and what it is made of. +//! +//! # Observable, not sampled +//! +//! A mouse can be plugged in mid-session; a window can move to another display. +//! The profile is a `Signal`, and consumers read the field they care about +//! through a memo so a change wakes only what it affects. +//! +//! # Injectable +//! +//! Probes are *supplied* to [`DeviceProfile`], never run inside it. That is +//! what lets Phases 2, 4, 5 and 7 be tested against synthetic profiles without +//! the hardware — the mitigation for Spec 08 R12, which is otherwise blocked on +//! owning an Android desktop device. + +use dioxus::prelude::*; + +/// What kind of pointing device is in use. +/// +/// Both variants can be true at once: an Android desktop device with a +/// touchscreen and a mouse is [`Self::Both`], and features that key off this +/// must handle that rather than assuming a dichotomy (Spec 08 §3.5 — the +/// tooltip case). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum PointerPrecision { + /// Nothing has pointed at the app yet. + #[default] + Unknown, + /// Mouse, trackpad or stylus — hover exists, small targets are reachable. + Fine, + /// Touch only — no hover, 44 px minimum targets, long-press replaces + /// right-click and hover tooltips are unreachable. + Coarse, + /// Both are present in this session. + Both, +} + +impl PointerPrecision { + /// `true` when a hover-triggered affordance (a tooltip) can actually be + /// reached. False for [`Self::Unknown`]: until we know, assume the + /// affordance needs a visible label, because an unreachable tooltip is a + /// worse failure than a redundant label. + #[must_use] + pub fn has_hover(self) -> bool { + matches!(self, Self::Fine | Self::Both) + } + + /// `true` when touch input is available, so long-press and larger targets + /// must be offered. + #[must_use] + pub fn has_touch(self) -> bool { + matches!(self, Self::Coarse | Self::Both) + } + + /// Folds an observed pointer event into the current state, latching to + /// [`Self::Both`] once each kind has been seen. + /// + /// This is the interim signal S0.6 §4 describes: winit surfaces device + /// add/remove but `blitz-shell` does not forward it yet, so precision is + /// inferred from the events that do arrive. Once the shell forwards device + /// enumeration this becomes a direct read and the latch can go. + #[must_use] + pub fn observe(self, seen: PointerPrecision) -> Self { + match (self, seen) { + (Self::Unknown, other) => other, + (current, Self::Unknown) => current, + (a, b) if a == b => a, + _ => Self::Both, + } + } +} + +/// Rough capability class of the GPU, from the wgpu adapter. +/// +/// Replaces `cfg!(target_os = "android")` as the renderer-path selector: the +/// question the renderer actually asks is "can this device run Vello's compute +/// pipelines", which an emulator on x86 answers differently from a physical +/// Android device (S0.6 §2a, §3). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum GpuClass { + /// Not yet probed. + #[default] + Unknown, + /// Discrete GPU. + Discrete, + /// Integrated GPU. + Integrated, + /// Software rasteriser (SwiftShader, llvmpipe) — cannot run Vello compute. + Software, + /// No usable adapter; the CPU renderer is the only option. + None, +} + +impl GpuClass { + /// `true` when the GPU paint path is viable. + #[must_use] + pub fn supports_gpu_paint(self) -> bool { + matches!(self, Self::Discrete | Self::Integrated) + } +} + +/// Physical characteristics of the display a window is on. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct PhysicalDisplay { + /// Measured or calibrated pixels per inch. `None` while unknown — per D-04 + /// the calibration prompt appears on first use of Actual Size, never at + /// first run, so an unknown value is a normal state and not an error. + pub px_per_inch: Option, +} + +/// How the window is presented. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum WindowMode { + /// Not yet determined. + #[default] + Unknown, + /// One window filling the display — the phone default, and desktop + /// fullscreen. + FullscreenSingle, + /// A window among others, freely resizable. + Windowed, +} + +/// A snapshot of what this session is running on. +/// +/// Construct with [`Self::default`] and fill in fields as probes report; every +/// field is independently `Unknown`/`None` until then, and consumers must +/// behave sensibly in that state rather than waiting for it. +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub struct DeviceProfile { + /// What is pointing at the app. + pub pointer: PointerPrecision, + /// Whether a hardware keyboard is attached. Advisory: the IME safe area is + /// driven by the actual inset value, which is already 0 when no soft + /// keyboard is shown (S0.4 §7), so this must not be used to *reserve* + /// space. + pub hardware_keyboard: bool, + /// Total system RAM, when the platform has been queried. + pub system_ram_bytes: Option, + /// GPU capability class. + pub gpu_class: GpuClass, + /// The current display's physical characteristics. + pub display: Option, + /// How the window is presented. + pub window_mode: WindowMode, + /// Whether the user or platform asked for reduced motion. Wired to + /// [`crate::MotionPreference`] by the app. + /// + /// TODO(device-profile): probe the platform setting — Android + /// `Settings.Global.ANIMATOR_DURATION_SCALE`, Windows + /// `SPI_GETCLIENTAREAANIMATION`, macOS + /// `accessibilityDisplayShouldReduceMotion`. Until then this is only ever + /// set by an explicit user preference, and defaults to full motion. + pub reduced_motion: bool, +} + +/// The device-profile context injected at the application root. +#[derive(Clone, Copy, PartialEq)] +pub struct AtDeviceProfileContext { + /// The live profile. + pub profile: Signal, +} + +/// Provides [`AtDeviceProfileContext`] at the application root and returns the +/// backing signal so probes can push into it. Call once, in the root component. +pub fn use_provide_device_profile() -> Signal { + let profile = use_signal(DeviceProfile::default); + provide_context(AtDeviceProfileContext { profile }); + profile +} + +/// Reads the device profile injected at the application root. +/// +/// Returns [`DeviceProfile::default`] — everything `Unknown` — when no context +/// has been provided, so a component used outside an app root (a test, a +/// preview) degrades instead of panicking. +#[must_use] +pub fn use_device_profile() -> DeviceProfile { + match try_consume_context::() { + Some(ctx) => *ctx.profile.read(), + None => DeviceProfile::default(), + } +} + +/// Folds an observed pointer kind into the ambient profile. +/// +/// Cheap enough to call from every pointer handler: it writes only when the +/// precision actually changes, so a stream of mouse-moves does not wake the +/// consumers of the signal. +pub fn note_pointer(seen: PointerPrecision) { + let Some(ctx) = try_consume_context::() else { + return; + }; + let mut profile = ctx.profile; + let current = profile.peek().pointer; + let next = current.observe(seen); + if next != current { + profile.write().pointer = next; + } +} + +#[cfg(test)] +#[path = "device_profile_tests.rs"] +mod tests; diff --git a/appthere-ui/src/device_profile_tests.rs b/appthere-ui/src/device_profile_tests.rs new file mode 100644 index 00000000..9a2e915b --- /dev/null +++ b/appthere-ui/src/device_profile_tests.rs @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for [`super::DeviceProfile`] and the pointer-precision latch. + +use super::{DeviceProfile, GpuClass, PointerPrecision, WindowMode}; + +#[test] +fn everything_starts_unknown() { + let p = DeviceProfile::default(); + assert_eq!(p.pointer, PointerPrecision::Unknown); + assert_eq!(p.gpu_class, GpuClass::Unknown); + assert_eq!(p.window_mode, WindowMode::Unknown); + assert_eq!(p.system_ram_bytes, None); + assert!(p.display.is_none()); + assert!(!p.hardware_keyboard); + assert!(!p.reduced_motion); +} + +#[test] +fn unknown_pointer_offers_no_hover() { + // The conservative direction: until we know, assume a tooltip cannot be + // reached, so the affordance carries a visible label. + assert!(!PointerPrecision::Unknown.has_hover()); + assert!(!PointerPrecision::Unknown.has_touch()); +} + +#[test] +fn first_observation_sets_the_precision() { + assert_eq!( + PointerPrecision::Unknown.observe(PointerPrecision::Coarse), + PointerPrecision::Coarse + ); + assert_eq!( + PointerPrecision::Unknown.observe(PointerPrecision::Fine), + PointerPrecision::Fine + ); +} + +#[test] +fn seeing_both_kinds_latches_to_both() { + // The Android desktop case: a touchscreen and a mouse in one session. This + // is the state Spec 08 §3.5 says features must handle, so it must be + // reachable from either starting point. + assert_eq!( + PointerPrecision::Coarse.observe(PointerPrecision::Fine), + PointerPrecision::Both + ); + assert_eq!( + PointerPrecision::Fine.observe(PointerPrecision::Coarse), + PointerPrecision::Both + ); +} + +#[test] +fn both_is_absorbing() { + // Once a mouse has been seen, a later touch must not demote back to Coarse + // and take the tooltips away. + for seen in [ + PointerPrecision::Fine, + PointerPrecision::Coarse, + PointerPrecision::Both, + PointerPrecision::Unknown, + ] { + assert_eq!(PointerPrecision::Both.observe(seen), PointerPrecision::Both); + } +} + +#[test] +fn repeated_observations_are_stable() { + assert_eq!( + PointerPrecision::Fine.observe(PointerPrecision::Fine), + PointerPrecision::Fine + ); + assert_eq!( + PointerPrecision::Coarse.observe(PointerPrecision::Coarse), + PointerPrecision::Coarse + ); +} + +#[test] +fn an_unknown_observation_never_downgrades() { + assert_eq!( + PointerPrecision::Fine.observe(PointerPrecision::Unknown), + PointerPrecision::Fine + ); +} + +#[test] +fn both_has_hover_and_touch() { + assert!(PointerPrecision::Both.has_hover()); + assert!(PointerPrecision::Both.has_touch()); + assert!(PointerPrecision::Fine.has_hover()); + assert!(!PointerPrecision::Fine.has_touch()); + assert!(PointerPrecision::Coarse.has_touch()); + assert!(!PointerPrecision::Coarse.has_hover()); +} + +#[test] +fn only_real_gpus_support_the_paint_path() { + // The emulator case that `--cfg android_gpu` currently encodes at build + // time: SwiftShader must not be handed the Vello compute path. + assert!(GpuClass::Discrete.supports_gpu_paint()); + assert!(GpuClass::Integrated.supports_gpu_paint()); + assert!(!GpuClass::Software.supports_gpu_paint()); + assert!(!GpuClass::None.supports_gpu_paint()); + assert!(!GpuClass::Unknown.supports_gpu_paint()); +} + +#[test] +fn a_synthetic_profile_can_describe_an_android_desktop() { + // R12's mitigation: the device we cannot buy, constructed in a test. + let p = DeviceProfile { + pointer: PointerPrecision::Both, + hardware_keyboard: true, + system_ram_bytes: Some(16 * 1024 * 1024 * 1024), + gpu_class: GpuClass::Integrated, + display: None, + window_mode: WindowMode::Windowed, + reduced_motion: false, + }; + assert!(p.pointer.has_hover(), "a mouse is attached: tooltips work"); + assert!(p.pointer.has_touch(), "the touchscreen still exists"); + assert!(p.gpu_class.supports_gpu_paint()); + assert_eq!(p.window_mode, WindowMode::Windowed); +} diff --git a/appthere-ui/src/lib.rs b/appthere-ui/src/lib.rs index 8a164b95..fc6ab2fd 100644 --- a/appthere-ui/src/lib.rs +++ b/appthere-ui/src/lib.rs @@ -26,8 +26,10 @@ #![warn(missing_docs)] pub mod components; +pub mod device_profile; pub mod responsive; pub mod safe_area; +pub mod scroll; pub mod theme; pub mod tokens; @@ -59,6 +61,10 @@ pub use components::{ MacroDialogFrame, MacroDialogFrameProps, MacroGrantChoice, MacroTrustChoice, PanelPosture, Platform, RecentDocument, BACKDROP_Z_INDEX, }; +pub use device_profile::{ + note_pointer, use_device_profile, use_provide_device_profile, AtDeviceProfileContext, + DeviceProfile, GpuClass, PhysicalDisplay, PointerPrecision, WindowMode, +}; 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, @@ -66,4 +72,8 @@ pub use responsive::{ 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 scroll::{ + use_viewport_controller, ContentRect, MotionPreference, RevealMargin, ScrollMetrics, + ViewportController, +}; pub use theme::{use_theme, AtThemeContext, ThemeVariant}; diff --git a/appthere-ui/src/scroll/animate.rs b/appthere-ui/src/scroll/animate.rs new file mode 100644 index 00000000..ee746bb8 --- /dev/null +++ b/appthere-ui/src/scroll/animate.rs @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Smooth-scroll animation (Spec 08 T1.1). +//! +//! `MountedData::scroll` is instant regardless of the [`ScrollBehavior`] passed +//! — the vendored `dioxus-native-dom` patch performs the scroll eagerly and +//! ignores the flag, and `scroll_to` (`scrollIntoView`) is a documented no-op. +//! S0.1 identified animated programmatic scroll as the one scroll capability +//! Blitz does not provide; per that spike it is closed **app-side** rather than +//! with a patch, because the shell has no per-element animation clock we could +//! drive and the patch surface is better kept where it is. +//! +//! So: this module steps the offset itself, issuing a series of instant +//! scrolls. The easing curve is pure and unit-tested; the driver lives in +//! [`super::controller`]. +//! +//! # Timing +//! +//! There is no `requestAnimationFrame` here and no async timer runtime under +//! `dioxus-native`. Ticks come from a worker thread that sleeps and signals +//! back through a channel — the same cross-thread yield the open-path layout +//! task and the save-status auto-clear already use. One thread per animation, +//! living ~200 ms; smooth scrolls are discrete user gestures (Find, Go To Page, +//! an outline click), never keystrokes, so they are rare by construction. + +/// Wall-clock length of a smooth scroll. Short enough to feel like a response +/// rather than a transition — a caret reveal that takes longer than this reads +/// as lag, which is the failure mode T1.3 warns about. +pub(super) const SMOOTH_DURATION_MS: f32 = 180.0; + +/// Interval between animation ticks, ≈60 Hz. +pub(super) const TICK_MS: u64 = 16; + +/// Ease-out cubic: fast departure, gentle arrival. +/// +/// Chosen over linear because a linear scroll stopping dead reads as a jump cut +/// at the end; ease-*out* specifically (rather than ease-in-out) because the +/// motion is a response to something the user just did, so it should start +/// immediately. +#[must_use] +pub fn ease_out_cubic(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + let inv = 1.0 - t; + 1.0 - inv * inv * inv +} + +/// Position at `elapsed_ms` into a scroll from `from` to `to`, and whether the +/// animation is finished. +/// +/// Finishing snaps exactly to `to`: interpolation alone would leave a +/// sub-pixel residue, and a scroll offset that never quite arrives keeps +/// `reveal_offset` asking for the same scroll forever. +#[must_use] +pub fn animation_step(from: f32, to: f32, elapsed_ms: f32, duration_ms: f32) -> (f32, bool) { + if duration_ms <= 0.0 || elapsed_ms >= duration_ms { + return (to, true); + } + let t = (elapsed_ms / duration_ms).clamp(0.0, 1.0); + (from + (to - from) * ease_out_cubic(t), false) +} + +/// Whether motion should be animated at all. +/// +/// Not derived from a CSS media query: Stylo/Blitz expose no +/// `prefers-reduced-motion` to query, so this is carried explicitly and set +/// from the platform accessibility setting once `DeviceProfile` grows the probe +/// (`TODO(device-profile)` there). Defaulting to [`Self::Full`] keeps today's +/// behaviour; a user or platform that asks for reduced motion gets instant +/// scrolls, which is the correct degradation — the scroll still happens, it +/// just does not animate. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum MotionPreference { + /// Animate smooth scrolls. + #[default] + Full, + /// Perform every scroll instantly. + Reduced, +} + +impl MotionPreference { + /// `true` when a smooth request should be honoured as an animation. + #[must_use] + pub fn animates(self) -> bool { + self == Self::Full + } +} + +#[cfg(test)] +#[path = "animate_tests.rs"] +mod tests; diff --git a/appthere-ui/src/scroll/animate_tests.rs b/appthere-ui/src/scroll/animate_tests.rs new file mode 100644 index 00000000..6b1c7d6f --- /dev/null +++ b/appthere-ui/src/scroll/animate_tests.rs @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for the smooth-scroll easing and stepper. + +use super::{animation_step, ease_out_cubic, MotionPreference}; + +#[test] +fn easing_spans_zero_to_one() { + assert_eq!(ease_out_cubic(0.0), 0.0); + assert_eq!(ease_out_cubic(1.0), 1.0); +} + +#[test] +fn easing_is_clamped_outside_the_unit_interval() { + // A tick can arrive late enough that t > 1; it must not overshoot past the + // target, which would look like a bounce. + assert_eq!(ease_out_cubic(1.7), 1.0); + assert_eq!(ease_out_cubic(-0.4), 0.0); +} + +#[test] +fn easing_is_monotonic_and_front_loaded() { + let mut prev = 0.0; + for i in 0..=10 { + let v = ease_out_cubic(i as f32 / 10.0); + assert!(v >= prev, "easing must not go backwards"); + prev = v; + } + // Ease-*out*: more than half the distance is covered in the first half of + // the time. This is what makes the scroll feel like a response. + assert!(ease_out_cubic(0.5) > 0.5); +} + +#[test] +fn step_at_zero_is_the_start_position() { + let (pos, done) = animation_step(100.0, 500.0, 0.0, 180.0); + assert_eq!(pos, 100.0); + assert!(!done); +} + +#[test] +fn step_snaps_exactly_to_the_target_when_finished() { + // Not "close to 500" — exactly 500. A residue leaves reveal_offset asking + // for the same scroll on every subsequent caret move. + let (pos, done) = animation_step(100.0, 500.0, 180.0, 180.0); + assert_eq!(pos, 500.0); + assert!(done); +} + +#[test] +fn a_late_tick_finishes_rather_than_overshooting() { + let (pos, done) = animation_step(100.0, 500.0, 10_000.0, 180.0); + assert_eq!(pos, 500.0); + assert!(done); +} + +#[test] +fn zero_duration_completes_immediately() { + let (pos, done) = animation_step(0.0, 42.0, 0.0, 0.0); + assert_eq!(pos, 42.0); + assert!(done); +} + +#[test] +fn upward_scrolls_interpolate_the_same_way() { + // Guards against an implementation that assumes to > from. + let (pos, done) = animation_step(500.0, 100.0, 90.0, 180.0); + assert!(!done); + assert!(pos < 500.0 && pos > 100.0, "got {pos}"); +} + +#[test] +fn reduced_motion_does_not_animate() { + assert!(MotionPreference::Full.animates()); + assert!(!MotionPreference::Reduced.animates()); + assert_eq!(MotionPreference::default(), MotionPreference::Full); +} diff --git a/appthere-ui/src/scroll/controller.rs b/appthere-ui/src/scroll/controller.rs new file mode 100644 index 00000000..53f59764 --- /dev/null +++ b/appthere-ui/src/scroll/controller.rs @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! [`ViewportController`] — the one handle on a scroll container (Spec 08 T1.2). + +use std::time::{Duration, Instant}; + +use dioxus::html::geometry::PixelsVector2D; +use dioxus::prelude::*; + +use super::animate::{animation_step, MotionPreference, SMOOTH_DURATION_MS, TICK_MS}; +use super::metrics::ScrollMetrics; +use super::reveal::{reveal_offset, RevealMargin}; + +/// A target rect in **content** coordinates: `(x, y, width, height)`. +pub type ContentRect = (f32, f32, f32, f32); + +/// Owns everything a caller needs to observe and drive one scroll container. +/// +/// Deliberately built **on** the existing `ScrollMetrics` signal rather than +/// beside it: the metrics a scrollbar already mirrors from `onscroll` are the +/// same numbers a reveal needs, and a second source would recreate the +/// divergence Spec 01 audit A-1 removed from viewport width (S0.1 §3). +/// +/// # A command must never subscribe to scroll state (L08-019) +/// +/// This is the rule I-20 was created by breaking. `scroll_to_reveal` read the +/// metrics signal reactively, so the caret-follow effect that called it became +/// a subscriber to every scroll event. Turning the wheel then re-ran the +/// effect, which recomputed the caret's position *relative to the new scroll +/// offset*, found it outside the reveal margin — because the user had just +/// scrolled it there — and scrolled back. The wheel was capped at the margin +/// band around the caret, and the cap was asymmetric (one line up, three down) +/// because the margin is. +/// +/// Nothing in the type system stops that recurring, so the discipline is: +/// **observation methods read, command methods peek.** `metrics` and +/// `visible_rect` are the observers and say so; every command path goes +/// through [`Self::metrics_now`]. If a command ever needs a value not exposed +/// that way, add a peeking accessor rather than reaching for the reactive one. +/// +/// The deeper guarantee lives at the call site: a reveal fires on *caret +/// revision change*, never on anything derived from scroll position. Removing +/// the subscription stops the loop; keying the trigger on caret identity means +/// a future subscription slipping back in cannot restart it. +/// +/// `Copy`, so it threads through render functions like any other signal bundle. +#[derive(Clone, Copy)] +pub struct ViewportController { + metrics: Signal, + mounted: Signal>, + /// Bumped on every new scroll command. An in-flight animation compares this + /// against the generation it started with and exits when superseded, so a + /// second reveal replaces the first instead of fighting it (T1.1). + generation: Signal, + motion: Signal, +} + +/// Creates a [`ViewportController`] over an existing metrics signal and the +/// `MountedData` captured from the container's `onmounted`. +/// +/// Call once per container, in a component. +pub fn use_viewport_controller( + metrics: Signal, + mounted: Signal>, +) -> ViewportController { + let generation = use_signal(|| 0_u64); + let motion = use_signal(MotionPreference::default); + ViewportController { + metrics, + mounted, + generation, + motion, + } +} + +impl ViewportController { + /// The live scroll geometry, as a **reactive** read. + /// + /// Calling this inside a `use_effect` subscribes that effect to every + /// scroll event. That is correct for an observer — a scroll indicator, a + /// page counter — and catastrophic for anything that issues a scroll. See + /// the type docs; commands use [`Self::metrics_now`]. + #[must_use] + pub fn metrics(&self) -> ScrollMetrics { + *self.metrics.read() + } + + /// The visible region in content coordinates, `(x, y, width, height)`, as a + /// **reactive** read. Same subscription caveat as [`Self::metrics`]. + #[must_use] + pub fn visible_rect(&self) -> ContentRect { + self.metrics.read().visible_rect() + } + + /// The current scroll geometry **without subscribing** — the only accessor + /// a command path may use (L08-019). + fn metrics_now(&self) -> ScrollMetrics { + *self.metrics.peek() + } + + /// Sets the motion preference (see [`MotionPreference`]). + pub fn set_motion(&mut self, preference: MotionPreference) { + self.motion.set(preference); + } + + /// Cancels any in-flight smooth scroll, leaving the offset where it is. + /// + /// Called when the user takes the wheel — literally. An animation that + /// keeps running through a user gesture is the "fighting the user" + /// failure T1.4 exists to prevent. + pub fn cancel(&mut self) { + *self.generation.write() += 1; + } + + /// Scrolls so `rect` is visible with `margin` of clear space around it. + /// + /// A no-op when the rect is already visible with its margins intact, when + /// the container has not been measured, or when the container cannot + /// scroll. Returns `true` if a scroll was issued — callers use this to + /// avoid logging or reacting to reveals that did nothing. + pub fn scroll_to_reveal( + &mut self, + rect: ContentRect, + margin: RevealMargin, + behavior: ScrollBehavior, + ) -> bool { + let m = self.metrics_now(); + if !m.is_measured() { + return false; + } + let (x, y, w, h) = rect; + let target_y = reveal_offset(m.scroll_top, m.client_height, m.scroll_height, y, h, margin); + let target_x = reveal_offset( + m.scroll_left, + m.client_width, + m.scroll_width, + x, + w, + RevealMargin::default(), + ); + match (target_x, target_y) { + (None, None) => false, + (nx, ny) => { + self.scroll_to( + nx.unwrap_or(m.scroll_left), + ny.unwrap_or(m.scroll_top), + behavior, + ); + true + } + } + } + + /// Scrolls to an absolute offset. + /// + /// `Instant` applies immediately. `Smooth` animates unless the motion + /// preference is [`MotionPreference::Reduced`], in which case it degrades + /// to instant — the scroll still happens, it just does not animate. + pub fn scroll_to(&mut self, x: f32, y: f32, behavior: ScrollBehavior) { + // Every command supersedes an in-flight animation, including an + // instant one: otherwise a keystroke's instant reveal would be undone + // by the tail of a smooth scroll still running underneath it. + self.cancel(); + let smooth = behavior == ScrollBehavior::Smooth && self.motion.peek().animates(); + if !smooth { + self.apply(x, y); + return; + } + let m = self.metrics_now(); + self.animate(m.scroll_left, m.scroll_top, x, y); + } + + /// Issues one instant scroll through the mounted container. + fn apply(&self, x: f32, y: f32) { + let guard = self.mounted.peek(); + let Some(mounted) = guard.as_ref() else { + return; // container not mounted yet + }; + // The patched backing performs the scroll eagerly (it posts the event + // before returning a ready future), so dropping the future here is + // correct — the same call shape the scrollbar thumb drag uses. + drop(mounted.scroll( + PixelsVector2D::new(f64::from(x), f64::from(y)), + ScrollBehavior::Instant, + )); + } + + /// Drives a smooth scroll from `(fx, fy)` to `(tx, ty)`. + /// + /// Ticks arrive from a worker thread through a channel — there is no async + /// timer under `dioxus-native` (see the [`super::animate`] module docs). + /// The loop exits early the moment `generation` moves, so a superseding + /// command takes effect on the next tick rather than after this animation + /// finishes. + fn animate(&mut self, fx: f32, fy: f32, tx: f32, ty: f32) { + let me = *self; + let epoch = *self.generation.peek(); + let (tick_tx, mut tick_rx) = futures_channel::mpsc::unbounded::<()>(); + let spawned = std::thread::Builder::new() + .name("at-scroll-anim".into()) + .spawn(move || { + // Bounded by construction: duration / interval + slack. + let ticks = (SMOOTH_DURATION_MS as u64 / TICK_MS) + 2; + for _ in 0..ticks { + std::thread::sleep(Duration::from_millis(TICK_MS)); + if tick_tx.unbounded_send(()).is_err() { + break; // receiver dropped — animation superseded + } + } + }); + if spawned.is_err() { + me.apply(tx, ty); // no thread available: land on the target anyway + return; + } + let start = Instant::now(); + spawn(async move { + use futures_util::StreamExt; + while tick_rx.next().await.is_some() { + if *me.generation.peek() != epoch { + return; // superseded or cancelled + } + let elapsed = start.elapsed().as_secs_f32() * 1000.0; + let (x, done) = animation_step(fx, tx, elapsed, SMOOTH_DURATION_MS); + let (y, _) = animation_step(fy, ty, elapsed, SMOOTH_DURATION_MS); + me.apply(x, y); + if done { + return; + } + } + }); + } +} diff --git a/appthere-ui/src/scroll/metrics.rs b/appthere-ui/src/scroll/metrics.rs new file mode 100644 index 00000000..35e118d3 --- /dev/null +++ b/appthere-ui/src/scroll/metrics.rs @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! [`ScrollMetrics`] — the live geometry of a scroll container. + +/// Live scroll geometry for a scroll container, mirrored from the most recent +/// DOM `scroll` event. All values are logical (CSS) pixels. +/// +/// # `scroll_width` / `scroll_height` are distances, not sizes +/// +/// The DOM `scroll` event Loki receives (PATCH(loki) in `dioxus-native-dom`) +/// reports Taffy geometry, where these two are the **scrollable distance** +/// (content − client), *not* the total content size. Total content size is +/// therefore `client + max_scroll`. Getting this backwards silently halves or +/// doubles every derived figure, so the accessors below are the intended way +/// to read it. +/// +/// Defaults to all-zero, which callers treat as "not measured yet" — the first +/// scroll event (or the shell's post-resize replay) corrects it. +/// +/// Moved here from `loki-text` so the scrollbar, the caret-follow controller, +/// and any future consumer read one type rather than three copies of six +/// `f32`s (Spec 08 S0.1 §3 — the same single-source rule Spec 01 audit A-1 +/// applied to viewport width). +#[derive(Clone, Copy, PartialEq, Default, Debug)] +pub struct ScrollMetrics { + /// Current vertical scroll offset. + pub scroll_top: f32, + /// Current horizontal scroll offset. + pub scroll_left: f32, + /// Horizontal scrollable **distance** (content width − client width). + pub scroll_width: f32, + /// Vertical scrollable **distance** (content height − client height). + pub scroll_height: f32, + /// Visible width of the container. + pub client_width: f32, + /// Visible height of the container. + pub client_height: f32, +} + +impl ScrollMetrics { + /// `true` once a real scroll event has sized the container. An unmeasured + /// container has no meaningful visible rect, so reveal requests against it + /// are dropped rather than guessed. + #[must_use] + pub fn is_measured(&self) -> bool { + self.client_height > 0.0 || self.client_width > 0.0 + } + + /// `true` when the content can be scrolled horizontally. + #[must_use] + pub fn can_scroll_x(&self) -> bool { + self.client_width > 0.0 && self.scroll_width > 0.5 + } + + /// `true` when the content can be scrolled vertically. + #[must_use] + pub fn can_scroll_y(&self) -> bool { + self.client_height > 0.0 && self.scroll_height > 0.5 + } + + /// Total content height in logical pixels (`client + max_scroll`). + #[must_use] + pub fn content_height(&self) -> f32 { + self.client_height + self.scroll_height + } + + /// Total content width in logical pixels (`client + max_scroll`). + #[must_use] + pub fn content_width(&self) -> f32 { + self.client_width + self.scroll_width + } + + /// The currently visible region in **content** coordinates, as + /// `(x, y, width, height)`. + /// + /// This is the rect that `scroll_to_reveal` measures a target against: the + /// caret's document position is in the same space, so "is the caret + /// visible" is a plain containment test with no transform in between. + #[must_use] + pub fn visible_rect(&self) -> (f32, f32, f32, f32) { + ( + self.scroll_left, + self.scroll_top, + self.client_width, + self.client_height, + ) + } +} + +#[cfg(test)] +#[path = "metrics_tests.rs"] +mod tests; diff --git a/appthere-ui/src/scroll/metrics_tests.rs b/appthere-ui/src/scroll/metrics_tests.rs new file mode 100644 index 00000000..17d76af7 --- /dev/null +++ b/appthere-ui/src/scroll/metrics_tests.rs @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for [`super::ScrollMetrics`]. + +use super::ScrollMetrics; + +fn sample() -> ScrollMetrics { + ScrollMetrics { + scroll_top: 120.0, + scroll_left: 30.0, + scroll_width: 400.0, + scroll_height: 9100.0, + client_width: 600.0, + client_height: 900.0, + } +} + +#[test] +fn default_is_unmeasured() { + let m = ScrollMetrics::default(); + assert!(!m.is_measured()); + assert!(!m.can_scroll_x()); + assert!(!m.can_scroll_y()); +} + +#[test] +fn content_size_adds_client_to_the_scrollable_distance() { + // The invariant the module docs warn about: scroll_height is a distance, + // so content is client + distance. If this ever reads 9100 instead of + // 10000, every derived figure in the scrollbar and the reveal is wrong. + let m = sample(); + assert_eq!(m.content_height(), 10_000.0); + assert_eq!(m.content_width(), 1_000.0); +} + +#[test] +fn visible_rect_is_in_content_coordinates() { + let m = sample(); + assert_eq!(m.visible_rect(), (30.0, 120.0, 600.0, 900.0)); +} + +#[test] +fn axis_scrollability_needs_more_than_half_a_pixel() { + // Sub-pixel slack between content and client is not "scrollable"; treating + // it as such makes the scrollbar appear on content that fits. + let m = ScrollMetrics { + client_width: 600.0, + client_height: 900.0, + scroll_width: 0.25, + scroll_height: 0.25, + ..Default::default() + }; + assert!(m.is_measured()); + assert!(!m.can_scroll_x()); + assert!(!m.can_scroll_y()); +} diff --git a/appthere-ui/src/scroll/mod.rs b/appthere-ui/src/scroll/mod.rs new file mode 100644 index 00000000..adb483ac --- /dev/null +++ b/appthere-ui/src/scroll/mod.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Scroll observation and control for a document container (Spec 08 Phase 1). +//! +//! # Why this lives in `appthere-ui` +//! +//! Every AppThere app has a scrollable document surface with the same three +//! needs: know where it is, know what is visible, and put something on screen. +//! Before this module those were three separate ad-hoc answers inside +//! `loki-text`. Per L08-005, shared UI primitives live here. +//! +//! # What Blitz gives us, and what it does not +//! +//! Spike S0.1 (`docs/spikes/S0.1-blitz-scroll-capability.md`) found that five +//! of the six scroll capabilities Spec 08 needs already ship in the vendored +//! patch set: +//! +//! - reading the offset, and subscribing to changes — the PATCH(loki) chain +//! `scroll_node_by_collect` → `Document::handle_scroll_changes` → a DOM +//! `scroll` event, mirrored into [`ScrollMetrics`]; +//! - the visible rect — the same event, plus `MountedData::get_client_rect`; +//! - instant programmatic scroll — `MountedData::scroll`; +//! - absolute-positioned overlays — already proven by the spelling popup. +//! +//! The missing one is **animated** programmatic scroll: `MountedData::scroll` +//! ignores its `ScrollBehavior` and `scroll_to` is a no-op. Per S0.1 that is +//! closed app-side, in [`animate`] and [`ViewportController::animate`], rather +//! than with another Blitz patch. +//! +//! # Shape +//! +//! | Module | Role | +//! | --- | --- | +//! | [`metrics`] | live geometry of the container | +//! | [`reveal`] | pure "what offset shows this rect" arithmetic | +//! | [`animate`] | easing + the motion preference | +//! | [`controller`] | the Dioxus-facing handle | +//! +//! The arithmetic is deliberately separate from the Dioxus surface so it is +//! unit-tested without a window — the same split `responsive::page_fit` uses. + +mod animate; +mod controller; +mod metrics; +mod reveal; + +pub use animate::{animation_step, ease_out_cubic, MotionPreference}; +pub use controller::{use_viewport_controller, ContentRect, ViewportController}; +pub use metrics::ScrollMetrics; +pub use reveal::{reveal_offset, RevealMargin}; diff --git a/appthere-ui/src/scroll/reveal.rs b/appthere-ui/src/scroll/reveal.rs new file mode 100644 index 00000000..3bd9ad85 --- /dev/null +++ b/appthere-ui/src/scroll/reveal.rs @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Pure reveal geometry: what scroll offset brings a target rect into view. +//! +//! Separated from the Dioxus-facing controller so the arithmetic is unit-tested +//! headlessly — the same split `responsive::page_fit` and +//! `loki_renderer::virtualize` use. + +/// How much clear space to keep around a revealed target, in logical pixels. +/// +/// Named `leading` / `trailing` rather than `above` / `below` because the same +/// type serves both axes. For caret-follow the caller derives these from the +/// **live body-style line height**, not from a pixel constant (Spec 08 T1.3): +/// three lines of trailing space is a different number at 12 pt and at 24 pt, +/// and a hardcoded margin would be wrong at every zoom but one. +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub struct RevealMargin { + /// Space kept before the target — above it vertically, left of it + /// horizontally. + pub leading: f32, + /// Space kept after the target — below it vertically, right of it + /// horizontally. + pub trailing: f32, +} + +impl RevealMargin { + /// A margin of `leading` and `trailing` logical pixels. + #[must_use] + pub fn new(leading: f32, trailing: f32) -> Self { + Self { leading, trailing } + } + + /// The caret-follow default expressed in lines: one line of clearance above + /// and three below, so the next few lines the user is about to type are + /// already on screen (Spec 08 T1.3). + #[must_use] + pub fn caret_lines(line_height_px: f32) -> Self { + Self { + leading: line_height_px, + trailing: line_height_px * 3.0, + } + } +} + +/// The scroll offset along one axis that reveals `[target_start, target_start + +/// target_len]` plus `margin`, or `None` when no scroll is needed. +/// +/// All values are in **content** coordinates on that axis. `current` is the +/// present offset, `client` the visible extent, `max_scroll` the scrollable +/// distance (see [`super::ScrollMetrics`] — a distance, not a size). +/// +/// Behaviour: +/// +/// - Already fully visible, margins included → `None`. This is what keeps +/// typing in the middle of the page from scrolling at all. +/// - Target below the fold → scroll the **minimum** distance that brings its +/// trailing margin to the bottom edge. +/// - Target above the fold → scroll the minimum distance that brings its +/// leading margin to the top edge. +/// - The result is clamped to `[0, max_scroll]`, and a clamp that lands back on +/// `current` returns `None` rather than a no-op scroll — otherwise a caret on +/// the last line would re-issue a scroll on every keystroke forever. +/// +/// When the target plus its margins is taller than the viewport, the target's +/// **leading** edge wins: the caret itself must stay visible even if the +/// trailing lines cannot. +#[must_use] +pub fn reveal_offset( + current: f32, + client: f32, + max_scroll: f32, + target_start: f32, + target_len: f32, + margin: RevealMargin, +) -> Option { + if client <= 0.0 { + return None; // unmeasured container — nothing to reveal into + } + + let desired_min = target_start - margin.leading; + let desired_max = target_start + target_len.max(0.0) + margin.trailing; + + let wanted = if desired_max - desired_min > client { + // Cannot satisfy both edges. Anchor the leading edge so the target + // itself is on screen; the trailing margin is the part we give up. + desired_min + } else if desired_max > current + client { + desired_max - client + } else if desired_min < current { + desired_min + } else { + return None; // already visible with margins intact + }; + + let clamped = wanted.clamp(0.0, max_scroll.max(0.0)); + // Sub-pixel differences are not worth a scroll event; they also produce the + // keystroke-loop described above. + if (clamped - current).abs() < 0.5 { + None + } else { + Some(clamped) + } +} + +#[cfg(test)] +#[path = "reveal_tests.rs"] +mod tests; diff --git a/appthere-ui/src/scroll/reveal_tests.rs b/appthere-ui/src/scroll/reveal_tests.rs new file mode 100644 index 00000000..1031e082 --- /dev/null +++ b/appthere-ui/src/scroll/reveal_tests.rs @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for [`super::reveal_offset`]. Extracted per the file-ceiling idiom. + +use super::{reveal_offset, RevealMargin}; + +/// A 900 px viewport over 10 000 px of content: max_scroll = 9100. +const CLIENT: f32 = 900.0; +const MAX: f32 = 9100.0; + +fn no_margin() -> RevealMargin { + RevealMargin::default() +} + +#[test] +fn fully_visible_target_does_not_scroll() { + // Caret mid-viewport — the common case while typing. Must be a no-op, or + // every keystroke would jitter the page. + assert_eq!( + reveal_offset(1000.0, CLIENT, MAX, 1400.0, 20.0, no_margin()), + None + ); +} + +#[test] +fn target_below_the_fold_scrolls_the_minimum() { + // Visible [1000, 1900). Target at 1950..1970 needs the bottom edge at 1970. + let out = reveal_offset(1000.0, CLIENT, MAX, 1950.0, 20.0, no_margin()); + assert_eq!(out, Some(1970.0 - CLIENT)); +} + +#[test] +fn target_above_the_fold_scrolls_the_minimum() { + // Visible [1000, 1900). Target at 800 needs the top edge at 800. + let out = reveal_offset(1000.0, CLIENT, MAX, 800.0, 20.0, no_margin()); + assert_eq!(out, Some(800.0)); +} + +#[test] +fn trailing_margin_is_kept_below_the_target() { + // The caret-follow case: a caret at the very bottom of the viewport is + // technically visible, but the three trailing lines are not, so we scroll. + let margin = RevealMargin::caret_lines(20.0); // 20 above, 60 below + let caret_top = 1000.0 + CLIENT - 20.0; // last line of the visible band + let out = reveal_offset(1000.0, CLIENT, MAX, caret_top, 20.0, margin); + let expected = caret_top + 20.0 + 60.0 - CLIENT; + assert_eq!(out, Some(expected)); + // And the caret keeps at least its 60 px (3 lines) of trailing space. + let new_bottom = expected + CLIENT; + assert!(new_bottom - (caret_top + 20.0) >= 60.0); +} + +#[test] +fn leading_margin_is_kept_above_the_target() { + let margin = RevealMargin::caret_lines(20.0); + // Caret just below the top edge: visible, but with no line above it. + let out = reveal_offset(1000.0, CLIENT, MAX, 1005.0, 20.0, margin); + assert_eq!(out, Some(1005.0 - 20.0)); +} + +#[test] +fn clamps_at_the_top_of_the_document() { + // A caret on line 1 wants to scroll above 0; the clamp holds it at 0. + let margin = RevealMargin::caret_lines(20.0); + let out = reveal_offset(50.0, CLIENT, MAX, 10.0, 20.0, margin); + assert_eq!(out, Some(0.0)); +} + +#[test] +fn clamps_at_the_bottom_and_then_stops_asking() { + let margin = RevealMargin::caret_lines(20.0); + // Caret on the very last line, already scrolled to the end. The desired + // offset exceeds max_scroll, clamps back to where we already are, and must + // therefore report "no scroll needed" — not a scroll to the same place. + let at_end = MAX; + let caret_top = at_end + CLIENT - 30.0; + let out = reveal_offset(at_end, CLIENT, MAX, caret_top, 20.0, margin); + assert_eq!(out, None, "a clamped no-op must not re-issue a scroll"); +} + +#[test] +fn target_taller_than_the_viewport_anchors_its_leading_edge() { + // A selection (or a caret whose margins exceed the viewport) that cannot + // fit: the top of the target must win, so the caret stays on screen. + let out = reveal_offset(0.0, CLIENT, MAX, 2000.0, 2000.0, no_margin()); + assert_eq!(out, Some(2000.0)); +} + +#[test] +fn margins_exceeding_the_viewport_still_show_the_caret() { + // Pathological: 3 lines of trailing space at a huge line height. + let margin = RevealMargin::caret_lines(400.0); // 400 + 1200 > 900 + let out = reveal_offset(0.0, CLIENT, MAX, 3000.0, 20.0, margin); + assert_eq!(out, Some(3000.0 - 400.0)); +} + +#[test] +fn unmeasured_container_is_a_no_op() { + // Before the first scroll event the client size is 0; a reveal request must + // be dropped, not acted on with a guessed viewport. + assert_eq!( + reveal_offset(0.0, 0.0, 0.0, 5000.0, 20.0, no_margin()), + None + ); +} + +#[test] +fn sub_pixel_differences_do_not_scroll() { + // Visible [1000, 1900); target ends 0.2 px past the fold. Not worth a + // scroll event, and issuing one would loop on every keystroke. + let out = reveal_offset(1000.0, CLIENT, MAX, 1880.0, 20.2, no_margin()); + assert_eq!(out, None); +} + +#[test] +fn non_scrollable_content_never_scrolls() { + // Content shorter than the viewport: max_scroll is 0, so every request + // clamps to 0 and a caret already at 0 gets None. + assert_eq!( + reveal_offset(0.0, CLIENT, 0.0, 500.0, 20.0, no_margin()), + None + ); +} + +#[test] +fn horizontal_axis_behaves_identically() { + // The function is axis-agnostic; this pins that it is genuinely reusable + // for the wide-page pan case rather than vertical-only by accident. + let out = reveal_offset(0.0, 600.0, 400.0, 700.0, 10.0, RevealMargin::new(0.0, 0.0)); + assert_eq!(out, Some(110.0)); +} diff --git a/docs/spikes/README.md b/docs/spikes/README.md new file mode 100644 index 00000000..2922cd22 --- /dev/null +++ b/docs/spikes/README.md @@ -0,0 +1,172 @@ + + +# Spike findings + +Investigation documents produced by a spec's Phase 0. No production code; each +document is evidence for a decision taken later. + +## Loki Spec 09 — Layout Memory + +| ID | Document | Answers | Verdict | +| --- | --- | --- | --- | +| S09.0 | [Layout residency census](S09.0-layout-residency-census.md) | Spec 09 §3 Q1–Q7 **and E0** | **45–63% of layout residency is evictable for text-bearing documents; object-heavy content is far higher (98%).** Body text runs 123 B/char, 69 of it evictable, flat across a 25× size change. The *fraction* is the durable number — it moves by under 2× where the rate moves 60×. Eviction is **safe** (layout is a pure function of the CRDT) but not *representable*: `None` already means "read-only", so an evicted page would read as a silently wrong answer. Checkpoint recovery exists but only at clean page tops. Glyph items are stored **three** times — sharing one allocation removes ~26% with no eviction machinery | + +**E0 has been run** (S09.0 §10), so Spec 09 L9-005 is satisfied and the phase +plan is unblocked. Spec 09 §4 describes E0 as a manual RSS comparison needing +real hardware; it does not — layout is CPU-only, so it runs headless under dhat, +which also disposes of both methodological caveats §4 raises. It is committed as +`loki-bench/benches/layout_editing_residency.rs` and doubles as the regression +guard for the steps that follow. + +The census's headline figure survived contact with the instrument (predicted 72 +B/char, measured 70.1, flat across a 4× document-size change). Its *total* did +not, and the 36 B/char gap led to the cheapest win on the list. + +Then E0 was pointed at the conformance corpus — and chasing an inconsistency in +the result found that **the instrument was order-dependent**: one-time costs, +font loading above all, were billed to whichever measurement ran first. Warm, +almost every corpus figure changed, by up to **252×**, and the "floor artefact at +4.5k characters" turned out not to exist. Corrected numbers in S09.0 §10a: real +formatting costs 1.7× the synthetic rate (not 2.5×), and the evictable band is +**45–63% for text-bearing documents** with object-heavy content far higher. +Spec 09 should still target the fraction — but note it is a property of +*documents*, not a goal for us; what we control is how much of it we reclaim. + +The failed ×10 experiment then paid for itself. Repetition cannot vary size at +constant formatting (it changes the cache-hit profile), but run as a sweep at +×1/×2/×5/×10 it decomposes residency into **~78 B/char keyed to paragraph +content and ~39 B/char paid per placement**, with residuals under 0.04 B/char. +That sizes S9-1 from measurement rather than struct arithmetic, and means +boilerplate-heavy documents deduplicate for free (S09.0 §10b). + +**S9-1 has shipped** (S09.0 §10c prediction, §10d result). `ParaCache` now holds +`Arc`, so the cache and the page editing index share one +allocation: body-text editing residency **69.4 → 34.8 B/char**, total +**123.3 → 89.0** (−27.8%), with `C` unchanged at 78.2 and `P` collapsing +39.3 → 1.1 — exactly the split predicted before the code was written, per +L9-013. The total now lands on the census's *original* ~88 B/char prediction, +which the extra copy had been hiding. The prediction protocol earned its keep +before the measurement did: deriving it found that §10b's account of which +coefficient S9-1 would move was wrong, and corrected it ahead of the work. The +run also caught what the prediction missed — read-only residency rose ~11 B/char, +because the deep `clone` into the cache had been compacting glyph vectors as a +side effect nothing had named. + +**S9-2 has shipped too** (§10f prediction, §10g result). `ByteIndexMap` — `u32` +entries plus an `Identity` variant — took body text to **73.0 B/char total, +34.8 editing**, against 124 and 69.4 when E0 first ran: **41% off total +residency across the two steps**, no eviction machinery, no contract change. The +prediction that mattered here was the *null* one: C and P were predicted not to +move, and did not, because after S9-1 the maps live in one place and appear in +both measured conditions. A harness reporting only the duplication sweep would +have called S9-2 a no-op while total residency fell 18%. + +**Three follow-ups landed with S9-2's review** (S09.0 §10h, §10i). R9-15 is now +measured rather than recorded: E0 has a CJK tier, and **CJK costs ~3× Latin per +character** (111.7 vs 34.8 B/char editing) while the evictable fraction stays +inside the text-bearing band (50.9% vs 47.7%) — so the rate does not transfer +across scripts and the fraction does, the same split the duplication sweep found +on an independent axis. The tier fails rather than prints if no CJK face resolves, +since tofu shapes into a perfectly believable number. L9-016's `Arc::get_mut` ban +is a CI gate (`scripts/check-arc-get-mut.py`), verified by negative test to fail +on a real call and pass on prose about one. And S9-3's governing metric is +derived as **page-access-set bounds** rather than C and P — which found that the +scan inventory is four sites, not one, and that the worst of them +(`recompute_page_index`) starts at page 0 and runs on *every keystroke*. + +Three of those follow-ups then produced results worth having. **R9-16 — the +per-byte hypothesis — is refuted by its own discriminator** (§10j): a +Cyrillic+Greek tier at 1.85 bytes/char reads 88.1 B/char where per-byte predicts +135.1, and per source byte the three scripts read 73.0 / 47.7 / 74.7 rather than +agreeing. The CJK/Latin match was a two-point coincidence. Neither characters nor +bytes are invariant — but the **evictable fraction is, across all three scripts +(47.7 / 50.4 / 50.9%)**, now the third independent axis supporting L9-008. And +**the per-keystroke scan is measured** (§10l): 3.3 µs at 445 pages, 13.6 µs at +889, so it is not a present-day latency defect and S9-3 stays architecture — but +a characterisation test showed the `visible` exit **does** fire on split-page +geometry, so **R9-18 is over-generalised rather than false** — it survives with a +geometry qualifier, since for the bench's byte-0 single-page probes the original +evidence still holds. The access-set claim was bundled with it and survives +independently (cost is not proportional to `M` but is superlinear in `N`), and +carries forward as **R9-19**. Geometry, not caret position, is the variable that +selects the path, and the bench swept the wrong axis: a four-case geometry sweep +discriminates both, and **the keystroke path is neither geometry yet measured** — +typing is mid-paragraph at arbitrary offsets, which Q4 makes the common case in +prose, so R9-19's `N` prior may be pessimistic for exactly the path that matters. + +Both refuted claims here — the per-byte denominator and the never-firing exit — +share one shape (§10m, L9-018): each crossed an observable domain boundary +without an observation in the target domain, inferring unit invariance from byte +counts and control flow from time. Coherence in the domain you measured says +nothing about the domain you are concluding about. Findings in both specs are now +recorded in the three-way **Observed / Not established / What would settle it** +form (L9-019) so the gap is visible when the claim is written, not after it is +corrected. + +## Loki Spec 08 — UX & Memory Remediation Program, Phase 0 + +| ID | Document | Gates | Verdict | +| --- | --- | --- | --- | +| S0.1 | [Blitz scroll capability](S0.1-blitz-scroll-capability.md) | Phases 1, 2, 7 | 5 of 6 capabilities already exist and ship today. R1 closed | +| S0.2 | [Render pipeline and memory census](S0.2-render-memory-census.md) | Phase 2 | Textures are already windowed; the unbounded axis is zoom×DPI, not page count. Phase 2's acceptance criterion needs restating | +| S0.3 | [Coordinate-space audit](S0.3-coordinate-space-audit.md) | T1.3, T3.1, T5.5 | Chain documented; §3.3's stated cause for I-06 is wrong; a better candidate identified | +| S0.4 | [IME patch archaeology](S0.4-ime-patch-archaeology.md) | T3.2 | Patch intact; the **Android build of `loki-text` is broken** by a duplicated entry point. R6 closed | +| S0.5 | [Page style format study](S0.5-page-style-format-study.md) | Phase 6 | §3.4 confirmed; most of T6a/T6b already built under ADR-0012. Phase 6 is much smaller than scoped | +| S0.6 | [Device capability probe](S0.6-device-capability-probe.md) | T1.6 and all later responsive work | 11 behavioural `cfg(target_os)` sites, all enumerated. R13 closed | + +### Exit criteria + +Spec §4 Phase 0 requires six findings documents and confirmation that the §7 +decisions still hold. Both are met. All eight decisions survive the findings +unchanged; four gain a consequence: + +| Decision | Status | Consequence from Phase 0 | +| --- | --- | --- | +| D-01 patch locally, PR upstream | **Holds** | Cheaper than assumed — S0.1 finds no new patch is needed for scroll. The one new patch candidate is input-device events for pointer precision (S0.6 §4) | +| D-02 page-style names in a custom part | **Holds** | S0.5 §4 identifies `docx/write/custom_props.rs` as the worked example to copy | +| D-03 OS measurement system → locale → metric | **Holds** | S0.6 §4 folds the probe into `DeviceProfile`'s plumbing | +| D-04 calibrate on first use of Actual Size | **Holds** | S0.6 §4 confirms R7: physical display size is frequently absent or wrong, so calibration is the primary path as D-04 assumes | +| D-05 character-based measure | **Holds** | `reflow_metrics.rs` already has a pixel cap (`MAX_REFLOW_TILE_PX`) to replace | +| D-06 extract `appthere-color-ui` | **Holds, with a correction** | **`appthere-color` does not exist.** The picker lives in `appthere-ui/src/components/color_picker/` (`mod.rs`, `custom.rs`, `convert.rs`). T5.1 must create *both* crates, or restate D-06 as "extract the existing `appthere-ui` picker into a standalone pair". This is the one §7 decision written against a component that is not there | +| D-07 styles document-scoped, defaults app-scoped | **Holds** | Document half already implemented (S0.5 §1); the application-scoped half is new | +| D-08 budget derived per device at runtime | **Holds** | S0.6 confirms the violation set is small; S0.2 §5 finds the largest violation is the Android renderer path, not the budget | + +### Recommended changes to the spec before Phase 1 starts + +1. **Phase 2 acceptance (§4).** Replace "peak RSS for a 500-page document within + 20% of a 10-page document" with a resident-texture-bytes bound. Texture + residency is already document-length-independent; the RSS difference between + those documents is layout and editing data, which Phase 2 as scoped does not + touch. Add the layout tail as a new issue rather than absorbing it silently. + (S0.2 §4, §6.) +2. **Add an issue for the broken Android build.** S0.4 finds a compile failure, + not the behavioural regression I-07 describes. It should be tracked and fixed + ahead of Phase 3 rather than inside it, since nothing on Android can be + verified until it builds. +3. **Rescope Phase 6 (§4).** T6a.1, T6a.2, T6a.3, T6b.1, T6b.2, T6b.3 and most + of T6b.5 are already implemented. The real backlog is `style:page-usage`, + the page-size catalogue, the EMU migration of the page family only, + application-scoped defaults, the advisory custom part, and all of T6c. + (S0.5 §1, §4.) +4. **Correct §3.3.** Spelling squiggles are already emitted per layout line; + the leading candidate is the fragment clip floor discarding the descender + band. (S0.3 §4.) +5. **Note in §3.1** that the overlay *and* most scroll capabilities are proven, + so T1.1 is a documentation task. + +### Open items carried out of Phase 0 + +| Item | Owner phase | Note | +| --- | --- | --- | +| ~~Identify the sixth scroll capability (spec r3 §3.1)~~ | — | **Answered** in S0.1 §2a: the missing one is **animated programmatic scroll**, which is app-side work, so T1.1 has no patch to land. Nested containers are *unproven*, not missing | +| Probe P1 — nested scroll containers | T1.7 (Phase 1) | S0.1 §4. **Still open** — needs a running app; not runnable in the dev sandbox. Test input routing, not layout: blitz-dom models the geometry, so the plausible failure is renders-right/routes-wrong. Gates T7.3; R2 is *unverified*, not unsupported | +| Wire the `DeviceProfile` platform probes | **distributed** — T2.0, T3.2, T4.0, T5.5, T7.1 | The type, context and pointer latch landed in Phase 1 (`appthere-ui/src/device_profile.rs`); every probe behind it is still `Unknown`. Per spec r5 the probes now land **with their consuming phase** rather than as a standalone tail — a probe with no consumer cannot be tested, and the consumer is the first thing that would notice a wrong value. The 11 behavioural `cfg` sites from S0.6 §2a retire as their probe arrives, so L08-011 is asserted but not yet true | +| Screen-test each phase before the next builds on it | every phase | Spec r6 §3.6, now the program's leading risk (R23). The first screen test of Phase 1 found I-20 — a functional regression that had passed 31 unit tests, the full workspace suite, the CI clippy command and eight script gates. Automated gates cannot see behavioural regressions, and unverified phases stack | +| Close Phase 0.5: run the negative test, and make CI reach the branch | next CI run | S0.4 §6b. Half one (host gates are blind) is measured; half two needs an NDK the sandbox cannot fetch. Note the branch currently triggers **no** CI — `rust.yml` fires only on `main` pushes and PRs to `main` | +| Confirm I-06 candidate 1 with a failing test | T3.1 | S0.3 §4 | +| Decide Phase 2 acceptance criterion (a) or (b) | before T2.1 | S0.2 §4 | +| I-21: pick the branch of the T1.9 diagnostic | next screen test | Inspection ruled out the margin arithmetic, chrome inside `client_height`, and a missing zoom factor; two causes remain and a `tracing::debug!` on `loki_text::caret_follow` separates them in one observation. See the `editor_caret_follow` module docs. **The margin value has deliberately not been tuned** — three of T1.9's four causes are bugs that lowering it would mask | +| Reconcile L08-003 with ADR-0012 Decision 2 | Phase 6 ADR pass | S0.5 §7 | diff --git a/docs/spikes/S0.1-blitz-scroll-capability.md b/docs/spikes/S0.1-blitz-scroll-capability.md new file mode 100644 index 00000000..b82a2d64 --- /dev/null +++ b/docs/spikes/S0.1-blitz-scroll-capability.md @@ -0,0 +1,139 @@ + + +# S0.1 — Blitz scroll capability spike + +| Field | Value | +| --- | --- | +| Spec | Loki Spec 08 §4 Phase 0, gates Phases 1, 2 and 7 | +| Date | 2026-07-25 | +| Status | Complete — **Phases 1 and 2 are unblocked** | +| Method | Source audit of the vendored `patches/blitz-dom`, `patches/blitz-shell`, `patches/dioxus-native-dom`, and the app-side consumers in `loki-text` / `appthere-ui`. No device run (no GPU/display in this environment); every claim below is cited to a file and line. | + +## 1. Headline + +**Spec §3.1 overstates the blocker.** Five of the six capabilities in the §3.1 +table already exist in the vendored patch set and are in production use today — +the editor's custom scrollbar, the tile-virtualization window, the responsive +width sensor and the spelling context menu are all built on them. The scroll +work in Phase 1 is therefore **wiring, not enablement**. + +Only two things are genuinely missing: + +1. **Animated** programmatic scroll (instant works). +2. Runtime confirmation that a **nested** scroll container inside the document + flow behaves (the DOM model supports it; it has never been exercised). + +Neither needs an upstream change. R1 ("Blitz exposes no usable scroll offset") +does not materialise. R2 (nested containers) downgrades from "likely no" to +"probably yes, needs a probe" — see §4. + +## 2. Capability table (§3.1, answered) + +| Capability | Status | Evidence | Route | +| --- | --- | --- | --- | +| Read current scroll offset of the document container | **Available** | `ScrollEvent` carries `scroll_top/left`, `scroll_width/height`, `client_width/height`; consumed at `loki-text/src/routes/editor/editor_canvas.rs:200`. Also pull-style via `MountedData::get_scroll_offset()` (`patches/dioxus-native-dom/src/mounted.rs:74`). | none | +| Subscribe to scroll-offset change events | **Available** | PATCH(loki) chain: `BaseDocument::scroll_node_by_collect` records changed nodes (`patches/blitz-dom/src/document.rs:1338`) → `Document::handle_scroll_changes` (`document.rs:96`) → dioxus-native-dom dispatches a DOM `scroll` event → `onscroll`. Plus `collect_scroll_containers` (`document.rs:1467`) which the shell replays after a resize so containers re-receive geometry without a user gesture. | none | +| Programmatically set scroll offset — **instant** | **Available** | `MountedData::scroll` (`patches/dioxus-native-dom/src/mounted.rs:111`) → `BaseDocument::scroll_node_to_collect` (`patches/blitz-dom/src/document.rs:1307`), which clamps and bubbles. Live consumer: scrollbar thumb drag, `loki-text/src/routes/editor/editor_scrollbar.rs:191`. | none | +| Programmatically set scroll offset — **animated** | **Missing** | `MountedData::scroll` ignores `ScrollBehavior`; `scroll_to` (`scrollIntoView`) is a documented no-op (`mounted.rs:121`) because it would need the scrollable ancestor's geometry, which that crate cannot reach. | **App-side.** Step the offset per frame from `ViewportController` using the existing instant `scroll`. Do **not** patch Blitz for this — the shell has no animation clock we can drive per-element, and an app-side easing keeps the patch surface where it already is. | +| Query the scroll container's visible rect in document space | **Available** | `MountedData::get_client_rect()` (`patches/dioxus-native-dom/src/mounted.rs:97`); live consumers `loki-text/src/routes/editor/editor_responsive.rs:74` and `appthere-ui/src/responsive/width_sensor.rs:34`. Visible rect in document space = `(scroll_left, scroll_top, client_width, client_height)` from the same `ScrollEvent`. | none | +| Position an element at an absolute viewport coordinate, above all content | **Available and proven** | `loki-text/src/routes/editor/editor_spell_panel.rs` — `position: absolute` in the `position: relative` editor root, transparent full-area dismiss backdrop, viewport-edge clamping. `position: absolute` is confirmed working in the current Stylo + stylo_taffy 0.2 + Taffy 0.9 stack (CLAUDE.md "Confirmed CSS properties"). | none — **extract, don't reinvent** (T4.1) | +| Nested / independent scroll containers within document flow | **Modelled, unproven** | `scroll_node_by_collect_inner` (`patches/blitz-dom/src/document.rs:1371`) resolves `overflow-x/y: scroll\|auto` per node, consumes what the node can take, and bubbles the remainder to `node.parent`; `scroll_node_within_collect` (`document.rs:1357`) drops the residue at the root instead of nudging the viewport. So a child scroll container inside the document flow is a first-class case in the DOM, not an unsupported one. What is unverified is the *gesture* path on a nested container and the interaction with the wheel/touch synthesis in `patches/blitz-shell/src/window.rs`. | **Probe P1** before T7.3 commits | + +### Stale documentation found + +`loki-text/src/editing/hit_test.rs:10-13` states that +`MountedData::get_client_rect()` and `offset_x`/`offset_y` are +`unimplemented!()` in dioxus-native-dom. That was true at 0.7.4 before the +`onmounted`/`MountedData` patch; it is contradicted by two live callers today. +The hit-test *strategy* it describes (compute the origin from known layout +values) is still the right one and should stay, but the justification is wrong +and will mislead the next reader. Fix the comment in Phase 1 alongside T1.2. + +## 2a. Addendum (2026-07-25) — answering spec r3 §3.1 + +Spec r3 §3.1 asks which capability is the missing one, noting that R2 and the +T7.4 fallback hinge on the answer. It presumes the missing one is nested scroll +containers. **It is not.** The table above splits r1's row 3 ("programmatically +set scroll offset (animated + instant)") into its two halves, which is why it +has seven rows where r1 had six. Mapping back to r1's six: + +| r1 row | State | +| --- | --- | +| 1 read offset | ships | +| 2 subscribe to changes | ships | +| 3 set offset | **instant ships; animated is the one missing capability** | +| 4 visible rect | ships | +| 5 overlay | ships | +| 6 nested containers | **modelled in blitz-dom, never exercised** | + +So there are two distinct states, not one: + +- **Missing: animated programmatic scroll.** `MountedData::scroll` ignores + `ScrollBehavior`, and `scroll_to` is a no-op. This is what "5 of 6" refers to. + It is **app-side work, not a patch** — see §3 — so **T1.1 as written in r3 + ("land the S0.1 route … patch in `patches/` with a README note per L08-001") + does not apply.** There is no patch to land; L08-001 is not engaged. +- **Unproven, not missing: nested scroll containers.** The DOM models them + (`scroll_node_by_collect_inner` resolves `overflow` per node and bubbles the + remainder to the parent). Nothing has ever driven one, so R2 stays open on + *evidence*, not on a known gap. Probe P1 (§4) settles it, and it must run + before T7.3's design is fixed — not before Phase 1, which does not depend on it. + +Consequence for the risk register: R2's severity is over-stated at High while +the DOM-level support is present. It should read "unverified" rather than +"unsupported" until P1 reports. + +## 3. Recommended route + +Per D-01 the program patches locally and ships. This spike finds that **no new +patch is required for Phase 1 or Phase 2**: + +- T1.1 reduces to: correct the stale `hit_test.rs` comment, and record in + `docs/patches.md` that the scroll chain (`scroll_node_by_collect`, + `handle_scroll_changes`, `scroll_node_to_collect`, `collect_scroll_containers`) + is now load-bearing for caret-follow and windowed rendering, not only for the + scrollbar thumb. That raises the cost of losing it in a future re-vendor, + which is exactly the failure mode S0.4 documents for the IME patch. +- T1.2's `ViewportController` is a pure app-side type. It should own: + - `visible_rect()` — from the last `ScrollEvent` (already mirrored into + `ScrollMetrics`, `editor_scrollbar.rs`), not a fresh measurement; + - `scroll_to(offset, behavior)` — instant via `MountedData::scroll`, animated + by stepping; + - `scroll_to_reveal(rect, margin)` — the T1.3 consumer. + + `ScrollMetrics` already carries every field it needs. The controller should + **absorb** `ScrollMetrics` rather than sit beside it, or the program acquires + a second scroll source of truth — the exact defect Spec 01 audit A-1 fixed for + viewport width. + +- No upstream PR is required for scroll. If one is opened anyway, the honest + candidate is `MountedData::scroll_to` (`scrollIntoView`), which is a genuine + upstream gap and would replace our app-side `scroll_to_reveal` if it landed. + +## 4. Probe P1 — nested scroll containers (blocks T7.3/T7.4, not Phase 1) + +Cheapest decisive test, to run on a desktop build before Phase 7 designs land: + +1. In the reflow view, wrap a wide table in a `div` with `overflow-x: auto` and + a width narrower than its content. +2. Verify: (a) a horizontal wheel/trackpad gesture over the table scrolls the + table and not the document; (b) the gesture bubbles vertically to the + document container when the table has no vertical overflow; (c) the document + container does not gain a horizontal scrollbar; (d) `onscroll` fires on the + inner container with sane `scroll_width`. +3. If (a) or (d) fails, the shell's wheel handler is resolving the target node + above the inner container — that is a `window.rs` patch, not a fork. + +Record the result in this file. Until it is recorded, T7.4's modal-viewer +fallback stays the assumed path. + +## 5. Consequences for the spec + +- §3.1 should be read as "the overlay capability *and* five of six scroll + capabilities are proven; animated scroll and nested containers are the gaps". +- R1 can be closed. +- R2 stays open but is downgraded pending P1. +- The Phase 1 estimate should drop: T1.1 is documentation plus a comment fix. diff --git a/docs/spikes/S0.2-render-memory-census.md b/docs/spikes/S0.2-render-memory-census.md new file mode 100644 index 00000000..540f3f54 --- /dev/null +++ b/docs/spikes/S0.2-render-memory-census.md @@ -0,0 +1,207 @@ + + +# S0.2 — Render pipeline and memory census + +| Field | Value | +| --- | --- | +| Spec | Loki Spec 08 §4 Phase 0, gates Phase 2 (I-01) | +| Date | 2026-07-25 | +| Status | Complete — with **two corrections to the spec's premises** (§2, §6) | +| Method | Source audit plus arithmetic derived from the allocation sites. This environment has no GPU, so no live profile was taken; the byte figures are computed from texture dimensions and struct contents, in the same manner as `docs/memory-audit-2026-06-12.md`. Every figure below is reproducible from the cited code. | + +## 1. Where page textures are allocated and how long they live + +One GPU texture per **mounted** page tile, owned by that tile's +`LokiPageSource` (`loki-renderer/src/page_paint_source.rs`): + +| Event | Code | Effect | +| --- | --- | --- | +| Tile enters the window | `DocumentView` mounts `PageTile` → `use_wgpu(LokiPageSource::new)` (`page_tile.rs:80`) | source created, no texture yet | +| First frame | `CustomPaintSource::render` → `render::allocate_page_texture(device, w_phys, h_phys)` (`page_paint_source.rs:170`) | texture allocated, registered with Blitz | +| Later frames, nothing changed | reuse guard on `(generation, size, cursor)` (`page_paint_source.rs:155`) | zero cost | +| Generation / size / caret change | old handle `unregister_texture`d, new one allocated (`page_paint_source.rs:165`) | one allocation, old freed | +| Tile leaves the window | `release()` (`page_paint_source.rs:120`) | texture unregistered — **this is the only path that returns memory** | +| App suspend | `suspend()` (`page_paint_source.rs:100`) | handle dropped without unregistering; safe only because the window renderer is recreated on resume | + +Residency is therefore governed entirely by **which tiles are mounted**, and +that is decided by `virtualize::visible_window` +(`loki-renderer/src/virtualize.rs:19`), consumed at +`document_view.rs:186`. + +Current window: `[viewport_top − vh, viewport_top + 2·vh]` — i.e. the visible +band grown by **one full viewport height on each side**, total 3·vh. Spec T2.2 +proposes 0.5× each side; that is a **reduction** of today's band, not an +addition. + +## 2. Correction 1 — the Hot/Warm/Cold tiers no longer exist + +Spec §4 Phase 0 asks how "the Hot/Warm/Cold tiers in `loki-render-cache` +interact with" page textures. They do not, because they were removed. + +`loki-render-cache` is **115 lines total** and contains no cache: `PageIndex`, +a blanket `CacheKey` marker trait, the `PageSource` trait, `GpuTexture`, and +`RenderError`. There is no tier enum, no LRU, no eviction, no registry. +`grep -rn "Hot\|Warm\|Cold" loki-render-cache loki-renderer loki-vello +appthere-canvas` returns nothing. + +The tier system described in `docs/memory-audit-2026-06-12.md` (finding 1) was +superseded by virtualization: `page_paint_source.rs:15` now states "Every +mounted tile renders at full resolution; virtualization only mounts pages near +the viewport, so texture memory is bounded by mounting." + +**Consequence for T2.3.** "LRU eviction within the Cold tier" has nothing to +evict from — there are no tiers and no cache. T2.3 must **introduce** a +residency registry, not extend one. Recommended shape in §5. + +`docs/memory-audit-2026-06-12.md` should get a note that finding 1's fix was +later replaced, or the next reader repeats this discovery. + +## 3. Texture arithmetic + +US Letter is 612 × 792 pt → 816 × 1056 CSS px (`× 96/72`, +`document_view.rs:108`). The physical texture is CSS px × zoom × device scale +(`page_paint_source.rs:174`), RGBA8: + +``` +bytes/page = 816·z·s × 1056·z·s × 4 = 3_446_784 · (z·s)² +``` + +| zoom `z` | device scale `s` | bytes/page | note | +| --- | --- | --- | --- | +| 0.25 (the clamp floor, `document_view.rs:86`) | 1 | 0.22 MB | | +| 0.5 | 1 | 0.86 MB | | +| 1.0 | 1 | 3.45 MB | | +| 1.25 | 1 | 5.39 MB | matches the spec §3.2 figure | +| 2.0 | 1 | 13.79 MB | matches the spec §3.2 figure | +| 1.0 | 2 | 13.79 MB | HiDPI at 100% costs the same as 1× at 200% | +| 2.0 | 2 | **55.15 MB** | the realistic worst case | + +A4 (595.28 × 841.89 pt → 793.7 × 1122.5 px) is 3.56 MB at `z·s = 1`. + +### Resident set today, by zoom + +Pages resident ≈ `3·vh / stride + 1`, `stride = 1056·z + page_gap_px`. For a +900 px-tall viewport, 24 px gap, Letter: + +| `z` | stride px | pages resident | texture bytes, `s=1` | texture bytes, `s=2` | +| --- | --- | --- | --- | --- | +| 0.25 | 288 | ~11 | 2.4 MB | 9.5 MB | +| 0.5 | 552 | ~6 | 5.2 MB | 20.7 MB | +| 1.0 | 1080 | ~4 | 13.8 MB | 55.1 MB | +| 2.0 | 2136 | ~3 | 41.4 MB | **165.4 MB** | + +Two things follow, and both matter for T2.3: + +- **Resident texture bytes are already independent of document length.** 10, + 100 and 500 pages give the same table. The spec's "~540 MB for 100 pages" + describes the *pre-virtualization* behaviour; it is not the behaviour of the + current tree. +- **The unbounded axis is zoom × DPI, not page count.** A byte budget is + still worth having — 165 MB on a HiDPI display at 200% is real — but it + should be understood as capping the *high-zoom* case, not the *long-document* + case. + +## 4. Correction 2 — what is actually O(document length) + +Textures are windowed. These are not: + +| Structure | Scope | Code | Rough cost | +| --- | --- | --- | --- | +| `PaginatedLayout.pages: Vec>` | **every page in the document** | `loki-layout/src/result.rs:105` | positioned items for all pages: glyph runs, rects, decorations | +| `LayoutPage.editing_data: Option` | every page, when `preserve_for_editing` | `result.rs:148` | holds `Arc` — Parley layouts (clusters, glyphs, runs) — for every paragraph on the page. The largest per-page structure by a wide margin | +| Loro oplog | whole document + edit history | audit finding 6 | edit-driven, not idle | +| Inactive tab sessions | per open tab | audit finding 3, still "Recommended" | full preserved layout + Loro + undo | + +**This is the finding that most affects Phase 2's acceptance criteria.** The +stated test — "peak RSS for a 500-page document within 20% of a 10-page +document at 100% zoom" — cannot be satisfied by texture windowing, because +texture residency is *already* equal between those two documents while layout +and editing data scale linearly with page count. Windowing textures further +moves the 100%-zoom figure by at most a few MB in either direction. + +Two honest options for Phase 2; the program should pick one before T2.1 starts: + +- **(a) Keep the criterion, widen the scope.** Add layout/editing-data + windowing to Phase 2 (drop `editing_data` for pages far outside the window + and recompute on demand; `relayout_paginated_incremental` already reuses + pages by `Arc` clone, so the machinery is adjacent). This is a materially + larger change than T2.1–T2.6 as written, and it touches the editing hot path. +- **(b) Keep the scope, restate the criterion.** Make the Phase 2 gate + *resident texture bytes*, which is what I-01 is actually about and what T2.3 + can bound exactly, and track total RSS as a separate, non-gating measurement + with its own follow-up issue for the layout tail. + +**Recommendation: (b).** It is falsifiable, measurable in CI without a GPU (a +counter, see §6), and it does not smuggle an editing-path rewrite into a +memory phase. The layout tail should become a new issue in the register rather +than being absorbed silently. + +## 5. What is stubbed or incomplete for I-01 + +Ordered by impact: + +1. **The Android CPU path bypasses virtualization entirely.** + `document_view.rs:56-64` returns `ReflowDocView` — a flat HTML-flow renderer + — for `target_os = "android"` without `--cfg android_gpu`, before any of the + windowing code runs. The most memory-constrained devices in the fleet get + the least memory management, and per §3.5 / D-08 this is exactly the kind of + compile-target behavioural gate the program forbids. **T2.1 should treat + this as its first item**, coordinated with T1.6's `DeviceProfile`. +2. **No byte budget and no eviction** (T2.3) — nothing to extend, see §2. +3. **The window is a page-overlap test with no floor or ceiling.** At the 0.25 + zoom clamp with a tall window it mounts ~11 tiles; there is no cap on tile + count, only the geometric overlap. +4. **Regeneration runs on every scroll event.** `viewport_top_px` is set from + `scroll_offset()` (`editor_canvas.rs:344`), which the `onscroll` handler + updates per event, so `DocumentView` re-renders and re-derives the window on + each one. GPU work is correctly suppressed by `PageTileProps::eq` + (`page_tile.rs:53`), so this is diff cost rather than paint cost — but T2.4's + threshold is still the right fix. +5. **The off-window placeholder is a plain white div** (`document_view.rs:274`) + with no text-density hint (T2.4). +6. **Invalidation coverage** (T2.5): generation, physical size and caret are + covered by the reuse guard. Zoom flows through `set_zoom` → generation, and + device scale change flows through `w_phys/h_phys`. The gap is + **page-style change**, which Phase 6 introduces — add the test with T6a, not + before. + +## 6. Measurement: what `loki-bench` can and cannot do + +`loki-bench` has `memory` (dhat `AllocStats`), `rss`, `budget`, `leak`, +`baseline`, `parity`, `axis`. Two limits matter for T2.6: + +- `rss::peak_rss_bytes()` reads `/proc/self/status` and returns `None` + everywhere but Linux (`loki-bench/src/rss.rs:34`). macOS/Windows are marked + as on-device follow-ups. +- **GPU textures are invisible to both signals.** dhat measures host heap; RSS + in a headless CI run has no GPU allocation to see at all. + +So the Phase 2 acceptance test must not be an RSS assertion. Add a **resident +texture-bytes counter** to the renderer — incremented on +`allocate_page_texture`, decremented on `unregister_texture` — expose it, and +assert on it. That is the number T2.3's budget bounds, it is exact rather than +inferred, and it is checkable without a GPU by unit-testing the residency +policy against synthetic page geometry. Keep RSS as an on-device confirmation +with a budget entry, per the `budget.rs` "review target, never a gate" +convention. + +## 7. Recommended shape for T2.3 + +A `PageResidency` type in `loki-render-cache` (the crate finally earning its +name), owned by `RendererState`: + +- inputs: the window from T2.2 (pinned set), a byte budget, and each tile's + `(page_index, w_phys, h_phys)`; +- state: insertion/LRU order over non-pinned entries plus a running byte total; +- output: the set of page indices allowed to hold a texture this frame. + +`DocumentView` intersects that set with the geometric window when choosing what +to mount. Pinning falls out for free: pages inside the visible band are never +offered for eviction, satisfying L08-002. The budget default comes from +`DeviceProfile` (T1.6), not from `cfg!(target_os)` — 64 MB baseline, 24 MB +floor, 256 MB ceiling as the spec proposes; note from §3 that 256 MB +accommodates the 200%/HiDPI case with headroom while 24 MB forces a +two-tile window there, which is the correct degradation. diff --git a/docs/spikes/S0.3-coordinate-space-audit.md b/docs/spikes/S0.3-coordinate-space-audit.md new file mode 100644 index 00000000..f562c730 --- /dev/null +++ b/docs/spikes/S0.3-coordinate-space-audit.md @@ -0,0 +1,201 @@ + + +# S0.3 — Coordinate-space audit + +| Field | Value | +| --- | --- | +| Spec | Loki Spec 08 §4 Phase 0, gates T1.2/T1.3 (I-05), T3.1 (I-06), T5.5 (I-13) | +| Date | 2026-07-25 | +| Status | Complete — includes a **correction to §3.3's stated root cause for I-06** (§4) | +| Method | Source audit. No device run. | + +## 1. The chain, end to end + +Paginated mode, from a document byte offset to a physical texel: + +| # | Space | Produced by | Notes | +| --- | --- | --- | --- | +| 1 | document `(block_index, byte_offset)` | editing model | the CRDT-facing position | +| 2 | Parley cluster / line, paragraph-local **points** | `ParagraphLayout` (`loki-layout/src/para.rs`), queried via `para_query.rs` | `Cursor::from_byte_index` + `Selection::geometry`. `ParagraphLayout::line_indent` (`para_query.rs:~228`) re-adds the drawn indent (hanging first line) so query geometry matches painted geometry | +| 3 | paragraph → **fragment**, still points | `flow_split::split_and_place_loop` / `emit_fragment` (`loki-layout/src/flow_split.rs`) | a paragraph crossing a page/column boundary becomes N `PositionedItem::ClippedGroup`s; each carries `items_in_y_range(frag_start, split_y)` translated by `(dx, cursor_y − frag_start)` | +| 4 | fragment → **column** | `flow_columns::position_current_column` (`flow_columns.rs:60`) | applied *retroactively*: every item pushed since `column_item_start` is translated by `column_x_offset`, and the matching `current_paragraphs[column_para_start..]` origins are shifted by the same amount. `PositionedItem::translate` moves a `ClippedGroup`'s `clip_rect` and its children together (`items.rs:97`), so this composes correctly | +| 5 | column → **page** | `LayoutPage` (`loki-layout/src/result.rs:121`) | `content_items` are content-area-local; header/footer/comment items are page-local. **The painter adds the margin offset** — this asymmetry is a live trap for anyone writing new geometry code | +| 6 | page points → **tile CSS px** | `document_view.rs:108` `PTS_TO_CSS_PX = 96/72`, times `zoom` | tile box size | +| 7 | tile CSS px → **window CSS px** | flex centring | two implementations, see §2 | +| 8 | window CSS px → **physical texels** | `page_paint_source.rs:174` `render_scale = scale × 96/72 × zoom` | `scale` is the device scale factor from Blitz; the texture is 1:1 with the composited canvas | +| 9 | scroll | `ScrollEvent.scroll_top` → `viewport_top_px` (`editor_canvas.rs:344`) | drives virtualization and page indicator | + +Reflow mode replaces steps 3–6 with a single continuous layout at +`reflow_layout_tile_width_pt(viewport_px)` presented as zero-gap band tiles, and +substitutes a type scale for zoom (`reflow_metrics.rs`). + +## 2. Every site that implements part of the chain + +### pt ↔ CSS px (the `96/72` factor), production code only + +| Site | Direction | Purpose | +| --- | --- | --- | +| `loki-renderer/src/document_view.rs:108` | pt→px | tile box sizing | +| `loki-renderer/src/page_tile.rs:128,155` | px→pt | mouse-down / drag hit-test, tile-local | +| `loki-renderer/src/page_paint_source.rs:174` | pt→px | render scale | +| `loki-renderer/src/reflow_metrics.rs:27` | px→pt | `PX_TO_PT` (canonical for reflow) | +| `loki-text/src/editing/hit_test.rs:40` | px→pt | `PX_TO_PT` (paginated hit-test) | +| `loki-text/src/editing/selection_handles.rs:29` | pt→px | `PT_TO_PX` | +| `loki-text/src/editing/relayout.rs:62-63` | pt→px | page box for relayout | +| `loki-text/src/routes/editor/editor_style_data.rs:85` | pt→px | style inspector preview | +| `loki-spreadsheet/src/routes/editor/editor_inner.rs:35,38` | both | sheet grid | +| `loki-ooxml/src/xlsx/{export_xml.rs:157,import.rs:252}` | both | XLSX column widths — **format-domain, leave alone** | + +Ten production sites, four distinct spellings of the same constant. + +### Origin / centring + +- `appthere_ui::responsive::Viewport::centred_origin_x` — the canonical one + (Spec 01 audit A-1 consolidated this after a hardcoded 1280 px default caused + hit-testing to diverge from rendering). +- `page_tile.rs` avoids the problem entirely by using + `MouseEvent::element_coordinates()`, which is already tile-local. + +Two strategies coexist on purpose, and that is fine — but +`loki-text/src/editing/hit_test.rs:10-13` justifies the calculated strategy +with a claim that is now false ("`MountedData::get_client_rect()` … are +`unimplemented!()`"); see S0.1 §2. + +### Page-slot arithmetic (page index from a scroll offset) + +Three independent copies of `slot = page_height_px × zoom + page_gap_px`: + +- `loki-text/src/routes/editor/editor_canvas.rs:~232` (status-bar page indicator) +- `loki-text/src/editing/hit_test.rs:136` +- `loki-text/src/editing/selection_handles.rs:68` + +These must agree or the caret, the hit-test and the page number disagree about +which page the user is on. They are currently consistent; nothing enforces it. + +## 3. Recommended canonical API + +A single `loki-renderer::coords` module (it is the crate both the renderer and +the app already depend on, and it owns `reflow_metrics`, the existing precedent +for "one source for the conversions paint and hit-testing must agree on"): + +```rust +pub const PT_PER_CSS_PX: f32 = 72.0 / 96.0; +pub const CSS_PX_PER_PT: f32 = 96.0 / 72.0; + +pub struct PageSlots { page_height_pt: f32, gap_px: f32, zoom: f32 } +impl PageSlots { + pub fn slot_px(&self) -> f32; + pub fn page_at(&self, doc_y_px: f32) -> usize; + pub fn page_top_px(&self, page: usize) -> f32; +} + +/// Document-space rect for a (page, page-local pt) rect — the input +/// `scroll_to_reveal` needs. +pub fn page_rect_to_document_px(page: usize, rect_pt: LayoutRect, slots: &PageSlots) -> Rect; +``` + +T1.2's `ViewportController` consumes `page_rect_to_document_px`; T5.5's Actual +Size replaces the implicit 96 in `CSS_PX_PER_PT` with the measured or +calibrated px-per-inch for the display. Migrating the ten sites above is +mechanical; do it in Phase 1 while the surface is small, not in Phase 5 when +three more features depend on it. + +## 4. I-06 — the spec's stated cause does not match the code + +Spec §3.3 asserts the squiggle is "emitted once per *text range* using the +origin of the range's **first** layout fragment". It is not. +`emit_spelling_squiggles` (`loki-layout/src/para_underlays.rs:177`) resolves +each misspelling to a Parley `Selection` and iterates +`Selection::geometry(layout)`, which yields **one rect per visual line** +(`para_underlays.rs:210`), each anchored to that line's own +`baseline + descent`. Line breaks are already handled correctly, and the +fragment splitter carries decorations by y-range like any other item. + +So §3.3's prescription ("emit one decoration primitive per fragment") is +already the behaviour. The defect is elsewhere. Ranked candidates: + +### Candidate 1 (leading) — the fragment clip floor eats the squiggle + +`emit_fragment` sets `clip_height = (split_y − frag_start).floor()` +(`flow_split.rs:~196`). The comment justifies the floor explicitly and only in +terms of glyph ink: + +> "Parley's `max_coord` equals `baseline + descent + leading_below`; glyphs +> never reach `max_coord`, so flooring by up to 1 pt never clips visible ink." + +That reasoning does not hold for spelling squiggles, which are deliberately +placed *below* the glyphs: the wave band is +`[baseline + descent − t/2, baseline + descent + t/2]` with `t` ∈ [0.7, 1.5] pt +(`para_underlays.rs:211-224`). When `leading_below` is small — tight line +spacing, or a font whose leading Parley places above the line — the squiggle +sits inside exactly the sub-point band the floor discards. The affected line is +always **the last line of a fragment**, i.e. the last line before a page or +column break, which is precisely the reported symptom. + +`DecorationKind::Spelling` was added after the floor and its justification; no +one re-derived the invariant. + +**Falsifying test.** Lay out a paragraph with a misspelling on the last line +before a forced page break, at line-height 1.0, and assert a `Spelling` +decoration survives on that page with its full thickness. Repeat with a column +break. If the squiggle is present but the bottom half is missing, this is the +cause. + +**Fix, if confirmed.** Do not remove the floor (it exists to stop the next +line's top row leaking through). Instead have the clip height cover the +decoration band: track the maximum decoration bottom within the fragment and +extend `clip_height` to it, or floor only the *glyph* extent and take the max +with the decoration extent. Fix it at the splitter, not by moving the squiggle. + +### Candidate 2 — the decoration y-extent filter is a 1-pt band + +`ParagraphLayout::items_in_y_range` (`para_query.rs:~205`) gives a glyph run +±3 font-sizes of slop but treats a `Decoration` as exactly +`[d.y, d.y + thickness]`. Because a squiggle's band straddles the line's +`descender`, an item that should belong to fragment A can fall on either side +of a boundary depending on sub-point rounding. Over-inclusion is harmless (the +clip masks it); *under*-inclusion drops the squiggle. The stated design +principle in that function is "an item with an unknown extent is always kept — +dropping a visible item would be a rendering bug", and the decoration arm +violates its own principle by being tight rather than conservative. + +**Fix.** Give decorations the same conservative treatment: widen to the line +box, or at minimum ±1 pt. + +### Candidate 3 (separate defect, found while auditing) — editing origin drops the list indent + +In paginated mode the editing-geometry origin and the painted-item translation +disagree on x: + +| Path | editing origin | item translation | +| --- | --- | --- | +| continuous / reflow, `flow_para_place.rs:65-71` | `(dx, dy)` ✅ (with a comment noting they must match) | `(dx, dy)` | +| paginated keep-together, `flow_para_place.rs:101-107` | `(0.0, dy)` ❌ | `(dx, dy)` | +| paginated split loop, `flow_split.rs:52,68,~199` | `(0.0, ty)` ❌ | `(dx, ty)` | + +`dx` is `state.current_indent`. **Scope is narrow**: `flow_dispatch.rs:56-59` +sets `current_indent = 0.0` for the *first* block of a list item and folds the +indent into `indent_start` instead (which the query path does account for), so +the common bulleted/numbered paragraph is unaffected. `current_indent` is +non-zero only for **continuation blocks inside a list item** — a second +paragraph, a nested block — in paginated mode. For those, the caret, hit-test +and selection geometry sit one list level (18 pt per level) to the left of the +painted text. + +Not I-06, but the same class, cheap to fix alongside it, and worth a regression +test. Recommend folding into T3.1. + +## 5. Consumers of this audit + +- **T1.3 (caret follow)** needs `page_rect_to_document_px` from §3 plus the + visible rect from S0.1. Note step 5's asymmetry: a caret rect from + `editing_data` is content-area-local and must have the page margins added + before it becomes a page-local rect. +- **T3.1 (I-06)** starts from §4 candidate 1, with candidate 2 as the follow-up + and candidate 3 as an adjacent fix. +- **T5.5 (I-13)** replaces the constant 96 in step 6/8 with the measured display + px-per-inch; every site in §2's first table is a place that constant is + currently assumed, which is the argument for consolidating first. diff --git a/docs/spikes/S0.4-ime-patch-archaeology.md b/docs/spikes/S0.4-ime-patch-archaeology.md new file mode 100644 index 00000000..b36a3b7f --- /dev/null +++ b/docs/spikes/S0.4-ime-patch-archaeology.md @@ -0,0 +1,235 @@ + + +# S0.4 — IME patch archaeology (I-07) + +| Field | Value | +| --- | --- | +| Spec | Loki Spec 08 §4 Phase 0, gates T3.2 | +| Date | 2026-07-25 | +| Status | Complete — **root cause found, and it is worse than I-07 describes** | +| Method | Git archaeology plus source audit; the mechanism is confirmed by a standalone `rustc` reproduction (§3). | + +## 1. Headline + +The IME safe-area patch was **not** lost. Every layer of it is present and +intact: + +| Layer | Location | State | +| --- | --- | --- | +| Java shim | `patches/loki-file-access/android/ImeInsetsListener.java` | present, dexed by `build.rs` (`JAVA_SHIMS`, `build.rs:20`) | +| JNI listener | `patches/loki-file-access/src/platform/android/jni_ime.rs` | present, exported (`platform/mod.rs:31`) | +| IME inset in the mask | `jni_insets.rs:~175` folds `WindowInsets.Type.ime()` into `systemBars \| displayCutout` | present | +| Shell bridge | `patches/blitz-shell/src/ime_android.rs` | present, re-exported (`blitz-shell/src/lib.rs:26`) | +| Settle window | `window.rs:327-355` (`poll`), `window.rs:576` (`arm_ime_settle`, 400 ms, wakes at 60/160/280/400 ms) | present | +| App-side inset sensor | `loki-text/src/app.rs:59-84` (`SafeAreaResizeSensor`) | present | +| Wiring: `set_ime_visibility_listener` + `install_ime_listener` | `loki-text/src/lib.rs:90-93` | present — **but in dead code, see §2** | + +What broke is the **Android entry point**: `loki-text` currently defines +`android_main` twice, so the `loki-text` Android build does not compile at all. +The listener wiring lives in the copy that should have been deleted. + +## 2. Root cause — a merge kept both sides of a refactor + +`loki-text/src/lib.rs` contains, at the crate root and both gated on +`#[cfg(target_os = "android")]`: + +- line 30: `loki_app_shell::android_main!(tag = "LOKI", root = app::App, file_access = null_context);` + which expands to `static ANDROID_MAIN_RUNNING` **and** + `fn android_main(…)` (`loki-app-shell/src/android.rs:51-70`); +- lines 38–115: a hand-written `static ANDROID_MAIN_RUNNING` and + `fn android_main(…)` — the pre-refactor copy, carrying the IME listener + registration at lines 90–93. + +How it happened, from the history of that one file: + +| Commit | macro call | hand-written `fn` | | +| --- | --- | --- | --- | +| `f6ce3b5` … `943a7a3` | 0 | 1 | hand-written body is the only entry point | +| `6c503a7` (on `main`) | 1 | 0 | Spec 01 audit A-14: body moved into `loki_app_shell::android_main!` | +| `4f436e7` (on a branch cut before A-14) | 0 | 1 | **adds the IME wiring to the hand-written body** | +| `cce9772` — *"Merge branch 'main' into claude/adr-docs-setup-ogwz5a"* | **1** | **1** | ← the defect enters here | +| `9921578` … `HEAD` | 1 | 1 | carried forward untouched | + +The merge took the macro invocation from `main` and the hand-written function +from the branch, and nothing rejected the combination because **desktop CI never +compiles either one**: both are behind `#[cfg(target_os = "android")]`, and +`cargo check --workspace` on Linux/macOS/Windows skips them entirely. The +workspace gate is structurally blind to this whole file region. + +## 3. Confirmation that this is a hard error + +`macro_rules!` is hygienic for local bindings and lifetimes, **not for item +names** — an item emitted by a macro lands in the invocation's module namespace +under its literal name. Reduced case, run on the pinned toolchain (1.97.1): + +```rust +macro_rules! emit { + () => { static FLAG: bool = true; fn android_main() { let _ = FLAG; } }; +} +emit!(); +fn android_main() {} +``` + +``` +error[E0428]: the name `android_main` is defined multiple times + = note: `android_main` must be defined only once in the value namespace of this module +``` + +The same applies to `ANDROID_MAIN_RUNNING`, so the Android build of `loki-text` +produces two E0428s. It cannot have built since `cce9772`. + +**This should be reported as more than I-07.** The issue register describes a +behavioural regression ("IME dismissal no longer shrinks safe area"); the +actual state is that the Android build of the flagship app is broken. Whatever +APK is being exercised predates the merge, which is also why the *behaviour* +looks like a regression to a stale binary rather than a build failure to the +person testing it. + +## 4. The second bug the fix must not re-create + +The IME registration only ever existed in `loki-text`'s hand-written body. +`loki-spreadsheet/src/lib.rs:28` and `loki-presentation/src/lib.rs:23` use the +macro and nothing else, so **neither has ever had the soft-keyboard bridge**. +Deleting the duplicate without moving the wiring would fix the build and +silently keep two of three apps broken. + +## 5. Recommended fix (T3.2) + +1. Delete lines 31–115 of `loki-text/src/lib.rs` (the hand-written + `ANDROID_MAIN_RUNNING` + `android_main`). +2. Move the IME bridge into `loki_app_shell::android_main!`, immediately after + `::blitz_shell::set_android_app(android_app);` — the same position it + occupied in the hand-written copy, so ordering ("register the bridge before + installing the listener so the first callback is not dropped") is preserved. + All three binaries then get it. +3. Add the CI guard that would have caught this. The cheapest effective one is + a `cargo check --target aarch64-linux-android -p loki-text -p loki-spreadsheet + -p loki-presentation` job; it needs the Android target and NDK linker, but + `cargo check` alone would have failed on the E0428 without linking. A + grep-based guard (`scripts/`) that asserts each app's `lib.rs` contains + exactly one `android_main` definition is a weaker but zero-dependency + fallback. +4. Note the incident in `patches/README` / `docs/patches.md` as the spec asks — + but note it accurately: the *patch* was never dropped, the *call site* was + duplicated. The lesson generalises beyond patches to **any code that only + compiles under a cfg CI does not build**, which is the same class of blindness + as the "patch silently not used" failure the Dioxus pin guards against. + +## 6. Does the patch still apply to the current `blitz-shell`? Yes + +It is not a floating patch — `patches/blitz-shell` is a vendored copy at 0.2.3 +with the IME code already merged into `window.rs` and `ime_android.rs`. There +is nothing to re-apply and no rebase risk. **R6 can be closed**, and T3.2's +"reimplement against current blitz-shell" budget is not needed. + +## 6a. T0.5.3 audit result (2026-07-25) — no other instance of this shape + +Spec r3 T0.5.3 asks whether the same class of damage exists elsewhere: an item +defined twice behind `cfg`s that only *look* mutually exclusive, or a +`macro_rules!`-generated item name colliding with a hand-written one. Two scans +over all workspace Rust (excluding `target/` and `patches/`): + +**Duplicate top-level item names within a file — 22 candidates, all benign.** +Every one is a proper complementary pair, verified by reading the attribute +above each definition: + +| Pair kind | Sites | +| --- | --- | +| `cfg(feature = "serde")` / `cfg(not(...))` | 17 in `loki-doc-model/src/loro_bridge/` (`opaque`, `styles`, `inlines_read`, `settings`, `meta`, `table`, `comments`, `containers`) | +| `cfg(target_os = "android")` / `cfg(not(...))` | `loki-renderer/src/page_paint_render.rs:165,194` — note this is also S0.6 §2a's teardrop-handle behavioural gate, so it is being replaced anyway | +| `cfg(target_os = "linux")` / `cfg(not(...))` | `loki-bench/src/rss.rs:29,35` | +| anonymous `const _: () = assert!(…)` | `loki-macro-host/src/exec/doc_facade.rs:33,35` — anonymous, always legal | + +**Item-emitting macros — 5, one exposed, now closed.** + +| Macro | Items emitted | Collision risk | +| --- | --- | --- | +| `android_main!` | `ANDROID_MAIN_RUNNING`, `android_main` | **This was the defect.** All three call sites now hold exactly one invocation and no hand-written twin | +| `dhat_global_allocator!` | `LOKI_BENCH_DHAT_ALLOC` | 10 call sites in `loki-bench/benches/`, none defines that name. Also host-target and compiled by CI, so a collision would fail today | +| `id_newtype!`, `impl_conversion!`, `try_read_config!` | items inside `impl` blocks only | Impl-scoped; no module-namespace collision possible | + +**Conclusion: I-16 is a single incident, not a pattern.** What made it survive +was not that the shape is common but that the Android target was never +compiled — which is I-17, and why T0.5.2 rather than this audit is the durable +fix. + +## 6b. Closing Phase 0.5 — the negative test + +Spec r4 holds Phase 0.5 open until the `android-check` job runs green on the +branch **and** a deliberate Android-only error is shown to fail it. Neither can +be done from the development sandbox, for one reason each; both are recorded +here so closing the phase is mechanical rather than remembered. + +### Half one — host jobs are blind. Measured, 2026-07-25. + +Injected into the `android_main!` macro body in +`loki-app-shell/src/android.rs`, between the i18n and launch steps: + +```rust +let _negative_test: u32 = "NEGATIVE TEST — must fail android-check"; +``` + +A bare type error, in shipped code, on the path every Android build takes. +Both host gates pass regardless: + +| Gate | Result with the error present | +| --- | --- | +| `cargo check --workspace` | **exit 0** — `Finished dev profile` | +| `cargo clippy --workspace --all-features -- -D warnings -D clippy::unwrap_used -D clippy::expect_used` | **exit 0** | + +This is I-17 demonstrated rather than argued, and it is the reason I-16 lived +for three weeks: nothing in CI was looking. The injection was reverted +immediately; it is reproduced above so the fixture does not have to be +reinvented. + +### Half two — does `android-check` catch it? Not yet answerable here. + +The sandbox cannot compile for Android. `rustup target add +aarch64-linux-android` succeeds, but the build dies in `ring`'s build script +(`failed to find tool "aarch64-linux-android-clang"`), and the NDK cannot be +fetched: the agent proxy denies `dl.google.com:443` with a 403 at CONNECT +(`recentRelayFailures` in `$HTTPS_PROXY/__agentproxy/status`). This is a network +policy, not a missing step — no amount of local effort resolves it. + +**Procedure to close the phase**, on any machine or runner with the NDK: + +1. Apply the injection above; push. +2. Expect `android-check` **red**, `lint` and `build-and-test` **green**. Red + only in the Android job is the whole point — if the host jobs also go red, + the fixture is in the wrong place and proves nothing. +3. Revert; push. Expect all three green. +4. Record both run URLs here, then mark Phase 0.5 closed. + +### Prerequisite the branch does not currently satisfy + +`.github/workflows/rust.yml` triggers on `push` to `main` and on +`pull_request` targeting `main`. **A push to `claude/**` runs no CI at all**, so +"the CI job runs green on the branch" cannot happen as things stand. Either +open a pull request against `main` (the `pull_request` trigger then covers it), +or add the branch glob to the `push` trigger. Until one of those happens the +new job has never executed, and an unexecuted gate is indistinguishable from an +absent one — which is L08-014's own point turned back on itself. + +## 7. §3.5 conformance of the restored path + +Checked against the D-08 rule (no behaviour gated on the compile target): + +- The safe area is driven by the **actual** inset value: + `query_window_insets_dp` returns the union of `systemBars | displayCutout | + ime`, and `ime()` contributes 0 while no soft keyboard is shown + (`jni_insets.rs:~180`). An Android device with a hardware keyboard and no IME + therefore reserves nothing — no speculative reservation exists to remove. +- Attach/detach mid-session is handled if and only if the platform emits an + inset change, which is exactly what `ImeInsetsListener`'s + `OnApplyWindowInsetsListener` observes. No polling needed. +- `SafeAreaResizeSensor` and `current_safe_area` are `#[cfg(target_os = + "android")]`. That is **API selection**, not behaviour gating — there is no + `getRootWindowInsets` off Android — so it is compliant. Keep it. +- One genuine §3.5 item: `loki_app_shell::android_main!` seeds insets from + `query_insets_dp()` (the orientation-independent resource heights, no IME) at + startup, before any window exists. That is a bootstrap value corrected on the + first sensor tick; leave it, but do not let it become a "mobile means reserve + space" assumption anywhere downstream. diff --git a/docs/spikes/S0.5-page-style-format-study.md b/docs/spikes/S0.5-page-style-format-study.md new file mode 100644 index 00000000..d4936424 --- /dev/null +++ b/docs/spikes/S0.5-page-style-format-study.md @@ -0,0 +1,131 @@ + + +# S0.5 — Page style format study (I-04) + +| Field | Value | +| --- | --- | +| Spec | Loki Spec 08 §4 Phase 0, gates Phase 6 | +| Date | 2026-07-25 | +| Status | Complete — **§3.4's format analysis is confirmed; Phase 6's scope is materially smaller than written** | +| Method | Source audit of `loki-doc-model`, `loki-ooxml`, `loki-odf`, `loki-primitives`, plus `docs/adr/0012-style-resolution-and-page-styles.md`. | + +## 1. Headline + +Spec §3.4's reading of the two formats is correct on every point I could check +against our own importers and exporters. But the spec plans Phase 6 as if the +named-page-style model were greenfield. It is not: **ADR-0012 Decision 2 already +ratified the ODF-native model and most of it is built.** + +| Spec task | Actual state | +| --- | --- | +| T6a.1 introduce an EMU-backed `Length` newtype | **Already exists** — `loki_primitives::units::Length`, f64-backed, with `Emu`, `Twip`, `Pt`, `Mm`, `Inch`, `Px` unit types and compile-time-checked `into_unit::()` (`loki-primitives/src/units/{length,unit_types,convert}.rs`). The work is *migrating the page family from `Points` to `Length`*, not creating the type | +| T6a.2 extend `PageLayout` into a named `PageStyle` | **Already exists** — `loki-doc-model/src/style/page_style.rs`: `PageStyle { id, display_name, layout, extensions }`, `StyleCatalog::page_styles`, `derive_page_styles`, `Document::assign_page_styles` (idempotent, preserves ODF master-page names and user renames) | +| T6a.3 N-column spec with per-column widths + separator | **Already exists** — `SectionColumns { count: u8, gap, separator, widths: Vec }` (`layout/page.rs:181`). The 1–3 limit is **UI-only** (`loki-text/src/routes/editor/editor_ribbon_layout.rs:75-77`, three preset buttons) | +| T6a.5 document-scoped styles | **Done for the document half** — `Section::page_style` persists through Loro (`KEY_PAGE_STYLE_REF`, `loro_bridge/mod.rs:160,217`). The *application-scoped defaults* half does not exist | +| T6b.1 ODF master-page + page-layout | **Already exists** — `loki-odf/src/odt/write/page_styles.rs` resolves per-section `style:master-page` / `style:page-layout` names from the stored `page_style` id; `content.rs:185` attaches `style:master-page-name` to each section's first block | +| T6b.2 DOCX import derives a style per `w:sectPr` | **Already exists** via `assign_page_styles` after import | +| T6b.3 DOCX export, one section per style run | **Already exists** — `docx/write/section.rs` + `document.rs:73` | +| T6b.5 odd/even + first-page headers | **Model and DOCX done** — `header_first`/`header_even`/`footer_*` in `PageLayout`; `w:evenAndOddHeaders` written (`write/rels.rs:97`, `write/settings.rs`) | + +What is genuinely missing is listed in §4. + +## 2. Confirmations of §3.4 + +- **DOCX has no named page style.** Confirmed by our own exporter: page geometry + is only ever written as `w:sectPr` (`docx/write/section.rs`). Nothing carries a + name. +- **`w:mirrorMargins` is document-wide.** Confirmed, and **already modelled** as + a document setting rather than per-section: `DocxSettings::mirror_margins` + (`loki-ooxml/src/docx/model/settings.rs:18`), read + (`reader/settings.rs:42`), written (`write/settings.rs:24,29`), + round-trip-tested (`loki-ooxml/tests/round_trip.rs:790`), and carried through + the CRDT (`loro_bridge/settings.rs:112`). R3's "detect and warn on export" work + therefore sits on an existing mechanism; only the *detection of disagreement + between page styles* is new. +- **`w:cols` supports far more than three columns.** Confirmed; our model already + does too. Retiring the 1–3 limit is a UI change (T6c), not a model change. +- **ODF is the natural fit.** Confirmed — the model *is* the ODF model, by + ADR-0012 Decision 2, and the ODT writer already round-trips names. + +## 3. Highlight colour (I-09) — the export routing + +`w:highlight` is a fixed enumeration; arbitrary RGB must go to `w:shd` +`w:fill`. The current code takes the conservative route: the colour picker +restricts highlight to the named palette, marked in-tree as +`TODO(highlight-custom-color)` (introduced in `9921578`). So T5.3 is +implementing a deferral that was consciously recorded, not reversing a decision. + +Two consequences the ADR (L08-004) should state explicitly, because they are +easy to get wrong at the writer: + +- **`w:shd` is character shading, not highlight**, and Word renders it under a + different control. A document round-tripped through us will show its + "highlight" in Word's Shading UI. That is the correct trade and should be + documented as user-visible behaviour, not hidden. +- The **exact-enum-match** test must be on the resolved RGB, not on how the user + picked the colour, or the same yellow will export two different ways + depending on whether it came from the swatch grid or the hex field. + +ODF has no such limit (`fo:background-color` takes any value), so the ODT path +is unaffected. + +## 4. What is actually missing (the real Phase 6 backlog) + +| Gap | Where | Notes | +| --- | --- | --- | +| **`style:page-usage`** (`all` / `left` / `right` / `mirrored`) | nowhere — zero occurrences workspace-wide | The one genuinely new model field. Needed by T6a.2, T6b.1 and the mirroring UI | +| **Page-size catalogue** | `PageSize` has exactly two constructors, `a4()` and `letter()` (`layout/page.rs:110,122`) | T6a.4's full catalogue (ISO A0–A6, B4–B6, JIS B4–B6, C5/C6/DL, Letter, Legal, Tabloid, Executive, Statement, Folio, Quarto, #10, Monarch, index cards, user-defined) is new work, but it is a table of constants | +| **EMU migration of the page family** | `PageSize`, `PageMargins`, `SectionColumns`, `LineNumbering::distance` all use `Points` | See §5 — scope this tightly | +| **Application-scoped defaults** (D-07 / L08-012) | no app-settings store for default page size, margins, unit, or saved custom sizes | New. `loki-app-shell` already persists window geometry and recent documents, so there is a home for it | +| **Advisory DOCX custom part** (D-02 / T6b.4) | not written | The mechanism exists and has a worked example: `docx/write/custom_props.rs` adds `docProps/custom.xml` with its own package relationship and content-type override (`custom_props.rs:58-68`). Copy that shape | +| **`w:titlePg`** | `evenAndOddHeaders` is written; first-page variation needs checking end to end | T6b.5 is partial, not absent | +| **Unit system / OS measurement setting** (T6a.7) | nothing | New; feeds `DeviceProfile`-adjacent platform queries, see S0.6 | +| **Column UI beyond 3, page-style manager, catalogue picker** | `editor_ribbon_layout.rs`, `style_page_inspector.rs` | All of T6c | + +## 5. Recommendation on T6a.1 (EMU migration) — keep it narrow + +`Points` (`Length`) is used throughout `loki-doc-model`, `loki-layout`, +`loki-ooxml` and `loki-odf` — paragraph indents, font sizes, borders, tab stops, +table widths. A blanket "store everything in EMU" change is a workspace-wide +refactor with no relation to I-04 and would put Phase 6 on a collision course +with every other in-flight area. + +Migrate **only the page family**: `PageSize`, `PageMargins`, +`SectionColumns::{gap, widths}`, and the new page-usage/gutter fields. Provide +`From`/`into_unit` at the boundary so layout keeps consuming points. Rationale +for even that much: DOCX writes twips and ODF writes cm/in, so the page family +is where round-trip rounding actually accumulates, and the spec's own error +analysis (≤ 1/1440 in ≈ 0.0176 mm) is about `w:pgMar`/`w:pgSz`, not about +indents. + +State it in the ADR as "page geometry is stored in EMU" rather than "lengths are +stored in EMU", or L08-007 will read as licence for the wider change. + +## 6. Migration risk (R4) + +`assign_page_styles` is idempotent and already runs post-import, and +`KEY_PAGE_STYLE_REF` is already in the Loro schema, so documents written by the +current build **already carry page-style references**. The T6a.6 migration test +should therefore cover: + +1. a document written by the current build (has `page_style` refs, `Points` + geometry) opening under the new EMU schema without geometry drift — assert + exact page dimensions, not "looks the same"; +2. a document written *before* `KEY_PAGE_STYLE_REF` existed (no refs) getting + names synthesised by `assign_page_styles` without renumbering an existing + catalogue; +3. round-trip of a mirrored document through DOCX and back, asserting + `mirror_margins` survives (extend `loki-ooxml/tests/round_trip.rs:790`). + +Fixtures for (1) and (2) must be committed **before** any writer change, per R4. + +## 7. Note for the ADR + +L08-003 as drafted ("The page-style model follows ODF … DOCX is treated as a +lossy target") restates ADR-0012 Decision 2. It should **supersede or amend** +that ADR rather than sit beside it, or the next reader finds two ADRs deciding +the same thing four months apart. The genuinely new decisions are L08-012 +(document vs application scope) and L08-013 (the advisory custom part). diff --git a/docs/spikes/S0.6-device-capability-probe.md b/docs/spikes/S0.6-device-capability-probe.md new file mode 100644 index 00000000..20f8641d --- /dev/null +++ b/docs/spikes/S0.6-device-capability-probe.md @@ -0,0 +1,138 @@ + + +# S0.6 — Device capability probe (§3.5 / D-08) + +| Field | Value | +| --- | --- | +| Spec | Loki Spec 08 §4 Phase 0, gates T1.6 and every later phase's responsive behaviour | +| Date | 2026-07-25 | +| Status | Complete — **R13 does not materialise**; the violation set is small and enumerable | +| Method | Exhaustive `target_os` inventory across the workspace (excluding `patches/` and `target/`), plus an audit of the existing responsive foundation. | + +## 1. Headline + +Two findings, both good news for the program: + +1. **The foundation §3.5 asks for mostly exists.** + `appthere_ui::responsive` already establishes "size classes, not device + names" as an explicit decision (Spec 03 D4), with one measured width source + (Spec 01 audit A-1), a memoised `Breakpoint`, live sensors, and a `Viewport` + that already carries `zoom` and `dpi`. `DeviceProfile` should **extend that + context**, not stand beside it — a second capability context would recreate + the two-sources-of-truth defect A-1 fixed. +2. **R13 overstates the risk.** There are **49** `target_os` occurrences in + workspace Rust. Of those, **11 gate behaviour**; the rest are platform API + selection (which §8's rule explicitly permits) or comments. The §3.5 change + is a day of work plus the `DeviceProfile` type, not a sweep. + +## 2. Complete inventory (49 sites) + +### 2a. Behavioural gates — must move to `DeviceProfile` (11) + +| Site | Gates | Correct runtime signal | +| --- | --- | --- | +| `loki-renderer/src/lib.rs:18,21,26,33` and `document_view.rs:8,15,17,23,47,50,56,68` (one decision, many cfg lines) | **The whole renderer.** `target_os = "android"` without `--cfg android_gpu` returns `ReflowDocView`, an HTML-flow fallback — no paginated mode, no zoom, no tile virtualization, no texture budget | **GPU capability**, probed from the wgpu adapter at startup (does it support Vello's compute pipelines). An Android desktop device with a discrete-class GPU must get the real renderer; the emulator's SwiftShader must not. This is also the highest-value item in S0.2 §5 | +| `loki-renderer/src/vello_init.rs:25,27,32,34` | `use_cpu: true` and `AaSupport::area_only()` on Android — a Mali r54 driver workaround | **Adapter identity** (vendor/driver), not OS. Marked `COMPAT(android-mali)` and correctly scoped to the *driver*, but applied to the *platform*; a non-Mali Android device pays the CPU-compute cost for nothing | +| `loki-renderer/src/page_paint_source.rs:233,235` | `AaConfig::Area` vs `Msaa16` | same as above — must match whatever `vello_init` compiled in, so the two move together | +| `loki-renderer/src/page_paint_render.rs:164,193` | Selection **teardrop handles**: emitted on Android, empty elsewhere | **Coarse pointer present.** A phone with a mouse attached should not show teardrops; a Windows touchscreen tablet should | +| `loki-vello/src/scene.rs:40,101`, `scene_cursor.rs:68` | doc comments asserting the above ("only shown on iOS and Android (controlled by `#[cfg(target_os)]`)") | update with the behaviour | + +### 2b. Platform API selection — compliant, leave alone (36) + +- `loki-text/src/app.rs:40,75,84`, `loki-text/src/lib.rs:31,40`, + `loki-presentation/src/app.rs:30,61,70`, `loki-spreadsheet/src/app.rs:30,61,70` + — Android window-inset queries and the `android_main` FFI entry point. There + is no `getRootWindowInsets` off Android. (Note: `loki-text/src/lib.rs:31,40` + is the duplicated entry point S0.4 documents — it is being deleted, not + migrated.) +- `loki-app-shell/src/{app_data.rs:18,22, recent_documents.rs:13,25,30,37,136, + android.rs:52,61}` — storage paths and the JNI context. +- `loki-bench/src/rss.rs:28,34` — `/proc/self/status` vs the unimplemented + macOS/Windows readers. +- `loki-layout/src/font.rs:253` — a comment. + +### 2c. Manifests + +`[target.'cfg(target_os = "android")'.dependencies]` in the three app crates +(android-activity, android_logger, jni). Dependency selection, not behaviour — +compliant. + +## 3. The `--cfg android_gpu` flag is the same problem, one level down + +`loki-renderer/Cargo.toml:24-27` declares a custom cfg set via +`RUSTFLAGS='--cfg android_gpu'` "when building for Vulkan-capable physical +devices". That is a **build-time answer to a runtime question**, and it means +one APK cannot serve both an emulator and a physical device — precisely the +single-binary property D-08 requires. Replacing it with the adapter probe in +§2a is the same change, and it should be done once for both. + +## 4. Probe routes per property + +| Property | Source | Live? | Platform notes | +| --- | --- | --- | --- | +| Viewport width/height | Already solved: `AtViewportWidthSensor` / `AtWindowSizeSensor` → `Signal` → `Breakpoint` | **Yes** | Driven by the shell's post-resize `onscroll` replay (`collect_scroll_containers`, S0.1). Works identically everywhere | +| Pointer precision (fine vs coarse) | Winit device events (`DeviceEvent::Added/Removed`, mouse vs touch); Android `InputDevice.getSources()` via JNI | **Yes, but not plumbed** | Winit surfaces device add/remove; blitz-shell does not forward it today → needs a small `blitz-shell` patch, the one genuinely new patch in the program. Interim heuristic: latch to coarse on the first touch event and to fine on the first real mouse motion, and allow both to be true at once | +| Hardware keyboard present | Android `Configuration.keyboard` / `hardKeyboardHidden` via JNI; desktop: assume present | **Android: yes** (config change on attach/detach); desktop: static | Only consumer is IME/safe-area, and per S0.4 §7 the inset query already returns the truth without needing this. Treat as advisory, not as a gate | +| Available system RAM | Linux `/proc/meminfo`; macOS `sysctl hw.memsize`; Windows `GlobalMemoryStatusEx`; Android `ActivityManager.MemoryInfo` | Sampled at startup | All need FFI. `loki-app-shell` is the right home (it already does JNI); keep `loki-bench` out of it | +| GPU memory | wgpu adapter limits + `AdapterInfo` (device type: discrete/integrated/cpu) | Sampled | wgpu exposes no VRAM figure portably. Use `device_type` + system RAM as the budget input; do not pretend to a byte count | +| Display physical size / DPI | Wayland `wl_output` physical mm; X11 RandR; macOS `CGDisplayScreenSize`; Windows EDID via `EnumDisplayDevices`; Android `DisplayMetrics.xdpi/ydpi` | Per display, re-query on move | **Frequently wrong or absent** — many monitors report 0×0 or a nominal size. R7 is real; per D-04 the calibration fallback is the primary path (T5.5) | +| Window mode (fullscreen-single vs windowed-multi) | Winit `Window::is_maximized`/`fullscreen`; Android: window size vs display size, and multi-window mode via `Activity.isInMultiWindowMode()` | **Yes** | Mostly derivable from measured viewport vs display size — prefer that over a platform call | +| Measurement system (T6a.7) | macOS `NSLocale` measurement system; Windows `LOCALE_IMEASURE`; Linux `LC_MEASUREMENT` env; else locale region; else metric; user setting wins | Sampled | Not in §3.5's table but needed by Phase 6; same plumbing, so land it with `DeviceProfile` | + +**Cannot be observed live, must be sampled:** system RAM, GPU class, display +physical size (re-query on display change, but there is no portable "DPI +changed" event we consume today). **Observable live:** viewport, pointer +precision (once plumbed), hardware keyboard (Android), window mode. + +## 5. Recommended shape for T1.6 + +```rust +/// Runtime device capabilities. Extends the existing responsive context; +/// `viewport`/`breakpoint` stay where they are and are not duplicated here. +#[derive(Clone, Copy, PartialEq)] +pub struct DeviceProfile { + pub pointer: PointerPrecision, // Fine | Coarse | Both + pub hardware_keyboard: bool, + pub system_ram_bytes: Option, + pub gpu_class: GpuClass, // Discrete | Integrated | Software | None + pub display: Option, // px_per_inch, per display id + pub window_mode: WindowMode, +} +``` + +Three properties matter more than the shape: + +- **It is a `Signal`, not a value read once.** Every consumer reads it through a + memo on the field it cares about, so a mouse being plugged in re-renders the + tooltip logic and nothing else. +- **It is injectable.** Unit tests construct synthetic profiles — this is R12's + mitigation and the only way any of Phases 2/4/5/7 gets tested without an + Android desktop device. Make the constructor take the probed values rather + than probing inside itself. +- **It lives in `appthere-ui` beside `AtResponsiveContext`**, provided by the + same `use_provide_responsive`-style root call, so there is one place that + answers "what kind of session is this". + +## 6. Sequencing note + +`loki-renderer` deliberately does not depend on `appthere-ui` — it duplicates +the Compact breakpoint constant and drift-locks it with a test in `loki-text` +(`reflow_metrics.rs:38-43`). §2a's biggest item (the renderer path choice) is in +`loki-renderer`, so `DeviceProfile` cannot simply be imported there. Either the +GPU-class probe lives in `loki-renderer` and is *reported up* into +`DeviceProfile`, or the profile moves to a lower crate. **Recommend the former**: +the renderer already owns the wgpu adapter, so it is the natural prober, and the +existing crate boundary stays intact. + +## 7. Consequences for the spec + +- R13: close. 11 behavioural sites, all listed. +- R12 (no Android desktop hardware): the injectable-profile mitigation is + sound and should be treated as the primary test strategy, with hardware as + confirmation. +- The `--cfg android_gpu` build flag should be added to §3.5's list of things + D-08 invalidates — it is not a `cfg!(target_os)` but it encodes the same + assumption. diff --git a/docs/spikes/S09.0-layout-residency-census.md b/docs/spikes/S09.0-layout-residency-census.md new file mode 100644 index 00000000..e28be548 --- /dev/null +++ b/docs/spikes/S09.0-layout-residency-census.md @@ -0,0 +1,1248 @@ + + +# S09.0 — Layout residency census + +| Field | Value | +| --- | --- | +| Spec | Loki Spec 09 §3 (Q1–Q7) — the stated deliverable of the first session on that spec | +| Date | 2026-07-25 | +| Status | Complete — seven questions answered, **E0 run warm on synthetic and real documents** (§10, §10a). Confirmed for body text; the rate varies sixty-fold across document classes while the evictable fraction stays within a factor of two | +| Method | Source audit of `loki-layout`, `loki-text`, `loki-renderer`, and the vendored `parley-0.10.0`, **plus a headless dhat measurement** (§10). Figures were computed from struct definitions first and then measured; where they disagree, §10 reconciles them. Every claim is cited to file and line. | + +## 1. Headline + +> **Read §10 and §10a first if you want the numbers.** §2–§9 were written from +> the struct definitions, before E0 was run. E0 confirmed the editing-residency +> figure to within 3% *for body text*, showed the total was under-counted by +> ~36 B/char for a reason the audit had not found, and then showed on real +> documents that per-character is a serviceable predictor for text (69–168 +> B/char, within ~2.4×) but fails completely for object-heavy content. The +> derived +> sections are left as written, with §10/§10a as the correction — the +> divergence is the useful record. §10a's first corpus table was itself wrong — +> warm-up contamination — and is kept alongside the corrected one for the same +> reason. + +Five findings change the shape of the problem: + +1. **The dominant cost is not one structure but several, and they nest.** + **~124 bytes per character** of body text was resident (measured), of which + **~70 bytes is editing residency** that Spec 09 can window (measured; + predicted 72). §2 derives it, §10 measures and reconciles it. **After S9-1 + and S9-2 it is 73.0 B/char total, 34.8 editing** — 41% off, with no eviction + machinery and no contract change (§10d, §10g). +2. **A third of the editing cost was two `Vec` index maps**, not glyph or + shaping data. `orig_to_clean` and `clean_to_orig` were one `usize` per byte + of text each — 16 bytes per ASCII character, comparable to Parley's entire + per-character footprint. S0.2 did not identify these. §2.3. **Shipped as + S9-2** (§10g): `u32` plus an identity representation removed ~16 B/char, and + ~96% of real-document bytes turn out to be in paragraphs that need no map at + all. +3. **The re-materialisation mechanism Q4 asks about already exists**, built for + a different purpose: `PageStart` checkpoints capture exactly the flow state + needed to resume layout at a page top. Its limitation is precise and + important — checkpoints exist only at **clean page tops**, so a page that + begins mid-paragraph has none. §5. +4. **The glyph items were stored three times and the index maps twice**, because + `ParaCache` held a full deep copy alongside the editing index. Found by + reconciling E0 against the audit, not by the audit. §10. **Shipped as S9-1** + (§10d): editing residency for body text fell **69.4 → 34.8 B/char** and total + **123.3 → 89.0**, a 27.8% cut with no eviction machinery at all. +5. **The evictable band is a property of documents, not a target for us.** What + we control is the fraction *of that* we actually reclaim; quoting the band as + a goal would score `acid-docx` as a success for reasons unrelated to any + implementation. §10a. **Post-S9-1 the band is 31–53% for text-bearing + documents** (98% object-heavy) — lower precisely because S9-1 reclaimed part + of it, which is what realisation looks like in this metric. The 45–63% figure + quoted in Spec 09 predates S9-1. +6. **The instrument was order-dependent, and fixing it corrected almost every + corpus figure** — by up to 252×. One-time costs, font loading above all, were + billed to whichever measurement ran first. The "floor artefact at 4.5k + characters" did not exist. §10a. +7. **Residency has two components: ~78 B/char keyed to paragraph *content* and + ~39 B/char paid per *placement*.** Fitted over four duplication factors with + residuals under 0.04 B/char. It sized S9-1 from measurement rather than + struct arithmetic — the `Arc` change removes the **per-placement** copy, not + the content-keyed one, corrected in §10c before implementation — and it means + boilerplate-heavy documents deduplicate for free. §10b. + +The good news for feasibility: editing data is a **pure function** of the +document (Q6, §7), so eviction is always safe in the correctness sense. The bad +news is Q2/Q7: at least one consumer scans every page, and the residency switch +today is a single document-wide boolean. + +## 2. Q1 — what is in `editing_data`, in bytes + +### 2.1 The containment chain + +``` +PaginatedLayout.pages: Vec> result.rs:105 + └ LayoutPage.editing_data: Option result.rs:148 + └ PageEditingData.paragraphs: Vec result.rs:27 + └ PageParagraphData.layout: Arc result.rs:42 + ├ items: Vec para_layout_types.rs:198 + ├ parley_layout: Option> …:220 + ├ orig_to_clean: Vec …:222 + └ clean_to_orig: Vec …:224 +``` + +`Arc` is shared, not copied: a paragraph split across three +pages is referenced three times and stored once (`flow_split.rs`, `arc_layout.clone()`). +So the per-page cost is proportional to the text *on* that page, not to +paragraph count. + +### 2.2 Per-character arithmetic + +| Contributor | Structure | Bytes/char | Cited | +| --- | --- | --- | --- | +| Parley clusters | `ClusterData` — info + flags + style_index + glyph_len + text_len + glyph_offset + text_offset + advance | ~20 | `parley-0.10.0/src/layout/data.rs:17` | +| Parley glyphs | `Glyph { id: u32, style_index: u16, x, y, advance: f32 }` | 20 | `parley-0.10.0/src/layout/glyph.rs:6` | +| Our glyph copy | `GlyphEntry { id: u16, x, y, advance: f32 }` | 16 | `items.rs:152` | +| Byte index maps | `orig_to_clean` + `clean_to_orig`, `usize` each | 16 | `para_layout_types.rs:222,224` | +| **Editing subtotal** | | **~72** | measured 70.1 — §10 | +| Page paint copy | `content_items` glyph runs, translated | 16 | `result.rs:131` | +| **Total resident** | | **~88** | **measured 124** — this table misses the `ParaCache` copies, §10 | + +Per-run and per-line overheads sit on top: `RunData` carries four `Range` +plus `fontique::Attributes`, `Synthesis` and `RunMetrics` (~150–200 B, once per +font/style span, `data.rs:119`); our `PositionedGlyphRun` adds an origin, an +`Arc>` font handle, colour, synthesis, variation coords and an optional +`link_url` (~100 B/run, `items.rs:114`). Both are per-run, so they are noise +next to the per-character terms in body text and significant only in heavily +mixed-formatting content. + +### 2.3 The finding S0.2 missed + +`orig_to_clean` and `clean_to_orig` are **8 bytes per byte of source text +each**. For ASCII body text that is 16 B/char — as much as Parley's glyph array +and cluster array cost individually, and about 22% of total residency. They are +plain index maps between original and cleaned byte offsets. + +They are also the cheapest thing on this list to shrink, and shrinking them +needs no residency architecture at all: a document paragraph long enough to +matter still has offsets that fit in `u32`, which halves the term, and most +paragraphs have no cleaning divergence at all — for those the map is the +identity function and could be represented as a marker rather than a vector. +**This is worth doing regardless of how Spec 09's main question resolves**, and +it should be split out as its own small change rather than absorbed into an XL +one. + +### 2.4 Worked figures + +A dense US Letter page of body text ≈ 500 words ≈ 3,000 characters: + +| Document | Editing residency (70 B/char, measured) | Total layout (124 B/char, measured) | +| --- | --- | --- | +| 10 pages | ~2.1 MB | ~3.7 MB | +| 100 pages | ~21 MB | ~37 MB | +| 500 pages | ~105 MB | ~186 MB | +| 1000 pages | ~210 MB | ~372 MB | + +These are *text* figures. They exclude images, tables, and the Loro oplog, and +they assume dense pages; a typical document runs lighter per page. Treat them as +an order of magnitude, and see §8 before targeting any of them. + +## 3. Q2 — who needs `editing_data` for a page that is not visible + +Complete inventory of consumers (`grep` over `loki-text`, `loki-renderer`, +`loki-layout`): + +| Consumer | Site | Pages touched | Survives eviction? | +| --- | --- | --- | --- | +| Hit-testing | `editing/hit_test.rs:168,239` | the page under the pointer | **Yes** — visible by definition | +| Caret reveal | `editing/caret_reveal.rs:130` | the caret's page | **Yes** — caret's page is being scrolled to, so materialise-then-reveal is natural | +| Selection handles | `editing/selection_handles.rs:54` | the selection's two edge pages | **Mostly** — a selection can extend off-screen | +| Caret painting | `renderer/page_paint_render.rs:67` | the painted page | **Yes** — a painted page is resident anyway | +| Page location | `editing/page_locate.rs:57` | the caret's page ± neighbours | **Yes** | +| Ctrl-key commands | `routes/editor/editor_keydown_ctrl.rs:81` | the caret's page | **Yes** | +| Arrow navigation | `editing/navigation*.rs` | caret's page **and its neighbour** | **Yes, with a ±1 band** — up/down at a page boundary reads the adjacent page | +| **Nested-block search** | `editing/navigation_find.rs:57` `nested_para_page` | **every page in the document** | **No** — see below | +| Reflow navigation | `editing/reflow_nav.rs` | n/a — `ContinuousLayout`, a different structure | separate problem | + +**`nested_para_page` is the one that breaks.** Its own doc comment explains why +it exists: "A table cell's blocks can flow across a page break, so the sibling +of the focus need not live on the focus's page; a page-local search would miss +it and strand the caret." It resolves that with +`layout.pages.iter().position(…)` — a linear scan of every page's editing data, +on an arrow-key press inside a table. + +Under windowed residency that scan either materialises the whole document (which +defeats the point) or silently fails to find the sibling (which strands the +caret — the failure mode Spec 09 §4 predicts will "look like corruption rather +than like a cache miss"). It needs a **block-index → page index** map maintained +outside the evictable data. That map is small, derivable during flow, and would +also make the scan O(1) instead of O(pages) — so it is a win independent of +residency. + +## 4. Q3 — can `pages` be sparse or lazy? + +Two things are conflated in `LayoutPage` and they have different answers. + +**Page geometry can be kept.** `page_number`, `page_size`, `margins`, +`header_height`, `footer_height` are small and fixed-size, and the scroll +container needs every page's height to compute total content height and page +slots (`document_view.rs:108` derives tile boxes from `page_size_pts(i)` for all +`i`). Dropping geometry would break scrollbar extent and the page indicator. + +**Page content can be dropped.** `content_items`, `header_items`, +`footer_items`, `comment_items` and `editing_data` are the bulk and are only +needed to paint or to edit that page. + +So the answer to Q3 is: **a sparse representation is viable, but the split is +within `LayoutPage`, not across `pages`.** The vector stays dense; each entry +keeps a small always-resident geometry header and an evictable content payload. +That is a much less invasive change than a sparse `pages` collection, and it +preserves every index-based access already in the tree (`layout.pages[i]` is +used widely). + +One caveat: pagination is sequential, so page N's *geometry* is only known by +having flowed pages 0..N once. Lazily materialising a page that has never been +laid out is a different and harder problem than re-materialising one that was +laid out and then dropped. Spec 09 should scope itself to the latter. + +## 5. Q4 — what does re-materialising a dropped page cost? + +**The mechanism already exists.** `PaginatedReuse.checkpoints: Vec` +(`incremental.rs:82`) records, per page, "which page started, in which section, +at which section-local block, and the `FlowCheckpoint` needed to resume there" +(`incremental.rs:59-64`). `FlowCheckpoint` captures precisely the state that +makes a page's layout non-local: page number, per-list counters, previous list +id, note counter, accumulated indent (`incremental.rs:46-57`). + +So re-materialising page N costs *flowing one page from its checkpoint* — not a +document relayout. That is the bounded cost Q4 hoped for, and it is already +exercised in production by `relayout_paginated_incremental`, with an +`incremental == full` property test behind it (`incremental.rs:94-97`). + +**The limitation is precise and must be designed around.** Checkpoints are +pushed only when `state.cursor_y == 0.0 && state.current_items.is_empty()` +(`flow_run.rs:136-151`) — a *clean page top at a block boundary*. A page that +begins mid-paragraph, which is the common case for continuous prose, **has no +checkpoint**. Re-materialising such a page means resuming from the last clean +checkpoint at or before it and flowing forward through the intervening pages. + +That gives a cost model rather than a constant: + +- **Bounded** for documents whose paragraphs are short relative to a page + (structured documents, lists, tables): the nearest checkpoint is usually the + page itself or the one before. +- **Unbounded in the worst case** for a document that is one enormous paragraph: + the only checkpoint may be the document start. + +A residency design should therefore either (a) treat "distance to the previous +checkpoint" as the eviction cost function, keeping pages whose recovery is +expensive, or (b) extend the checkpoint mechanism to mid-paragraph resume, which +means capturing the paragraph's fragment offset as well. (b) is the more +principled fix and is where the real work is. + +Also note the eligibility gate: incremental reuse is disabled entirely when the +document has footnotes (`incremental.rs:110`), because notes render at section +end and a content change can repaginate the tail. Any residency scheme built on +checkpoints inherits that restriction unless it is lifted. + +## 6. Q5 — how much is Parley's, held indirectly? + +**About 40 of the ~72 editing bytes per character (~55%)** live inside +`parley::Layout`, reachable only through the `Arc>` +in `ParagraphLayout` (`para_layout_types.rs:220`). Breakdown in §2.2: +`ClusterData` and `Glyph` arrays dominate; `RunData`, `LineData`, +`LineItemData`, `styles`, `fonts` and `coords` are per-run or per-line. + +Two consequences: + +- It is **not ours to shrink** — the representation is Parley's, and short of a + different text stack the only lever is *not retaining it*. Which is exactly + what `preserve_for_editing: false` already does. +- It is **cleanly droppable**: it sits behind one `Option>`, so a design + could evict Parley layouts alone while keeping our lighter items and index + maps — a partial-eviction tier at ~40 B/char, roughly half the editing cost, + without touching any consumer that does not hit-test or query cursor geometry. + +That second point is worth taking seriously as a cheaper first step than full +page eviction: `hit_test_point` and `cursor_rect` are the only two operations +that need the Parley layout (`para_query.rs:23`), and both are caret operations +that by definition happen where the user is looking. + +## 7. Q6 — interaction with Loro + +**Editing data carries no state the CRDT does not have.** The chain is +Loro → `Document` → `layout_paginated_*` → `PaginatedLayout`, a pure function of +(document, fonts, options). Every consumer in §3 takes `editing_data` by +`as_ref()`; nothing mutates it in place. + +So eviction is **safe in the correctness sense**: anything dropped can be +recomputed from the CRDT, and no user data is lost by dropping it. This is the +single most encouraging finding for Spec 09 — the question is entirely one of +cost and of consumer contracts, not of correctness. + +The one place to be careful is `derive_loro_cursor` and the position types that +cross the boundary: they address the document by `(block_index, byte_offset, +path)`, which is CRDT-space, not layout-space. That is the right direction — +positions survive eviction because they do not reference layout objects. + +## 8. Q7 — what breaks if a page's layout is absent? + +**Today: everything, silently, because absence is not representable per page.** +`preserve_for_editing` is one document-wide boolean (`options.rs:22`, gate at +`flow_dispatch.rs:158`). `editing_data` is `Option`, but the +`None` case means "read-only document", not "evicted page" — and every consumer +in §3 treats `None` as "give up quietly" via `?` or `as_ref()?`. + +That is the trap Spec 09 §4 names. Under windowed residency those same `?` +operators would turn an evicted page into a **silently wrong answer**: the caret +does not move, find skips a match, the selection handle vanishes. No error, no +log, no crash — behaviour indistinguishable from a bug in the feature itself. + +The contract change is therefore not optional, and it is the actual work: + +- `None` must stop meaning two things. Distinguish "this layout was built + without editing data" from "this page's editing data is currently evicted" — + they demand opposite responses (give up vs. materialise and retry). +- Consumers split into two classes: those that can **materialise on demand** + (everything in §3 that touches the caret's page) and those that need a + **residency-independent index** (`nested_para_page`, §3). +- Any consumer that cannot do either must hold a **pin** for the duration of its + work, so eviction cannot occur underneath it. + +## 9. Recommendations for Spec 09's phase plan + +Ordered by (value ÷ risk), not by dependency: + +0. **Cache `Arc` rather than `ParagraphLayout`** (§10). Found + by E0's reconciliation, not by the original audit: the cache and the editing + index each hold a full deep copy. Sharing one allocation removes ~32 B/char — + about **26% of total residency** — from a change confined to `ParaCache` and + its callers. Largest win per unit of risk on this list, and it displaces the + index maps as the first thing to do. +1. **Shrink the index maps** (§2.3). ~16 B/char → ~4 B/char or less, no + architecture, no consumer contract change, no residency machinery. Roughly a + fifth of the editing residency for a fraction of the effort. Note that step 0 + already removes one of the two copies, so this compounds with it rather than + overlapping. +2. **Build the block → page index** (§3). Required by residency, but valuable + on its own: it removes an O(pages) scan from an arrow-key press. +3. **Evict Parley layouts only** (§6). One `Option>`, ~40 B/char, + two well-identified consumers. A real reduction that exercises the + materialise-on-demand path at a fraction of the blast radius. +4. **Then** consider full page-content eviction (§4, §8), with the checkpoint + cost model and the contract change. This is the XL part and it should not be + started until 1–3 have shown what the residual actually is. + +That ordering also has a property worth naming: each step is independently +shippable and independently measurable, so if the profiled numbers (§10) +disagree with the derived ones, the plan degrades gracefully instead of being +invalidated. + +## 10. E0 — the gating experiment, run (2026-07-25) + +Spec 09 §4 gates the whole phase plan on E0 and describes it as a manual RSS +comparison across two process runs, requiring real hardware. **It does not.** +Layout is CPU-only — Parley shaping plus our pagination; the GPU is involved in +painting, not layout — so the experiment runs headless, and dhat measures live +heap bytes directly rather than through an RSS proxy. That also disposes of both +methodological caveats §4 raises: the full layout pass is forced by +construction, and allocator retention cannot mask anything dhat counts. + +It is committed as `loki-bench/benches/layout_editing_residency.rs`, so it is +repeatable and becomes the regression guard for S9-1 … S9-4 rather than being +spent once. + +### Result + +| Tier | chars | editing residency | total resident | +| --- | --- | --- | --- | +| small (10 paras) | 4,524 | 411.8 B/char | 471.1 B/char | +| medium (60 paras) | 27,173 | **70.2 B/char** | 124.8 B/char | +| large (250 paras) | 113,394 | **70.1 B/char** | 124.0 B/char | + +**The census's headline number is confirmed.** Predicted 72 B/char of editing +residency; measured 70.1–70.2, an error under 3%. It is also flat across a 4× +change in document size, which is what a genuinely per-character model predicts +and what a model contaminated by fixed overheads would not show. The small tier +is dominated by per-document fixed costs at only 4.5k characters and should be +read as a floor artefact, not a data point. + +**Spec 09 L9-005 is satisfied and the phase plan is unblocked.** + +### But the total was under-counted, and the reason is a third copy + +Predicted total 88 B/char; measured 124. The 36 B/char gap has a specific +cause, found while E0 was building: **`ParaCache` stores `ParagraphLayout` by +value** (`para_cache.rs:41`, `HashMap`), `get` returns a +**clone** (`para_cache.rs:48-57`), and the flow then does +`Arc::new(para_layout.clone())` again when populating editing data +(`flow_para_place.rs:68`). So a cached paragraph's glyph items exist **three** +times — cache copy, editing `Arc` copy, page paint copy — and its index maps +**twice**. + +Reconciling against the measurement: + +| Contributor | copies | B/char | +| --- | --- | --- | +| Parley cluster + glyph arrays (`Arc`-shared, counted once) | 1 | ~40 | +| `GlyphEntry` items | 3 | ~48 | +| Byte-index maps | 2 | ~32 | +| **Model total** | | **~120** (measured 124) | + +And the editing half: `preserve_for_editing: false` drops the Parley layouts +(~40) **and** the whole `editing_data` `Arc` copy — its items (~16) and index +maps (~16) — for ~72 B/char. Measured 70. The two independent arithmetic paths +agree with each other and with the instrument. + +### The new cheapest win + +**Cache `Arc` instead of `ParagraphLayout`.** The cache and the +editing index would then share one allocation instead of holding two deep +copies, removing ~32 B/char — items and maps both — for a change confined to +`ParaCache` and its callers. No eviction machinery, no contract change, no +consumer audit. + +That is **about 26% of total residency**, larger than S9-1's index-map +shrinking and at comparable cost. The page's `content_items` copy must stay: +those are translated into page space, so they are genuinely different values +rather than a redundant copy. + +It should be sequenced **before** S9-1, and both should land before any +eviction work — between them they take ~48 B/char off a 124 B/char footprint +without touching the residency architecture at all. + +## 10a. Real documents — measured warm, after the instrument was fixed + +E0 was extended with the conformance corpus. The first run produced a table that +was **almost entirely warm-up artefact**, and chasing an inconsistency in it — +`para-carlito` reading 71.6 B/char at 377 characters while a 4,524-character +synthetic tier read 411.8 — found the cause. + +### The instrument was order-dependent + +One-time costs land inside whichever `measure()` runs first. There are two +layers of them, and each needed a different fix: + +- **Process-wide** (Parley context, first-use caches) — billed to the first tier + measured. A single warm-up pass before any measurement fixes it. +- **Per-document** (font loading for the faces that document uses) — billed to + each document's own first measurement. Only a per-document warm-up fixes it. + +`para-carlito` was never anomalous: it ran late, so its costs were already paid, +while the synthetic small tier ran first and absorbed everything. Both readings +were correct about an instrument that was order-dependent. + +**There is no floor artefact at 4.5k characters.** Warm, the 10-paragraph tier +reads 69.5 B/char — indistinguishable from the 250-paragraph tier at 69.4. The +"floor" was the warm-up, and the `RATE_FLOOR_CHARS` flag now marks small rows +only as a reader caution, not as a known contamination. + +### What the correction was worth + +| Document | Cold (first run) | Warm | Factor | +| --- | --- | --- | --- | +| synthetic small | 411.8 | 69.5 | 5.9× | +| synthetic medium / large | 70.2 / 70.1 | 69.4 / 69.4 | 1.0× | +| `styles-tinos` | 39,264.8 | **155.7** | **252×** | +| `para-gelasio` | 841.2 | 84.8 | 9.9× | +| `acid2-docx` | 349.1 | 168.1 | 2.1× | +| `iris-blueprint` | 176.4 | **117.5** | 1.5× | +| `para-carlito` | 71.6 | 71.6 | 1.0× | +| `acid-docx` | 3,950.4 | 4,191.2 | 0.9× | + +Only the three rows that were already warm — the two large synthetic tiers and +`para-carlito` — survived unchanged. Every other corpus figure previously +recorded was contaminated. + +### The corrected table + +| Document | chars | editing B/char | total B/char | evictable | +| --- | --- | --- | --- | --- | +| synthetic small | 4,524 | 69.5 | 128.8 | 54.0% | +| synthetic medium | 27,173 | 69.4 | 124.0 | 56.0% | +| synthetic large | 113,394 | 69.4 | 123.3 | 56.3% | +| `para-carlito` | 377 | 71.6 | 140.9 | 50.8% | +| `para-gelasio` | 172 | 84.8 | 188.9 | 44.9% | +| `styles-tinos` | 45 | 155.7 | 334.0 | 46.6% | +| `acid2-docx` | 4,042 | 168.1 | 292.6 | 57.5% | +| `iris-blueprint` | 24,832 | **117.5** | 186.0 | 63.2% | +| `acid-docx` | 5,477 | **4,191.2** | 4,277.7 | **98.0%** | + +Three conclusions change: + +1. **The "2.5× worse" figure drops to 1.7×.** `iris-blueprint` reads 117.5 + against the synthetic 69.4. Real formatting still costs more per character, + but by considerably less than the cold run implied. +2. **The rate is far more consistent than it looked.** Excluding `acid-docx`, + the corpus spans 69–168 B/char rather than 71–3,950. Plain prose + (`para-carlito`, `para-gelasio`) sits within 22% of the synthetic rate. +3. **The evictable band is wider, not narrower.** 44.9%–63.2% across everything + except `acid-docx`, which sits at **98%** — nearly all of its residency is + editing data. So the band is **45–63% for text-bearing documents, with + object-heavy documents far higher**, rather than the 49–74% the cold run + suggested. L9-008's premise survives — the fraction moves within a factor of + two while the rate moves by sixty — but the band itself needs restating. + +`acid-docx` remains a category error rather than an outlier: its residency is +images and tables, so a per-character denominator does not describe it. Its 98% +evictable is the informative part — object-heavy content is *more* amenable to +eviction, not less. That reinforces L9-010's two-denominator point. + +### Size at constant formatting is still unanswered, and repetition cannot answer it + +The proposed cheap test — concatenate `iris-blueprint` to ×10 — **does not +work, and the bench says so in its own output.** Repeating blocks makes them +byte-identical, so `ParaCache` keys collide and nine of every ten paragraphs are +cache hits rather than fresh layouts. The cache then holds a tenth as many +entries per character while `editing_data` still holds an `Arc` per placement, +so the rate falls (117.5 → 47.1) for a reason unrelated to size. + +The row is kept, labelled "not evidence", because the failure is the lesson: +scaling by repetition changes the cache-hit profile, and any future attempt to +synthesise scale must vary content as well as length. **Answering the size +question needs larger real fixtures.** + +## 10b. Residency has two components, and the failed experiment measured them + +The ×10 repetition could not answer the size question, but it turned out to +answer a better one. Repeating blocks makes them byte-identical, so `ParaCache` +keys collide: at ×n only `1/n` of paragraphs are distinct content, while +`editing_data` still holds an `Arc` per placement. Writing `x = unique/total`, +residency per character is + +``` +rate(x) = P + C·x +``` + +where **P is the per-placement cost every copy pays** and **C is the +content-keyed cost that deduplicates**. Run at ×1, ×2, ×5 and ×10 on +`iris-blueprint` and fitted: + +| x = unique/total | measured B/char | fitted | residual | +| --- | --- | --- | --- | +| 1.00 (×1) | 117.5 | 117.5 | +0.00 | +| 0.50 (×2) | 78.4 | 78.4 | +0.00 | +| 0.20 (×5) | 54.9 | 54.9 | −0.04 | +| 0.10 (×10) | 47.1 | 47.1 | −0.02 | + +**C = 78.2 B/char content-keyed, P = 39.3 B/char per-placement.** Residuals are +under 0.04 B/char across a range that moves by 2.5×, so the two-component model +is exact to measurement noise rather than a two-point coincidence. + +Three things follow. + +**It sizes S9-1 precisely.** Sharing one allocation between `ParaCache` and the +editing index is now costed from measurement rather than from struct +arithmetic. ~~It removes a copy of the *content-keyed* portion — the 78.2, not +the 39.3.~~ **Wrong, and corrected in §10c before implementation:** the copy +S9-1 removes is the **per-placement** one. The content-keyed copy is the cache +entry being *shared into*, not the copy being removed. + +**It is a product fact, not only a bench fact.** Residency is per unique +paragraph content plus per placement, so documents built from repeated +boilerplate — form rows, repeated headers, template blocks — deduplicate for +free. A flat B/char figure overstates them, and any target set from body-text +rates will be conservative for exactly the documents most likely to be large. + +**It independently supports L9-008.** The evictable fraction across the sweep is +63.2 / 63.1 / 62.9 / 62.8 % while the rate moves 2.5×. That is stronger evidence +than the cross-document band, because everything except duplication is held +constant — a controlled transformation that moves the rate substantially and +leaves the fraction within 0.4 points. + +**Caveat that remains.** One document, one axis. The decomposition is clean but +it is `iris-blueprint`'s formatting profile only, and duplication is the sole +variable exercised. It does not answer size-at-constant-formatting, which still +needs larger real fixtures. + +## 10c. S9-1's predicted split, recorded before implementation (L9-013) + +Spec 09 L9-013 requires each residency step to write down its predicted effect +on `C` and `P` *before* the change, so the re-run sweep tests the ownership model +rather than merely confirming a saving. This section is that record. Nothing in +`loki-layout` had been edited when it was written. + +### What each coefficient actually is + +`C` and `P` decompose the **editing delta** — `preserve_for_editing` on minus +off — not total residency. So the question is what that flag adds, and whether +each addition is paid per unique paragraph content or per placement. + +It adds exactly two things: + +| # | What | Where | Paid per | Coefficient | +| --- | --- | --- | --- | --- | +| 1 | `parley_layout: Some(Arc)` | inside the cached `ParagraphLayout` (`para_layout_types.rs:220`) | **unique content** | **C** | +| 2 | `Arc::new(para_layout.clone())` for the editing index | `flow_para_place.rs:68`, `:103`, `:125` | **placement** | **P** | + +Item 1 is content-keyed because `preserve_for_editing` is part of the cache key +and the Parley layout is already behind an `Arc` — a cache *hit* clones the +`ParagraphLayout` struct but only refcount-bumps this field, so `n` identical +paragraphs share one Parley `Layout`. Item 2 is per-placement because +`place_paragraph_layout` clones unconditionally, hit or miss, once per placed +paragraph; that clone deep-copies `items`, `line_boundaries`, `orig_to_clean` +and `clean_to_orig`. + +So the fitted coefficients name specific allocations: + +- **C = 78.2 B/char is the Parley `Layout` object.** Worth noting on its own: + §6 estimated ~40 B/char for Parley's cluster and glyph arrays from struct + definitions, so the measured figure is **1.9× the estimate**, and Parley is a + larger share of editing residency than the census assumed. That is S9-4's + target, and S9-4 is correspondingly more valuable than ranked. +- **P = 39.3 B/char is one duplicated `ParagraphLayout` body** — items, line + boundaries, and both index maps. §2.2's `orig_to_clean` + `clean_to_orig` at + 16 B/char is the S9-2-shaped part of it. + +### The prediction + +S9-1 stores `Arc` in `ParaCache`, so the editing index holds +`Arc::clone` of the cache's own allocation instead of a fresh deep copy. That +deletes item 2 and leaves item 1 untouched: + +| | Now | Predicted after S9-1 | +| --- | --- | --- | +| **C** (content-keyed) | 78.2 | **78.2 — unchanged** | +| **P** (per-placement) | 39.3 | **≤ 3** | +| rate at x = 1 | 117.5 | **~79–81 (−31 to −33%)** | + +`P` does not reach zero. Three residuals keep it above the floor: the 8-byte +`Arc` pointer plus `PageParagraphData` bookkeeping per placement (well under +1 B/char at ~60 chars per paragraph), and — the larger term — **paragraphs the +flow mutates after layout**. Inline images, floats, and picture bullets are +pushed into `items` *after* `layout_paragraph` returns, so those paragraphs must +clone-on-write and keep paying `P` in full. `iris-blueprint` is image-bearing, +so the surviving `P` is a direct measure of what fraction of its placements are +mutated. If `P` lands near 3, few are; if it lands near 15, many are, and the +clone-on-write path is worth attention in its own right. + +**This prediction contradicts what §10b originally said**, which is the point of +recording it. §10b asserted S9-1 removes a copy of the content-keyed portion. It +does not: the content-keyed copy is the cache entry, which is the allocation +being shared *into*. The copy that disappears is the per-placement one. The +error came from reasoning about the change's *description* ("cache and editing +index share one allocation") instead of about which of the two allocations +survives. + +### What each outcome would mean + +- **P falls to ≤3, C holds** — the ownership model is right. +- **C falls instead** — the model is wrong about the Parley layout being shared + across cache hits; something in the per-unique cost is being removed that + should not be reachable by this change. +- **Both fall** — S9-1 removed more than intended; check whether the read-only + condition also changed, which would mean the delta moved for a reason + unrelated to editing residency. +- **Neither moves** — the editing `Arc` was not a distinct allocation to begin + with, and §2.2's three-copies diagnosis is wrong somewhere. + +The old ~32 B/char estimate from struct arithmetic is superseded: re-derived +against the decomposition, S9-1's ceiling is **39.3 B/char** on this document. + +## 10d. S9-1 shipped — what the prediction got right, and what it missed + +S9-1 stores `Arc` in `ParaCache`, so the editing index shares +the cache's allocation instead of deep-copying it. Measured on the same warm +instrument, before and after, with the ordering control reading 0.0% drift both +times: + +| | Before | After | Predicted (§10c) | +| --- | --- | --- | --- | +| **C** content-keyed | 78.2 | **78.2** | 78.2 — unchanged | +| **P** per-placement | 39.3 | **1.1** | ≤ 3 | +| iris ×1 editing rate | 117.5 | **79.3** | ~79–81 | + +`C` is unchanged to the last printed digit and `P` collapsed to 1.1 B/char. The +ownership model in §10c is therefore right: `C` is the Parley `Layout` behind an +already-shared `Arc`, `P` was one duplicated `ParagraphLayout` body per +placement, and S9-1 removes the second without touching the first. + +`P = 1.1` also answers the sub-question §10c posed. Paragraphs the flow mutates +after shaping — inline images, floats, picture bullets — must clone-on-write and +keep paying `P` in full, so a large residual would have meant many mutated +placements. At ~60 chars per paragraph, 1.1 B/char is about 66 bytes per +paragraph: the `Arc` pointer and `PageParagraphData` bookkeeping, and almost +nothing else. Even in an image-bearing document, copy-on-write is rare. + +### Full before/after + +| Document | Editing before | after | Total before | after | +| --- | --- | --- | --- | --- | +| synthetic small (10p) | 69.5 | 31.8 | 128.8 | 90.2 | +| synthetic medium (60p) | 69.4 | 34.5 | 124.0 | 89.2 | +| synthetic large (250p) | 69.4 | 34.8 | 123.3 | **89.0** | +| acid2-docx | 168.1 | 114.6 | 292.6 | 243.0 | +| iris-blueprint | 117.5 | 79.3 | 186.0 | 150.2 | +| iris ×10 | 47.1 | 8.9 | 75.0 | 37.1 | + +**The total lands on the census's original prediction.** §2 predicted ~88 B/char +total for body text from struct definitions; the instrument measured 124, and +§2.2 diagnosed the 36 B/char gap as one copy too many. Remove that copy and the +same tier reads **89.0**. A model that was wrong by a specific amount, for a +specific reason, becomes right when the reason is removed — which is a stronger +result than the original agreement would have been. + +### The prediction missed something, and the instrument caught it + +Editing residency fell exactly as predicted, but the **read-only** condition +*rose* by ~11 B/char — visible only because the bench reports the total +alongside the delta. §10c listed "both fall" as a possible surprise and did not +anticipate this one: one side improving while the other regresses. + +The rise was exactly content-keyed (+11.1 B/char at x = 1, +1.2 at x = 10, a +tenth), which located it in the cache entry. The cause: `put(key, result.clone())` +was doing more than storing a copy. `Vec::clone` allocates capacity equal to +`len`, and the clone is deep, so **the cached layout was silently compacted at +every level** while the push-grown original stayed transient. Moving the original +into the `Arc` reverses which one survives, and the entry inherits up to 2× of +doubling slack in the glyph vectors — long-lived, because cache entries are. + +Fixed by making the compaction explicit: `ParagraphLayout::shrink_to_fit` and +`PositionedItem::shrink_to_fit`, called once per cache miss. It must recurse into +the nested glyph vectors — a shallow shrink of the top-level `Vec`s recovered +only about a quarter of it (4 of 11 B/char), because the slack that matters is +inside `PositionedGlyphRun::glyphs`. With the deep version, read-only residency +returns to baseline (+0.3 B/char on the large tier). + +Two things worth carrying forward. **An accidental optimisation is load-bearing +until someone measures it** — nothing named the compaction, no test covered it, +and removing the clone removed it silently. And **a residency change needs both +numbers**: a bench reporting only the delta it targets would have shown a clean +36% win and hidden a 20% regression next to it. + +### L9-013 assessed + +The protocol worked, and the part that paid was not the confirmation. Deriving +the prediction required reading the code closely enough to say which allocation +each coefficient *was*, and that reading is what found the error in §10b — a +claim already written into the spec, corrected before it could size the wrong +work. The measurement afterwards took minutes; the derivation is where the value +sat. Worth keeping for S9-2, where the same trap exists: the index maps look +content-keyed and are, but they are copied per placement too. + +## 10e. R9-14 answered — copy-on-write is enforced by the type, not by review + +The question was whether S9-1's copy-on-write is `Arc::make_mut` or a manual +refcount check, because the two give guarantees of different kinds. It is +`make_mut`, at both mutation sites — `flow_para.rs:162` and +`flow_para_chain.rs:177` — and there is no manual refcount check, no +`Arc::get_mut`, anywhere in the crate. + +That makes the guarantee structural. `Arc` hands out `&T` only, so obtaining +`&mut ParagraphLayout` from a shared layout is impossible without `make_mut` or +`get_mut`. A new mutation path that forgets to take a private copy does not +compile — it is an E0596 borrow error at the mutation, not a silent +cross-placement corruption. Three properties close the remaining routes: + +- **No interior mutability** anywhere in `ParagraphLayout` or anything it owns. + (The `Mutex`es in the crate are in `font_handle.rs`, on `FontResources`.) +- **`#![forbid(unsafe_code)]`** at the crate root, so there is no aliasing escape + hatch below the borrow checker. +- **Owned copies stay safe.** The public `layout_paragraph` returns an owned + `ParagraphLayout` via `Arc::unwrap_or_clone`; its fields are `pub` and freely + mutable, but it is unshared by construction. + +**One shape to watch, since it is the silent one.** `Arc::get_mut` returns +`Option`, so `if let Some(l) = Arc::get_mut(&mut layout) { … }` compiles, skips +the mutation whenever the layout is shared — which is always — and reports +nothing. That fails quietly in the L9-009 sense rather than loudly. It is not +present today; the rule is that CoW on a layout is `make_mut`, and `get_mut` on +a `ParagraphLayout` should be treated as a defect on sight. + +So the completeness of the trigger set is not something the trigger set has to +carry. It is carried by `Arc`. + +## 10f. S9-2's predicted effect, recorded before implementation (L9-013) + +S9-2 shrinks `orig_to_clean` / `clean_to_orig` — one `usize` per source byte +each, 16 B/char for ASCII (§2.3). + +### The prediction is that C and P do not move at all + +This is the interesting part, and it is a consequence of S9-1. The maps are +built by `clean_text_and_spans` unconditionally, so they are present whether +`preserve_for_editing` is on or off, and **after S9-1 they exist in exactly one +place** — the shared cache entry. Identical in both conditions means they cancel +in the difference: + +| | Now | Predicted after S9-2 | +| --- | --- | --- | +| **C** content-keyed | 78.2 | **78.2 — unchanged** | +| **P** per-placement | 1.1 | **1.1 — unchanged** | +| synthetic large **total** | 89.0 | **~73** | +| iris-blueprint **total** | 150.2 | **134–142** | + +**The duplication sweep is therefore the wrong instrument for S9-2**, and saying +so in advance is the point. Before S9-1 the maps were copied per placement and +*were* part of `P`; the same change measured a year earlier would have moved a +coefficient. Now the whole effect lands in the read-only baseline that both +conditions share, and a bench reporting only its target delta would show S9-2 +achieving precisely nothing. This is L9-014 arriving with a worked example one +step after it was written. + +If `C` does move, the claim that the maps are condition-independent is wrong — +most likely because some part of them is built only under `preserve_for_editing` +in a path this reading missed. + +### Sizing the total + +`orig_to_clean` is `text.len() + 1` entries; `clean_to_orig` is one per retained +byte. At 8 bytes each that is ~16 B/char for ASCII, ~8 after `u32`. The identity +representation removes the rest wherever the cleaner dropped nothing. + +The two tiers should therefore diverge, and by how much says something real about +the corpus: + +- **Synthetic large: ~73** (−16). Its text is words, digits and spaces — no + tabs, no control characters, no BOM — so every paragraph is the identity + function and both maps disappear. +- **iris-blueprint: 134–142** (−8 to −16). Tabs *are* dropped by the cleaner and + are common in real documents, so paragraphs containing them keep a halved + `Vec` while the rest go to identity. Where iris lands in that band is a + free measurement of its tab density, and it predicts what real documents get: + nearer −8 means the identity case is rarer in practice than the synthetic tier + suggests, which would matter for S9-5's sizing too. + +Contained: both maps are private to `loki-layout`, used only by `para_query.rs` +and `para.rs`, with no cross-crate consumers. + +## 10g. S9-2 shipped — the prediction held, including the null one + +`ByteIndexMap` replaces the two `Vec` maps: `u32` entries in a +`Box<[u32]>`, and an `Identity { len }` variant for the paragraphs where the +cleaner dropped nothing. Compacted once per cache miss, at the point the map is +stored, so the construction logic — which needs random-access mutation for the +drop-cap rebase — is untouched. + +| | Before | After | Predicted (§10f) | +| --- | --- | --- | --- | +| **C** content-keyed | 78.2 | **78.2** | unchanged | +| **P** per-placement | 1.1 | **1.1** | unchanged | +| synthetic large total | 89.0 | **73.0** | ~73 | +| iris-blueprint total | 150.2 | **134.5** | 134–142 | + +**The null prediction is the load-bearing one.** `C` and `P` are unchanged to the +printed digit, and every row above the rate floor holds its editing rate exactly +(large 34.8 → 34.8, iris 79.3 → 79.3). A harness reporting only the duplication +sweep would have concluded S9-2 did nothing, while total residency fell 18%. +That is L9-014 demonstrated rather than argued, one step after it was written. + +Two sub-floor rows did move — `para-gelasio` (172 chars) 15.8 → 1.7 and +`styles-tinos` (45 chars) 91.9 → 80.9 — which is what the rate floor exists to +flag. At those sizes the measured peak is dominated by transient shaping +allocations rather than retained residency, so the two conditions' peaks need +not differ by the retained delta at all. Not explained further here, because no +conclusion in this document rests on a sub-floor row; noted so the next reader +does not mistake it for a finding. + +### What iris's position in the band measured + +§10f said where iris landed inside 134–142 would be a free measurement of how +common the identity case is in real documents. It landed at **134.5**, the very +top of the saving — essentially the full 16 B/char, not the 8 B/char that `u32` +alone buys. + +Reading that back: if a fraction `f` of bytes sit in paragraphs where nothing was +dropped, the saving is `8 + 8f` B/char, so −15.7 implies **f ≈ 96%**. The +identity case is not an artefact of synthetic text — it dominates a real, +image-and-table-bearing document too. Tabs are the main thing the cleaner +removes, and they are far rarer per *byte* than their per-document presence +suggests. + +`acid2-docx` saved 20.4 B/char, i.e. **more than 16**, which is the expected +signature of multibyte UTF-8: the maps are sized per source *byte* while these +rates are per character, so a document with non-ASCII text pays more than 16 and +saves more than 16. A small independent confirmation that the maps are per-byte +as §2.3 modelled them. + +### Where this leaves the census + +Body text is now **73.0 B/char total, 34.8 of it editing residency**, against +124 and 69.4 when E0 first ran. Two changes, no eviction machinery, no contract +change, and no behavioural difference: **41% off total residency**, and the +figure is now well under the ~88 B/char the census predicted before either +redundancy was known about. + +## 10h. R9-15 measured — the rate does not transfer, the fraction does + +R9-15 recorded that every figure in this document is Latin text. That is now a +measurement rather than a caveat: E0 has a CJK tier, and this sandbox has +CJK-capable faces (`wqy-zenhei`, `ipafont-gothic`), so it shapes for real. + +| Tier | Chars | Editing B/char | Total B/char | Evictable | +| --- | --- | --- | --- | --- | +| synthetic large (Latin) | 113,394 | 34.8 | 73.0 | 47.7% | +| **cjk (120p)** | **16,092** | **111.7** | **219.5** | **50.9%** | +| ratio | | **3.2×** | **3.0×** | within 3.2 points | + +**The per-character rate does not transfer: CJK costs ~3× Latin.** Anything in +this spec quoted as B/char is a Latin figure, and applying it to a Japanese or +Chinese document under-predicts by a factor of three. R9-15 was right to call +this a gap rather than a nicety. + +**The evictable fraction does transfer**: 50.9% against 47.7%, inside the +text-bearing band. That is the same pattern the duplication sweep found — a +controlled change that moves the rate several-fold and leaves the fraction +nearly fixed — and it is now shown across a second, entirely independent axis. +L9-008's framing survives contact with non-Latin text: the fraction is the +cross-sectional invariant, the rate is not. + +**Coverage is checked before the rate is reported.** A CJK run against +Latin-only fonts shapes to a page of tofu that allocates, paginates, and yields +a perfectly believable B/char figure — R9-13's failure mode precisely. So the +tier counts `.notdef` glyphs first and fails rather than prints: this run reports +**16,092 glyphs, 0 `.notdef`**, one glyph per character. On a machine with no +CJK font the bench now fails loudly, which is deliberate per L08-014's corollary +— a row that can be produced without doing its work is worse than no row. + +**Mechanism not established.** Glyph count is 1.00 per character in both scripts, +so the 3× is not more glyphs. Two candidates, neither verified here: full-width +characters halve the characters per line, so per-line and per-run overheads are +paid roughly twice as often per character; and CJK has no spaces, so every +character is a break opportunity and Parley's line-breaking state may be +per-character rather than per-word. Distinguishing them is a line-count +comparison at equal character count — cheap, and worth doing before any +non-Latin target is set. Recorded as a hypothesis, not a finding. + +**One unrelated row moved.** `acid-docx` shifted 1.7% (editing 4149.0 → 4079.5) +when the CJK tier was inserted before it; every other row is identical to the +digit and the ordering control still reads 0.0% drift. The plausible reason is +that its peak is dominated by a few large image allocations rather than many +small text ones, making it sensitive at the percent level where the text rows are +not — and it is the row L9-010 already says the per-character denominator does +not describe. Flagged, not chased. + +## 10i. S9-3's governing metric — page-access sets, not C and P (L9-013 r7) + +L9-013 as originally written would produce a vacuous prediction here. S9-3's +purpose is not to reduce residency; it is to stop operations from *forcing* +residency. E0 structurally cannot see that — it lays out a whole document with +every page resident by construction, so "C and P unchanged" would be correct, +confirmed, and worthless. + +**The governing metric is the `editing_data` dereference set** (L9-017, sharpened +r11). For an operation `op` targeting a block on page `M` of an `N`-page +document, `A(op)` is the set of page indices whose **`editing_data` the operation +actually dereferences** — not the set it visits. The distinction is the whole +question: iterating `N` pages' *metadata* is harmless for windowing, cheap and +forcing no residency, while dereferencing `N` pages' *`editing_data`* is fatal. A +counter that blurs the two reports a frightening number for something benign or a +clean one for something ruinous. Under windowing every member of `A(op)` must be +resident or materialised, so what S9-3 must establish is that `|A(op)|` is +**bounded by a constant, independent of both `N` and `M`**. + +### The scan inventory, which is larger than Q2 recorded + +Q2 named `nested_para_page` as "one genuine breakage". Reading the tree, there +are **four** sites that scan pages and touch `editing_data`, and they do not +share a shape: + +| Site | Scan | `A` on hit | `A` on miss | Triggered by | +| --- | --- | --- | --- | --- | +| `navigation_find.rs:57` `nested_para_page` | `pages.iter().position(…)` from 0 | prefix `0..=M` | **all `N`** | caret crossing inside a table cell / note | +| `navigation_find.rs:109` `find_prev_para_data` | reverse from `page_index` | local, `≤` distance | `0..=page_index` | caret up / left across blocks | +| `navigation_find.rs:137` `find_next_para_data` | forward from `page_index` | local, `≤` distance | `page_index..N` | caret down / right across blocks | +| `page_locate.rs:56` `recompute_page_index` | forward **from 0**, breaks on first visible (`visible` *does* fire — §10l) | **open (R9-19)** — code suggests `0..=M`, timing points at `N` | **all `N`** | **every keystroke** | + +Two of these are already well-behaved: `find_prev_para_data` and +`find_next_para_data` start at the caret's own page, so their access set is +bounded by the distance to the target, which is small for local navigation. They +degrade only on a miss. + +**`recompute_page_index` is the one that matters, and it is worse than Q2's +example.** It starts at page 0 regardless of where the caret is, and it is called +on **every keystroke** (`editor_keydown_text.rs:40`, `editor_keydown.rs:200`, +`editor_keydown_ctrl.rs:36,41`). Typing on page 300 of a windowed document would +therefore touch pages 0–300 per character typed. `nested_para_page` fires only +when the caret crosses a nested-container boundary; this one fires continuously +during ordinary typing, which makes it the site that would actually cancel +windowing (R9-04). + +### The prediction + +A block→page index turns every one of these into a lookup: + +| Site | `A` now (hit / miss) | Predicted after S9-3 | +| --- | --- | --- | +| `nested_para_page` | `M+1` / `N` | **1 / 0** | +| `recompute_page_index` | **open (R9-19)** — see §10l | **1–2 / 0** | +| `find_prev_para_data` | distance / `page_index+1` | **1 / 0** | +| `find_next_para_data` | distance / `N−page_index` | **1 / 0** | + +`recompute_page_index` is allowed 2 rather than 1 because a paragraph straddling +a page break legitimately has entries on two pages, and the function's whole job +is to choose between them. Any site still scaling with `N` or `M` after the +change means S9-3 has not done its job, whatever the residency figures say. + +**Falsification.** If measured access sets stay proportional to `M`, the index is +not being consulted on the path that matters — most likely because a caller +recomputes from the layout rather than from the index. If they are constant but +larger than 2, the index maps blocks to too many pages and its key is wrong. + +### The instrument is a prerequisite, not a component + +There is no existing harness for this. It needs a counting accessor over page +`editing_data` — a test-only wrapper recording which page indices an operation +*dereferences* — plus a test per site asserting the bound against a document long +enough for `N` and `M` to differ meaningfully. + +**It comes before S9-3 rather than inside it** (r11). The falsification condition +above — "any site still scaling with `N` or `M`" — needs a known current +baseline, and there isn't one: the timing points at `N`, the code's shape +suggests `0..=M`, and §10l establishes neither. Without the accessor S9-3 has no +testable prediction at all, which is the L9-013 failure mode one level up. It is +also the honest reason S9-3 is not in S9-1 and S9-2's cost class. + +**This section states the metric and the prediction only. No S9-3 code has been +written**; the decision to cross into the invasive half of Spec 09 is deliberate +and not ours to take (§2.1.4 of the spec). + +## 10j. R9-16 — the per-byte hypothesis is refuted by its own discriminator + +The CJK tier looked like it had found the census's real unit. Latin large is 73.0 +B/char on ASCII, so 73.0 per source byte; CJK is 219.5 B/char at ~3 bytes per +character, so ~74.7 per source byte. A 2.3% match, and a total ratio of 3.007× +against a byte ratio of 2.94. If that held, every figure in this document would +transfer across scripts once restated per source byte. + +**It does not hold.** Both caveats attached to the hypothesis were the right ones, +and the second killed it. + +The bench now computes the **measured** source-byte count rather than assuming a +nominal 3.0 — leading digits, spaces and full stops pull CJK to 2.94 b/ch — and +adds the discriminator: a Cyrillic-and-Greek tier at ~2 bytes per character with +Latin-like shaping (one glyph per character, real word spaces, ordinary line +breaking). Per-byte residency predicts ~2× the Latin B/char rate; +script-complexity predicts ~1×. + +| Tier | b/ch | glyphs/char | lines/char | Editing B/char | **Total B/char** | **Total B/src-byte** | Evictable | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Latin large | 1.00 | 0.89 | 0.017 | 34.8 | 73.0 | **73.0** | 47.7% | +| **Cyrillic + Greek** | **1.85** | 0.91 | 0.018 | 44.4 | **88.1** | **47.7** | 50.4% | +| CJK | 2.94 | 1.00 | 0.030 | 111.7 | 219.5 | **74.7** | 50.9% | + +Per-byte predicted 73.0 × 1.85 = **135.1** B/char for Cyrillic. It measures +**88.1**. Per source byte the three tiers read 73.0 / 47.7 / 74.7 — if the byte +were the invariant they would agree, and Cyrillic sits **35% below** the other +two. The CJK/Latin agreement was a two-point coincidence, which is exactly the +risk that motivated demanding a third point before believing it. + +**Neither unit is invariant.** Cyrillic is 1.21× Latin per character while +carrying 1.85× the bytes, so characters do not explain it either. The census's +denominator problem is not solved by switching units; it is a real +script-dependence that has to be measured per script. + +### What the extra observables rule out + +Both tiers report glyphs and lines per character, so the obvious mechanisms can +be tested rather than speculated about: + +- **Not glyph count.** 0.89 / 0.91 / 1.00 glyphs per character across the three + tiers — essentially flat while the rate moves 3×. +- **Line count is real but insufficient.** CJK is full-width, so it fits about + half as many characters per line: 0.030 lines/char against Latin's 0.017, a + **1.76×**. That is a genuine effect and it is the first of §10h's two + hypotheses confirmed. But the rate moves **3.01×**, so line count explains + well under half of it. Cyrillic's lines/char is 0.018, a 1.06× that cannot + explain its 1.21×. +- **No two-term model over (lines, bytes) fits.** Solving `rate = a·lines/char + + b·bytes/char` on the Latin and Cyrillic rows gives a ≈ 3490, b ≈ 13.7, which + predicts **144.9** for CJK against **219.5** measured — short by a third. There + is a CJK-specific term neither observable captures. + +The remaining candidate is §10h's second hypothesis, now the only one standing: +CJK has no spaces, so every character is a line-break opportunity, and Parley may +carry break state per character where Latin carries it per word. Testing that +means reading Parley's cluster and break-opportunity structures rather than +measuring from outside, which is a separate investigation and is **not** done +here. + +### What survives, and it is the useful part + +**The evictable fraction transfers across all three scripts: 47.7 / 50.4 / +50.9%**, a spread of 3.2 points while the per-character rate spans 3×. That is +now the third independent axis on which the fraction holds and the rate does not +— after document class (§10a) and duplication (§10b). L9-008's framing is the +best-supported claim in this document. + +The practical consequence for Spec 09 is narrow and worth stating plainly: **any +absolute byte target must be set per script, or set for the worst script.** A +budget derived from Latin body text under-provisions a CJK document threefold, +and no change of denominator fixes that. + +## 10k. The ordering control's blind spot + +`acid-docx` moved 1.7% when the CJK tier was inserted before it while the +ordering control read 0.0% drift. Those are not in tension, and the reason is +worth recording so the next unexplained 1.7% is not filed as noise by default. + +The control re-measures the **first subject last**. What it detects is a one-time +cost leaking into a measured region — warm-up order-dependence, which is the +failure that produced the 252× errors in §10a. It says nothing about allocator +behaviour *around* a specific measurement: peak live heap for a document whose +residency is a few large image allocations can shift by a percent depending on +what the allocator did just before, and no re-measurement of a *different* +document detects that. + +So the control is much better than nothing and is not complete. It bounds +order-dependence in the warm-up; it does not bound run-to-run variance in a +single row. Making that second bound real would mean repeating each row and +reporting a spread rather than a point, which the text rows do not need — they +reproduce to the printed digit — and which the object-heavy row plausibly does. +Recorded rather than built, because no conclusion here rests on `acid-docx` +(L9-010 already says the per-character denominator does not describe it). + +## 10l. The per-keystroke scan, measured — and §10i's access set was wrong + +The question was whether `recompute_page_index` walking pages on every keystroke +is negligible today or a present-day typing-latency defect on long documents, +because the answer changes what S9-3 *is*. It is measured now +(`loki-text/benches/page_locate_latency.rs`), and it answered a different +question more usefully than the one asked. + +| Document | Caret page 0 | Caret last page | Flat-in-M ratio | Guaranteed full scan | +| --- | --- | --- | --- | --- | +| 445 pages / 4,000 paras | 3,442 ns | 3,284 ns | **0.95×** | 3,180 ns | +| 889 pages / 8,000 paras | 13,921 ns | 13,581 ns | **0.98×** | 13,300 ns | + +### R9-18 was over-generalised, not false — and the bench varied the wrong axis + +This section first made two claims at once and then retired both. Neither +treatment was right. One claim needed a qualifier and the other needed keeping. + +**R9-18, restated: `visible` does not fire *for the geometry the bench used*.** +The unqualified "never fires" is dead — a characterisation test on a real +laid-out document (`page_locate_characterisation_tests.rs`) puts the last byte of +a paragraph split across a page break on a **later** page than its first, which +only the band check produces. But for the bench's geometry — single-page +paragraphs at byte 0 — the original evidence still stands: a guaranteed miss +costs the same as a hit, and cost is flat in `M` where a `0..=M` walk would make +page 0 nearly free. Both hold. The claim needed a geometry qualifier, not +deletion. + +**The uncontrolled variable was geometry, and it is what selects the path.** The +bench swept caret position across pages while holding geometry fixed. Geometry is +the axis that decides whether `visible` fires, so the sweep varied the one thing +that does not matter and held the one that does. That is a defect in an +instrument built here, recorded as such. + +**R9-19 — the access set — survives on evidence independent of the mechanism.** +Cost is not proportional to `M` (page 0: 3,442 ns; page 444: 3,284 ns) yet is +superlinear in `N` (445 → 889 pages, 3.3 → 13.6 µs). Something `N`-sized is +touched per call regardless of caret position. Deleting this along with R9-18 +would have discarded the half that bears on windowing. + +### The four-case sweep, which discriminates both + +Vary geometry, not caret position: + +| Case | `visible` expected | | +| --- | --- | --- | +| byte 0 of a single-page paragraph | no (per R9-18 restated) | what the bench measures now | +| mid-paragraph, single-page | ? | unmeasured | +| first byte of a paragraph carried over from the previous page | ? | unmeasured | +| last byte of one continuing onto the next | yes (characterised) | unmeasured for cost | + +- **Cost flat across all four** → an `N`-sized preamble runs regardless of path, + and R9-19's pessimistic prior holds. +- **Cost collapses for the straddling cases** → there is no preamble, the + `N`-scaling was specific to byte-0 lookups, and **S9-3's target is much + narrower than R9-19 currently assumes.** + +### The keystroke path is neither geometry measured + +Typing happens **mid-paragraph at arbitrary offsets**, in paragraphs that may or +may not straddle a page boundary. Q4 (§5) already established that pages starting +mid-paragraph are the common case in prose. So the representative path is plausibly +closer to the split geometry — where `visible` fires and the walk is short — than +to the bench's byte-0 case. + +If that holds, **R9-19's `N` prior is pessimistic for exactly the path that +matters**, and S9-3 would be scoped against a worst case that the editor rarely +hits. Worth settling before S9-3 is sized, and cheap: it is the same four-case +sweep. + +### It is not a present-day latency defect + +At 445 pages the call costs 3.3 µs; at 889 pages, 13.6 µs. Against a ~16 ms frame +budget that is 0.02% and 0.08%. Even granting that it is one of several things +happening per keystroke, it is nowhere near the budget at realistic document +sizes. + +The scaling is worse than linear, and worth recording: doubling the document +(pages 2.00×) multiplied the cost **4.18×**, not 2×. Entries scanned only +doubled — paragraphs per page held at 9.0 in both — so the extra factor is +plausibly cache behaviour, the 889-page working set no longer fitting where the +445-page one did. Not verified, and it does not change the verdict: extrapolating +that doubling to ~7,000 pages still lands around a millisecond. + +**Verdict for the boundary decision: S9-3 stays architecture.** It does not buy a +user-visible typing-latency fix, so the case for crossing the line has to rest on +eviction groundwork alone. That is the answer the measurement was for, and it +argues against crossing rather than for it. This verdict is unaffected by the +retraction above: it rests on the absolute microsecond figures, which are +observations, not on what the loop does internally. + +### Instrument notes + +Two controls, both of which changed what was reported. The first probe seeded +`pos.page_index` with the correct page, which makes `new_page == pos.page_index` +trivially true — the function then returns the same value whether it found the +paragraph or scanned everything and found nothing, so the timing had no +established meaning. Fixed by seeding a *stale* index (also the realistic case) +and asserting the lookup resolves to the expected page. The second is a +timer-resolution floor: near-identical medians across very different workloads is +what a coarse clock looks like, so the bench measures an empty region first. It +reads 22 ns against 3,300 ns of signal, which is what makes these numbers +readable at all (R9-13). + +## 10m. The shape both wrong claims shared (L9-018) + +Two claims in this document were asserted and then disproved: the per-byte +denominator (R9-16, §10j) and the never-firing early exit (R9-18, §10l). They +came from different authors and different domains, and they are the same error. + +**Each crossed an observable domain boundary without a direct observation in the +target domain.** The per-byte claim inferred *unit invariance* from measured +*byte counts*. The control-flow claim inferred *which branch runs* from measured +*time*. In both cases the evidence was coherent — genuinely so — within the +domain that was measured, and coherence there is exactly what you would expect +whether or not the target-domain claim is true. So it carries no information +about the target. + +That is a sharper tell than "internally consistent and pointing where the author +was already leaning", which was the first diagnosis. Both of those were also +true, but they describe how the claim *felt*; this describes what was structurally +missing. The check is mechanical: name the domain you measured, name the domain +you are concluding about, and if they differ, either measure in the second or +record the claim as an inference. + +**The process fix already exists in this document** and is now the standard form +for findings in both specs (L9-019): the three-way split of **Observed / Not +established / What would settle it**, written at the moment of recording rather +than after a correction. Applied at the time, it would have caught both — neither +claim could have been filed under "Observed", and both would have arrived with +their own falsification attached. + +## 11. Standing caveat, restated + +Every byte figure here is computed from struct definitions. No profiler was +available — this environment has no GPU or display, the same constraint that +produced Spec 08's derived figures. Spec 09 §5 already requires one profiled run +on real hardware before the spec commits to a target, and nothing in this +document relaxes that. + +What a profile would most usefully settle, in priority order: + +1. Whether the ~88 B/char model holds for real documents, or whether per-run and + per-line overheads dominate in practice (heavily formatted text would push + that way). +2. The actual split between layout residency, the Loro oplog, and the font + caches — this document measures only the first. +3. ~~Whether `preserve_for_editing: false` really recovers the ~72 B/char this + model predicts.~~ **Run — see §10. Confirmed at 70.1 B/char.** + +Items 1 and 2 remain open. On item 1 the answer is now partly known: E0 uses +synthetic paragraphs with three style runs each, so it is not the homogeneous +best case, but a heavily formatted real document would still push the per-run +terms up. On item 2, this document and E0 both measure layout only — the Loro +oplog and the font caches are outside the measured region and remain +unquantified. diff --git a/loki-app-shell/src/android.rs b/loki-app-shell/src/android.rs index 6eb825e7..a31f6a04 100644 --- a/loki-app-shell/src/android.rs +++ b/loki-app-shell/src/android.rs @@ -8,7 +8,8 @@ //! entry point can't be a plain function in this crate. The bootstrap *body*, //! however, was duplicated verbatim across all three (Spec 01 audit A-14): the //! Android-16 double-fire guard, logger + panic-to-logcat setup, file-access -//! init, safe-area insets, `set_android_app`, i18n, and the Dioxus launch. +//! init, safe-area insets, `set_android_app`, the soft-keyboard (IME) visibility +//! bridge, i18n, and the Dioxus launch. //! //! [`android_main!`] generates that body once. It is a macro rather than a //! function so the expansion uses each binary's own `dioxus` / `blitz_shell` / @@ -16,6 +17,19 @@ //! `#![forbid(unsafe_code)]` (the emitted `unsafe` lives in the *caller*, under //! the scoped `#[allow(unsafe_code)]` the macro attaches; Spec 01 audit A-7). //! +//! ## This macro is the *only* `android_main` a binary may define +//! +//! `macro_rules!` is hygienic for local bindings but **not for item names**: the +//! `static ANDROID_MAIN_RUNNING` and `fn android_main` emitted below land in the +//! caller's module namespace under those literal names. A binary that both +//! invokes this macro and keeps a hand-written `android_main` gets `E0428` — and +//! because both are behind `#[cfg(target_os = "android")]`, no host job can see +//! it. Measured, not assumed: with a plain `let x: u32 = "string";` inside this +//! macro body, `cargo check --workspace` and the full CI clippy command both +//! still pass. That is how merge `cce9772` broke the `loki-text` Android build +//! (Spec 08 I-16 / S0.4). The `android-check` CI job added in the same change +//! (L08-014) is the only thing that catches it. +//! //! ## Usage //! //! ```ignore @@ -102,6 +116,28 @@ macro_rules! android_main { $crate::recent_documents::set_android_data_dir(data_path); } ::blitz_shell::set_android_app(android_app); + // Bridge Android soft-keyboard visibility back to the app. A + // NativeActivity is never told when the *user* dismisses the keyboard + // (back button, swipe-down gesture, hide key), so the bottom safe area + // would stay reserved for a keyboard that is gone. loki-file-access + // installs a decor-view inset listener that reports every IME + // visibility change; blitz-shell re-queries the safe area in response + // (converging to 0 on a collapse). Register the bridge *before* + // installing the listener so the first callback is not dropped. + // + // Lives here, not in one binary's entry point: it was previously + // wired only in `loki-text`, so Calc and Slides never had it + // (Spec 08 S0.4 §4). One implementation, three consumers. + ::loki_file_access::set_ime_visibility_listener(::std::boxed::Box::new(|visible| { + ::blitz_shell::notify_ime_visibility_changed(visible); + })); + // Returns `false` on a null pointer / JNI failure / API < 30, where + // the inset query already falls back; it is a plain bool, not a + // `Result` and not `#[must_use]`, and there is no recovery to + // attempt, so it is called as a statement and the value dropped. + ::loki_file_access::install_ime_listener( + ::blitz_shell::current_android_app().activity_as_ptr(), + ); ::log::info!("android_main: i18n init"); ::loki_i18n::init(); ::log::info!("android_main: launching dioxus"); diff --git a/loki-bench/Cargo.toml b/loki-bench/Cargo.toml index cdd8b8b2..2a94a0f9 100644 --- a/loki-bench/Cargo.toml +++ b/loki-bench/Cargo.toml @@ -41,6 +41,11 @@ loki-odf = { path = "../loki-odf" } name = "font_cache_dedup" harness = false +# Spec 09 E0: the experiment that gates that spec's phase plan (L9-005). +[[bench]] +name = "layout_editing_residency" +harness = false + [[bench]] name = "session_layout_residual" harness = false diff --git a/loki-bench/benches/layout_editing_residency.rs b/loki-bench/benches/layout_editing_residency.rs new file mode 100644 index 00000000..42ef0589 --- /dev/null +++ b/loki-bench/benches/layout_editing_residency.rs @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! **Spec 09 E0** — how much resident memory is editing residency? +//! +//! Spec 09 gated its phase plan (L9-005) on one experiment: does turning +//! `preserve_for_editing` off actually recover the ~72 bytes per character that +//! `docs/spikes/S09.0-layout-residency-census.md` predicts from struct +//! definitions? +//! +//! It did, for body text — 69.4 B/char against 72 predicted, flat across a 25× +//! document-size change. On **real** documents the rate is a different number +//! per document while the *evictable fraction* stays far narrower. Watch the +//! fraction, not the rate: that is what Spec 09 targets (L9-008, S09.0 §10a). +//! +//! **S9-1 has since landed**, so the numbers this prints are post-change: body +//! text reads ~34.8 B/char editing and ~89.0 total, down from 69.4 and 123.3. +//! The bench now guards that result rather than establishing it. Per L9-013 each +//! later step records its predicted effect on `C` and `P` before implementation +//! and re-runs the sweep to see which coefficient actually moved — S9-1's +//! prediction and outcome are S09.0 §10c and §10d. +//! +//! # Why this is a bench and not a manual RSS comparison +//! +//! Spec 09 r1 proposed diffing RSS across two process runs on real hardware. +//! **Layout is CPU-only** (Parley shaping plus our pagination; the GPU is +//! involved in painting, not layout), so it runs headless, and dhat measures +//! live heap directly — which also dissolves r1's two caveats: the full layout +//! pass is forced by construction, and allocator retention cannot mask what +//! dhat counts. Committed as a bench so it guards S9-1 … S9-5 rather than being +//! spent once (L9-006). +//! +//! # Measurement hygiene +//! +//! Three things this harness does deliberately, all learned from its own bad +//! numbers: +//! +//! - **A process warm-up runs before any measurement**, so shared one-time +//! costs are not billed to whichever tier happens to run first. Without it the +//! 10-paragraph tier read 411 B/char and looked like a size-dependent floor +//! artefact; warm, it reads 69.5, indistinguishable from the 250-paragraph +//! tier. There is no size floor — there was a *first-measurement* artefact. +//! - **A per-document warm-up runs before each row.** The process warm-up +//! covers only shared costs; a document introducing new fonts pays its own +//! loading inside its own first measurement. This was worth up to **252×**: +//! `styles-tinos` read 39,264 B/char cold and 155.7 warm. +//! - **A control re-measures the first document last.** If the warm-ups work, +//! the two readings agree; if they diverge, the instrument is order-dependent +//! and every rate in the table is suspect. Printed rather than asserted — +//! its value is the comparison, not a threshold. +//! +//! Run: `cargo bench -p loki-bench --bench layout_editing_residency` + +loki_bench::dhat_global_allocator!(); + +#[path = "support/mod.rs"] +mod support; + +use loki_bench::{AllocStats, measure}; +use loki_doc_model::document::Document; +use loki_layout::{FontResources, LayoutMode, LayoutOptions, layout_document}; +use std::hint::black_box; + +/// Documents below this many characters are dominated by per-document fixed +/// costs, so their per-character rate is not a data point. Reported alongside +/// the rate rather than hidden, so a reader can discount the row themselves. +const RATE_FLOOR_CHARS: usize = 5_000; + +/// How far the first and last reading of the same document may differ before +/// the run is declared order-dependent. Tight on purpose: warm, the two agree +/// exactly, so any real drift means a one-time cost is still leaking into a +/// measured region. +const ORDER_DRIFT_TOLERANCE: f64 = 0.05; + +fn layout_peak(resources: &mut FontResources, doc: &Document, preserve: bool) -> AllocStats { + let options = LayoutOptions { + preserve_for_editing: preserve, + spell: None, + ..Default::default() + }; + // Cold paragraph cache in both conditions, or the second run measures a + // cache hit and the comparison is meaningless. + resources.clear_paragraph_cache(); + measure(|| { + let layout = layout_document(resources, doc, LayoutMode::Paginated, 1.0, &options); + // Held live across the peak — this is the resident set being measured, + // not the transient cost of producing it. + black_box(&layout); + }) +} + +/// Lays out `doc` once and discards the result, so any one-time cost it +/// introduces — font loading above all — is paid outside the measured region. +fn warm_doc(resources: &mut FontResources, doc: &Document) { + let options = LayoutOptions { + preserve_for_editing: true, + spell: None, + ..Default::default() + }; + black_box(layout_document( + resources, + doc, + LayoutMode::Paginated, + 1.0, + &options, + )); + resources.clear_paragraph_cache(); +} + +/// Lays out a throwaway document so process-wide one-time costs are paid before +/// the first measurement. +fn warm_up(resources: &mut FontResources) { + let doc = support::build_doc(4, support::WORDS_PER_PARA); + let options = LayoutOptions { + preserve_for_editing: true, + spell: None, + ..Default::default() + }; + black_box(layout_document( + resources, + &doc, + LayoutMode::Paginated, + 1.0, + &options, + )); + resources.clear_paragraph_cache(); +} + +/// One measured row: both conditions, the retained delta, and the evictable +/// fraction. Returns `(retained_bytes, editing_per_char)`. +fn report_doc(resources: &mut FontResources, label: &str, doc: &Document) -> (i64, f64) { + let chars = support::char_count(doc); + let bytes = support::byte_count(doc); + // L9-009: a document that yields no characters means the extractor failed, + // not that the document is empty — the corpus has no empty fixtures. Fail + // rather than print a tidy "skipped", which is how six fixtures once + // measured nothing and said so in a way that read as normal. + assert!( + chars > 0, + "{label}: char_count returned 0 — the extractor did not match this \ + document's block or inline shapes, so this row would measure nothing" + ); + + // Per-document warm-up. A process-wide warm-up covers only the *shared* + // one-time costs; each document that introduces new fonts pays its own + // loading cost inside whichever of its measurements runs first. Without + // this, `iris-blueprint` read 176.4 B/char in the corpus loop and 117.5 + // when measured again later in the same run — the same document, 33% apart. + warm_doc(resources, doc); + + let editing = layout_peak(resources, doc, true); + let read_only = layout_peak(resources, doc, false); + let delta = editing.max_bytes as i64 - read_only.max_bytes as i64; + let per_char = delta as f64 / chars as f64; + let total_per_char = editing.max_bytes as f64 / chars as f64; + let evictable = if editing.max_bytes > 0 { + 100.0 * delta as f64 / editing.max_bytes as f64 + } else { + 0.0 + }; + let flag = if chars < RATE_FLOOR_CHARS { + " (below rate floor)" + } else { + "" + }; + // Both denominators, per R9-16. Several contributors are sized per source + // byte (§10g's multibyte cross-check proved it for the index maps), so + // B/char is B/byte times the script's bytes-per-character. Printing the + // measured byte count rather than an assumed ratio is the whole point: any + // ASCII punctuation, digits or spaces pull the average below the script's + // nominal width, and a hypothesis tested against an assumed 3.0 would be + // testing arithmetic rather than the document. + let bpc = bytes as f64 / chars.max(1) as f64; + let total_per_byte = editing.max_bytes as f64 / bytes.max(1) as f64; + eprintln!( + " {label:<24} chars={chars:>7} b/ch={bpc:>4.2} editing={per_char:>7.1} B/char \ + total={total_per_char:>7.1} B/char total={total_per_byte:>6.1} B/src-byte \ + evict={evictable:>5.1}%{flag}", + ); + (delta, per_char) +} + +fn main() { + support::header("Spec 09 E0 — editing residency: preserve_for_editing on vs off"); + eprintln!( + " The evictable % below is a property of each DOCUMENT, not a target for us\n \ + (L9-008): the engineering goal is what fraction of it we actually reclaim.\n \ + Rows under {RATE_FLOOR_CHARS} chars are flagged — small documents are fixed-cost heavy." + ); + + let mut resources = FontResources::new(); + // Before anything is measured — see the module docs. + warm_up(&mut resources); + + let mut worst_delta = 0_i64; + let mut first_small = 0.0_f64; + + eprintln!("\n synthetic tiers:"); + { + // Latin baseline for the R9-16 comparison: the other two tiers report + // the same two ratios, and without this row they have nothing to be + // ratios against. + let doc = support::build_doc(250, support::WORDS_PER_PARA); + let probe = layout_document( + &mut resources, + &doc, + LayoutMode::Paginated, + 1.0, + &LayoutOptions { + preserve_for_editing: true, + spell: None, + ..Default::default() + }, + ); + let (glyphs, notdef) = support::glyph_coverage(&probe); + let lines = support::line_count(&probe); + drop(probe); + resources.clear_paragraph_cache(); + let chars = support::char_count(&doc); + eprintln!( + " (latin coverage: {glyphs} glyphs, {notdef} .notdef, {:.2} glyphs/char, \ + {:.3} lines/char)", + glyphs as f64 / chars as f64, + lines as f64 / chars as f64, + ); + } + for &(name, paras) in support::DOC_TIERS { + let doc = support::build_doc(paras, support::WORDS_PER_PARA); + let (delta, per_char) = report_doc(&mut resources, &format!("{name} ({paras}p)"), &doc); + if name == "small" { + first_small = per_char; + } + worst_delta = worst_delta.max(delta); + } + + // ── CJK tier (R9-15) ──────────────────────────────────────────────────── + // Every other figure in this program is Latin text. CJK is the sharpest + // test of whether the per-character model transfers: ~3 bytes per character + // in UTF-8 against 1, no spaces to break on, and glyph coverage in the + // thousands rather than under a hundred. The index maps are sized per source + // *byte*, so S9-2's benefit should be roughly three times larger here — a + // prediction this row either confirms or kills. + eprintln!("\n CJK tier (R9-15 — the per-character model on non-Latin text):"); + { + let doc = support::build_cjk_doc(120, 6); + // Coverage sentinel before the rate. A CJK run against Latin-only fonts + // shapes to a page of tofu that still allocates and still yields a + // perfectly believable B/char figure — R9-13's failure mode, so it fails + // rather than prints. Fonts present here: wqy-zenhei, ipafont-gothic. + let probe = layout_document( + &mut resources, + &doc, + LayoutMode::Paginated, + 1.0, + &LayoutOptions { + preserve_for_editing: true, + spell: None, + ..Default::default() + }, + ); + let (glyphs, notdef) = support::glyph_coverage(&probe); + let lines = support::line_count(&probe); + drop(probe); + resources.clear_paragraph_cache(); + assert!( + glyphs > 0 && notdef * 10 < glyphs, + "CJK tier shaped {glyphs} glyphs of which {notdef} are .notdef — no \ + CJK-capable font resolved, so this row would measure tofu and report \ + it as a per-character rate" + ); + let chars = support::char_count(&doc); + eprintln!( + " (coverage: {glyphs} glyphs, {notdef} .notdef, {:.2} glyphs/char, {:.3} lines/char)", + glyphs as f64 / chars as f64, + lines as f64 / chars as f64, + ); + report_doc(&mut resources, "cjk (120p)", &doc); + } + + // ── 2-byte tier: the R9-16 discriminator ──────────────────────────────── + // Cyrillic and Greek are two UTF-8 bytes per character with Latin-like + // shaping — one glyph per character, real word spaces, ordinary breaking. + // So per-byte residency predicts ~2× the Latin B/char rate and + // script-complexity predicts ~1×. Two hypotheses, one row. + eprintln!("\n 2-byte tier (R9-16 discriminator — Cyrillic + Greek):"); + { + let doc = support::build_2byte_doc(160, 6); + let probe = layout_document( + &mut resources, + &doc, + LayoutMode::Paginated, + 1.0, + &LayoutOptions { + preserve_for_editing: true, + spell: None, + ..Default::default() + }, + ); + let (glyphs, notdef) = support::glyph_coverage(&probe); + let lines = support::line_count(&probe); + drop(probe); + resources.clear_paragraph_cache(); + assert!( + glyphs > 0 && notdef * 10 < glyphs, + "2-byte tier shaped {glyphs} glyphs of which {notdef} are .notdef — \ + no Cyrillic/Greek-capable font resolved, so this row would measure \ + tofu and report it as a per-character rate" + ); + let chars = support::char_count(&doc); + eprintln!( + " (coverage: {glyphs} glyphs, {notdef} .notdef, {:.2} glyphs/char, {:.3} lines/char)", + glyphs as f64 / chars as f64, + lines as f64 / chars as f64, + ); + report_doc(&mut resources, "cyrillic+greek (160p)", &doc); + } + + eprintln!("\n real documents (conformance corpus — six fixtures):"); + let mut corpus_seen = 0_usize; + let mut iris: Option = None; + for &(label, rel) in support::CORPUS { + match support::load_corpus_doc(rel) { + Some(doc) => { + report_doc(&mut resources, label, &doc); + if label == "iris-blueprint" { + iris = Some(doc); + } + corpus_seen += 1; + } + // Absence is tolerated (the fixture may not be checked out); a + // document that loads but measures nothing is not — see report_doc. + None => eprintln!(" {label:<24} unavailable (absent or import failed)"), + } + } + + // ── Duplication sweep: decomposing per-placement from content-keyed ───── + // + // Repeating a document's blocks makes them byte-identical, so `ParaCache` + // keys collide: at ×n only 1/n of the paragraphs are distinct content, while + // `editing_data` still holds an `Arc` per placement. That started as a + // failed attempt to vary size at constant formatting — it cannot do that, + // because it changes the cache-hit profile — but the failure measures + // something no other run in this program does. + // + // With `x = unique/total = 1/n`, residency per character is + // `rate(x) = P + C·x`, where **P is the per-placement cost every copy pays** + // and **C is the content-keyed cost that deduplicates**. Fitting the line + // separates them. That bears directly on S9-1: sharing one allocation + // between `ParaCache` and the editing index removes a copy of the + // *content-keyed* portion specifically. + // + // Product consequence, not just a bench one: residency is per unique + // paragraph content plus per placement, so documents with repeated + // boilerplate — form rows, repeated headers, template blocks — deduplicate + // for free, and a flat B/char figure overstates them. + // + // Post-S9-1 this is also the regression guard for the change: `P` is 1.1 + // B/char because the editing index shares the cache's allocation. If a + // future edit reintroduces a per-placement copy, `P` climbs back toward 39 + // here long before any behavioural test notices — nothing about the output + // changes when a layout is copied instead of shared. + if let Some(iris) = iris { + eprintln!("\n duplication sweep (x = unique/total; rate = P + C·x):"); + let mut points: Vec<(f64, f64)> = Vec::new(); + for &n in &[1_usize, 2, 5, 10] { + let doc = if n == 1 { + iris.clone() + } else { + support::repeat_doc(&iris, n) + }; + let (_, rate) = report_doc(&mut resources, &format!("iris ×{n}"), &doc); + points.push((1.0 / n as f64, rate)); + } + let (per_placement, content_keyed) = support::fit_line(&points); + eprintln!( + " fit over {} points: content-keyed C={content_keyed:.1} B/char, \ + per-placement P={per_placement:.1} B/char", + points.len(), + ); + eprintln!( + " → {:.0}% of editing residency deduplicates across identical paragraphs", + 100.0 * content_keyed / (content_keyed + per_placement).max(1.0), + ); + } + + // ── Ordering control (L9-011) ─────────────────────────────────────────── + // Re-measures the first subject last. This **asserts** rather than reports: + // sentinel checks catch an instrument that fails silently, but only a + // self-consistency check catches one that fails *plausibly*, and plausible + // wrong answers are the ones that get ratified into specs. 39,264 B/char + // read exactly like a small-document artefact and was written into Spec 09 + // r3 as established fact; it died only because two of this harness's own + // rows disagreed by more than any model allowed. + eprintln!("\n ordering control (same document, measured last):"); + let small = support::build_doc(support::DOC_TIERS[0].1, support::WORDS_PER_PARA); + let (_, last_small) = report_doc(&mut resources, "small (control)", &small); + let drift = (first_small - last_small).abs() / first_small.max(1.0); + eprintln!( + " small tier: first={first_small:.1} B/char last={last_small:.1} B/char drift={:.1}%", + drift * 100.0 + ); + assert!( + drift <= ORDER_DRIFT_TOLERANCE, + "E0 is order-dependent: the same document read {first_small:.1} B/char first \ + and {last_small:.1} B/char last ({:.1}% drift, tolerance {:.0}%). Every rate in \ + this run is contaminated by whatever one-time cost the earlier measurement \ + absorbed — fix the warm-up before trusting any figure here.", + drift * 100.0, + ORDER_DRIFT_TOLERANCE * 100.0, + ); + + if corpus_seen == 0 { + eprintln!("\n note: no corpus documents loaded — synthetic evidence only"); + } + + // The experiment is only meaningful if the two conditions differ at all. A + // zero delta means `preserve_for_editing` is not the switch the census + // assumes it is, which is itself the finding — fail loudly. + assert!( + worst_delta > 0, + "E0: preserve_for_editing recovered no memory at any tier — \ + the S09.0 census is wrong about what the flag controls" + ); +} diff --git a/loki-bench/benches/support/mod.rs b/loki-bench/benches/support/mod.rs index 98c5fea5..ec8816eb 100644 --- a/loki-bench/benches/support/mod.rs +++ b/loki-bench/benches/support/mod.rs @@ -21,6 +21,7 @@ use loki_doc_model::layout::section::Section; use loki_doc_model::style::props::char_props::CharProps; use loki_doc_model::style::props::para_props::{ParaProps, ParagraphAlignment}; use loki_doc_model::style::{ParagraphStyle, StyleCatalog, StyleId}; +use loki_layout::{DocumentLayout, PositionedItem}; /// A small fixed word pool — cycling it gives varied line breaks without a /// Lorem-ipsum dependency. @@ -144,3 +145,298 @@ pub fn report_row(label: &str, s: AllocStats) { s.total_bytes, s.total_blocks, s.max_bytes, ); } + +// ── Spec 09 E0 helpers (layout residency) ──────────────────────────────────── + +/// Counts the characters of display text in a document. +/// +/// Built on `inline_plain_text`, which already flattens every inline variant. +/// A hand-rolled matcher over a subset of `Block`/`Inline` silently returned +/// zero for every real document in the corpus, because they use `StyledPara` +/// and `Heading` where [`build_doc`] uses `Para` — the quiet-wrong-answer shape +/// Spec 09 L9-009 now forbids. +pub fn char_count(doc: &Document) -> usize { + use loki_doc_model::content::toc::inline_plain_text; + + fn block_chars(b: &Block) -> usize { + match b { + Block::Para(i) | Block::Plain(i) | Block::Heading(_, _, i) => { + inline_plain_text(i).chars().count() + } + Block::StyledPara(p) => inline_plain_text(&p.inlines).chars().count(), + Block::BlockQuote(inner) => inner.iter().map(block_chars).sum(), + Block::OrderedList(_, items) | Block::BulletList(items) => items + .iter() + .flat_map(|blocks| blocks.iter()) + .map(block_chars) + .sum(), + Block::Table(t) => t + .head + .rows + .iter() + .chain( + t.bodies + .iter() + .flat_map(|b| b.head_rows.iter().chain(b.body_rows.iter())), + ) + .chain(t.foot.rows.iter()) + .flat_map(|row| row.cells.iter()) + .flat_map(|cell| cell.blocks.iter()) + .map(block_chars) + .sum(), + _ => 0, + } + } + doc.sections + .iter() + .flat_map(|s| s.blocks.iter()) + .map(block_chars) + .sum() +} + +/// Conformance-corpus documents, as `(label, path relative to `loki-bench/`)`. +/// +/// Read by path rather than by depending on `appthere-conformance`, so no crate +/// edge is added for the dependency-direction gate to weigh. Note the corpus is +/// **six** documents — the ~143 `TC-*` entries elsewhere in that crate are a +/// planned case catalog, not fixtures on disk. +pub const CORPUS: &[(&str, &str)] = &[ + ( + "acid-docx", + "../appthere-conformance/fixtures/docx/acid-docx.docx", + ), + ( + "acid2-docx", + "../appthere-conformance/fixtures/docx/acid2-docx.docx", + ), + ( + "iris-blueprint", + "../appthere-conformance/fixtures/docx/iris-blueprint.docx", + ), + ( + "styles-tinos", + "../appthere-conformance/fixtures/odt/styles-tinos.odt", + ), + ( + "para-gelasio", + "../appthere-conformance/fixtures/odt/para-gelasio.odt", + ), + ( + "para-carlito", + "../appthere-conformance/fixtures/odt/para-carlito.odt", + ), +]; + +/// Imports a corpus fixture, or `None` when it is absent or fails to import. +pub fn load_corpus_doc(rel: &str) -> Option { + use loki_doc_model::io::DocumentImport; + use std::io::Cursor; + + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(rel); + let bytes = std::fs::read(&path).ok()?; + if rel.ends_with(".docx") { + loki_ooxml::DocxImport::import(Cursor::new(bytes.as_slice()), Default::default()).ok() + } else { + loki_odf::OdtImport::import(Cursor::new(bytes.as_slice()), Default::default()).ok() + } +} + +/// Total **source bytes** of a document's text, the UTF-8 length that +/// [`char_count`] counts characters of. +/// +/// Spec 09 R9-16: several residency contributors are sized per source byte +/// rather than per character — the index maps demonstrably so (§10g's multibyte +/// cross-check) — so a per-character rate is a per-byte rate multiplied by the +/// script's bytes-per-character. Reporting both lets the reader see which unit +/// is the invariant instead of inferring it from an assumed ratio. +pub fn byte_count(doc: &Document) -> usize { + use loki_doc_model::content::toc::inline_plain_text; + + fn block_bytes(b: &Block) -> usize { + match b { + Block::Para(i) | Block::Plain(i) | Block::Heading(_, _, i) => { + inline_plain_text(i).len() + } + Block::StyledPara(p) => inline_plain_text(&p.inlines).len(), + Block::BlockQuote(inner) => inner.iter().map(block_bytes).sum(), + Block::OrderedList(_, items) | Block::BulletList(items) => items + .iter() + .flat_map(|blocks| blocks.iter()) + .map(block_bytes) + .sum(), + _ => 0, + } + } + + doc.sections + .iter() + .flat_map(|s| s.blocks.iter()) + .map(block_bytes) + .sum() +} + +/// Russian and Greek sentences for the 2-byte-per-character tier. +/// +/// The discriminator for R9-16. Cyrillic and Greek are two UTF-8 bytes per +/// character with Latin-like shaping complexity — one glyph per character, real +/// word spaces, ordinary line breaking. So the two hypotheses separate cleanly: +/// **per-byte residency predicts ~2× the Latin B/char rate**, while +/// script-complexity predicts ~1×, since nothing about this text is harder to +/// shape than English. DejaVu covers both. +const BICAMERAL_2BYTE_SENTENCES: &[&str] = &[ + "Разметка документа пересчитывается для каждого абзаца.", + "Эта строка проверяет расстановку переносов и межстрочный интервал.", + "Η διάταξη του εγγράφου υπολογίζεται για κάθε παράγραφο.", + "Αυτή η γραμμή ελέγχει τη στοίχιση και το διάστιχο του κειμένου.", + "Ширина колонки влияет на количество строк в абзаце.", + "Το μέγεθος της γραμματοσειράς καθορίζει το ύψος της γραμμής.", +]; + +/// Builds a 2-byte-per-character document (Cyrillic + Greek) — see +/// [`BICAMERAL_2BYTE_SENTENCES`]. +pub fn build_2byte_doc(paras: usize, sentences: usize) -> Document { + build_from_sentences(paras, sentences, BICAMERAL_2BYTE_SENTENCES) +} + +/// Shared paragraph builder for the non-Latin tiers. +/// +/// Paragraphs are seeded so each is distinct, matching [`build_doc`]: identical +/// paragraphs would collide in `ParaCache` and measure deduplication instead of +/// size (L9-012). +fn build_from_sentences(paras: usize, sentences: usize, pool: &[&str]) -> Document { + let blocks: Vec = (0..paras) + .map(|i| { + let mut s = format!("{}. ", i + 1); + for j in 0..sentences { + s.push_str(pool[(i + j) % pool.len()]); + } + Block::Para(vec![Inline::Str(s)]) + }) + .collect(); + let section = Section::with_layout_and_blocks(PageLayout::default(), blocks); + let mut doc = Document::new(); + doc.sections = vec![section]; + doc +} + +/// Japanese and Simplified-Chinese sentences for the CJK tier. +/// +/// Real sentences rather than repeated ideographs: glyph coverage and shaping +/// cost both depend on how many *distinct* characters appear, so a repeated +/// character would understate the font-cache side of the measurement. +const CJK_SENTENCES: &[&str] = &[ + "文書のレイアウトは段落ごとに計算されます。", + "この行は日本語の文字送りを確認するためのものです。", + "编辑器需要在每次按键后重新计算段落布局。", + "字形缓存的大小取决于文档中不同字符的数量。", + "改行位置は字送りと行間の設定によって変わります。", + "表格单元格中的文本会按照列宽自动换行。", +]; + +/// Builds a CJK document of `paras` paragraphs, each `sentences` sentences long. +/// +/// Spec 09 R9-15: every B/char figure in the census is measured on Latin text, +/// and the per-character model may not transfer. CJK is the sharpest test — +/// three bytes per character in UTF-8 against one, no spaces to break on, and +/// glyph coverage in the thousands rather than under a hundred. +/// +/// Paragraphs are seeded so each is distinct, matching [`build_doc`]: identical +/// paragraphs would collide in `ParaCache` and measure deduplication instead of +/// size (L9-012). +pub fn build_cjk_doc(paras: usize, sentences: usize) -> Document { + build_from_sentences(paras, sentences, CJK_SENTENCES) +} + +/// Counts shaped glyphs in a laid-out document, and how many are `.notdef`. +/// +/// Returns `(total, notdef)`. Glyph id 0 is `.notdef` in every OpenType face, so +/// a CJK run against a Latin-only font resolves to a page of tofu that still +/// allocates, shapes, and produces a perfectly plausible B/char figure. That is +/// the R9-13 failure mode exactly — an instrument reporting a believable wrong +/// number — so the CJK tier checks coverage before reporting a rate. +/// Total laid-out lines across a paginated document's editing index. +/// +/// Discriminates the mechanism behind a script's residency rate: full-width CJK +/// fits roughly half as many characters per line as Latin, so per-line and +/// per-run overheads are paid about twice as often per character. If lines per +/// character tracks the rate, the driver is line count; if it does not, it is +/// something per-character in shaping. +pub fn line_count(layout: &DocumentLayout) -> usize { + match layout { + DocumentLayout::Paginated(p) => p + .pages + .iter() + .filter_map(|page| page.editing_data.as_ref()) + .flat_map(|ed| ed.paragraphs.iter()) + .map(|para| para.layout.line_boundaries.len()) + .sum(), + _ => 0, + } +} + +pub fn glyph_coverage(layout: &DocumentLayout) -> (usize, usize) { + fn count(items: &mut dyn Iterator) -> (usize, usize) { + let (mut total, mut notdef) = (0usize, 0usize); + for item in items { + if let PositionedItem::GlyphRun(run) = item { + total += run.glyphs.len(); + notdef += run.glyphs.iter().filter(|g| g.id == 0).count(); + } + } + (total, notdef) + } + match layout { + DocumentLayout::Paginated(p) => { + let (mut total, mut notdef) = (0usize, 0usize); + for page in &p.pages { + let (t, n) = count(&mut page.all_items()); + total += t; + notdef += n; + } + (total, notdef) + } + // Only the paginated mode is measured; anything else reports no + // coverage rather than a number the caller might trust. + _ => (0, 0), + } +} + +/// Repeats a document's blocks `times` over, holding its formatting profile +/// constant while scaling size — the way to vary size independently of +/// formatting density (Spec 09 §4.1). +pub fn repeat_doc(doc: &Document, times: usize) -> Document { + let mut out = doc.clone(); + for section in &mut out.sections { + let original = section.blocks.clone(); + for _ in 1..times { + section.blocks.extend(original.iter().cloned()); + } + } + out +} + +/// Least-squares fit of `rate = intercept + slope · x`, returning +/// `(intercept, slope)`. +/// +/// Used by the duplication sweep: with `x = unique/total` (i.e. `1/n` for an +/// n-fold repeated document), the intercept is the **per-placement** cost that +/// every copy pays and the slope is the **content-keyed** cost that +/// deduplicates. Returns `(0, 0)` for fewer than two distinct `x` values — +/// callers must not report a fit they did not get. +pub fn fit_line(points: &[(f64, f64)]) -> (f64, f64) { + let n = points.len() as f64; + if points.len() < 2 { + return (0.0, 0.0); + } + let sx: f64 = points.iter().map(|p| p.0).sum(); + let sy: f64 = points.iter().map(|p| p.1).sum(); + let sxx: f64 = points.iter().map(|p| p.0 * p.0).sum(); + let sxy: f64 = points.iter().map(|p| p.0 * p.1).sum(); + let denom = n * sxx - sx * sx; + if denom.abs() < f64::EPSILON { + return (0.0, 0.0); + } + let slope = (n * sxy - sx * sy) / denom; + let intercept = (sy - slope * sx) / n; + (intercept, slope) +} diff --git a/loki-layout/src/flow_para.rs b/loki-layout/src/flow_para.rs index 9169b7c5..14a59516 100644 --- a/loki-layout/src/flow_para.rs +++ b/loki-layout/src/flow_para.rs @@ -15,12 +15,9 @@ //! deferred to a future Parley (workaround would be U+202B/U+200F controls). use loki_doc_model::content::block::StyledParagraph; -use loki_doc_model::content::float::{TextWrap, WrapSide}; -use crate::geometry::LayoutRect; -use crate::items::{PositionedImage, PositionedItem}; use crate::para::{ParagraphLayout, ResolvedParaProps, layout_paragraph_spelled}; -use crate::resolve::{emu_to_pt, resolve_para_props}; +use crate::resolve::resolve_para_props; use super::columns_impl::break_column; use super::editing::push_editing_para; @@ -28,6 +25,8 @@ use super::{FlowState, LayoutWarning, finish_page}; #[path = "flow_para_chain.rs"] mod chain; +#[path = "flow_para_images.rs"] +mod images; #[path = "flow_para_place.rs"] mod place; #[path = "flow_split.rs"] @@ -36,6 +35,7 @@ mod split; mod widow_orphan; pub(super) use chain::flow_keep_with_next_chain; +pub(super) use images::{apply_overlay_images, stack_block_images}; use place::{place_paragraph_layout, place_with_footnote_band}; use split::split_and_place_loop; @@ -140,30 +140,45 @@ pub(super) fn flow_paragraph(state: &mut FlowState, para: &StyledParagraph, bloc state.options.spell.as_ref(), ); + // ── Flow-level item injection ──────────────────────────────────────────── + // Picture bullets, inline images and floats are pushed into the paragraph's + // items *after* shaping, so they cannot live in the shared cache entry. + // + // Each is conditional and the overwhelming majority of paragraphs need none + // of them, so the copy is taken only when there is something to inject + // (S9-1): `Arc::make_mut` clones here, since the cache always holds a + // second reference, and the resulting private copy is what reaches both the + // page items and the editing index — exactly the layout that was placed. + // Paragraphs that skip this block keep the single shared allocation. + // // Picture bullet (feature 5.4): place the label image in the hanging label // box on line 0. Injected into the paragraph's items so it translates with // the paragraph on placement. - if let Some(src) = &marker.bullet_src - && let Some(item) = - super::flow_list_marker::picture_bullet_item(src, &resolved, ¶_layout) - { - para_layout.items.push(item); - } + let bullet_item = marker + .bullet_src + .as_ref() + .and_then(|src| super::flow_list_marker::picture_bullet_item(src, &resolved, ¶_layout)); + if bullet_item.is_some() || !images.is_empty() || float_plan.is_some() { + let layout = std::sync::Arc::make_mut(&mut para_layout); + if let Some(item) = bullet_item { + layout.items.push(item); + } - // ── Inline image placement (gap #9) ────────────────────────────────────── - // Block-stack the non-floating images and collect any `wrapNone` overlays. - let overlay_items = stack_block_images(&mut para_layout, &images, state.content_width); + // ── Inline image placement (gap #9) ────────────────────────────────── + // Block-stack the non-floating images and collect any `wrapNone` overlays. + let overlay_items = stack_block_images(layout, &images, state.content_width); - // Emit the float beside the wrapped text; a float taller than its text - // becomes an `ActiveFloat` so *following* paragraphs wrap its remainder. - if let Some((_, placement)) = float_plan { - para_layout.items.push(placement.item); - } + // Emit the float beside the wrapped text; a float taller than its text + // becomes an `ActiveFloat` so *following* paragraphs wrap its remainder. + if let Some((_, placement)) = float_plan { + layout.items.push(placement.item); + } - // Emit overlay (`wrapNone`) floats last: behind-text ones go under the - // whole paragraph (drawn first), in-front ones over the text (drawn last). - // Neither reserves vertical space nor shifts the text. - apply_overlay_images(&mut para_layout, overlay_items); + // Emit overlay (`wrapNone`) floats last: behind-text ones go under the + // whole paragraph (drawn first), in-front ones over the text (drawn last). + // Neither reserves vertical space nor shifts the text. + apply_overlay_images(layout, overlay_items); + } // The paragraph's content top in page coordinates (where the float image's // own top sits), captured before placement may advance/split the cursor. @@ -206,85 +221,3 @@ pub(super) fn flow_paragraph(state: &mut FlowState, para: &StyledParagraph, bloc finish_page(state); } } - -/// Block-stacks a paragraph's non-floating images above its text (gap #9) and -/// returns any `wrapNone` overlays for the caller to emit after floats. -/// -/// TODO(inline-image-flow): Parley has no inline image boxes, so images are a -/// block-level prefix — existing items shift down to make room. Shared by -/// [`flow_paragraph`] and the keep-with-next chain (`flow_para_chain`) so an -/// image in a `keepNext` paragraph (e.g. a captioned figure) is not dropped. -pub(super) fn stack_block_images( - para_layout: &mut ParagraphLayout, - images: &[crate::resolve::CollectedImage], - content_width: f32, -) -> Vec<(bool, PositionedItem)> { - let mut total_image_height = 0.0f32; - let mut image_items: Vec = Vec::new(); - // Overlay floats (`wrapNone`): Word reserves no space for them, so instead - // of stacking above the text they float at a side-anchored position over - // the full-width text (or under it when `behind_text`). - let mut overlay_items: Vec<(bool, PositionedItem)> = Vec::new(); - for img in images { - if img.cx_emu == 0 && img.cy_emu == 0 { - continue; // zero-size image — skip without crashing - } - let w = emu_to_pt(img.cx_emu); - let h = emu_to_pt(img.cy_emu); - if let Some(f) = img.float.filter(|f| f.wrap == TextWrap::None) { - // Anchor to the same side `plan_float` would have chosen: text on - // the left (`side=Left`) means the object sits on the right. - let x = if matches!(f.side, WrapSide::Left) { - (content_width - w).max(0.0) - } else { - 0.0 - }; - overlay_items.push(( - f.behind_text, - PositionedItem::Image(PositionedImage { - rect: LayoutRect::new(x, 0.0, w, h), - src: img.src.clone(), - alt: img.alt.clone(), - }), - )); - continue; - } - image_items.push(PositionedItem::Image(PositionedImage { - rect: LayoutRect::new(0.0, total_image_height, w, h), - src: img.src.clone(), - alt: img.alt.clone(), - })); - total_image_height += h; - } - if total_image_height > 0.0 { - // Expand background fill to cover image area (first item when present). - if let Some(PositionedItem::FilledRect(bg)) = para_layout.items.first_mut() { - bg.rect.size.height += total_image_height; - } - // Shift all existing paragraph items down by total image height. - for item in &mut para_layout.items { - item.translate(0.0, total_image_height); - } - para_layout.height += total_image_height; - // Prepend image items (they render before paragraph text). - image_items.append(&mut para_layout.items); - para_layout.items = image_items; - } - overlay_items -} - -/// Emits `wrapNone` overlay images: behind-text ones under the whole paragraph -/// (drawn first), in-front ones over the text (drawn last). Neither reserves -/// vertical space nor shifts the text. -pub(super) fn apply_overlay_images( - para_layout: &mut ParagraphLayout, - overlay_items: Vec<(bool, PositionedItem)>, -) { - for (behind, item) in overlay_items { - if behind { - para_layout.items.insert(0, item); - } else { - para_layout.items.push(item); - } - } -} diff --git a/loki-layout/src/flow_para_chain.rs b/loki-layout/src/flow_para_chain.rs index 37b34f59..41130570 100644 --- a/loki-layout/src/flow_para_chain.rs +++ b/loki-layout/src/flow_para_chain.rs @@ -9,9 +9,11 @@ //! `super::place_paragraph_layout` and the block synthesizers via //! `super::super::` (the `flow` module). +use std::sync::Arc; + use loki_doc_model::content::block::{Block, StyledParagraph}; -use crate::para::{ParagraphLayout, ResolvedParaProps, layout_paragraph_spelled}; +use crate::para::{ByteIndexMap, ParagraphLayout, ResolvedParaProps, layout_paragraph_spelled}; use crate::resolve::{CollectedNote, resolve_para_props}; use super::{FlowState, LayoutWarning, break_column, finish_page, place_paragraph_layout}; @@ -20,7 +22,10 @@ use super::{FlowState, LayoutWarning, break_column, finish_page, place_paragraph /// and the footnotes/endnotes it collected (committed to `pending_footnotes` /// only when the block is actually placed, so a re-flowed too-tall suffix does /// not double-collect). -type ChainEntry = (ResolvedParaProps, ParagraphLayout, Vec); +/// +/// The layout is the shaping cache's `Arc` (S9-1); a chain member with images +/// takes a private copy via `Arc::make_mut`, the rest share the entry. +type ChainEntry = (ResolvedParaProps, Arc, Vec); /// Maximum keep-with-next chain length before truncation (ADR 004 §4). const CHAIN_LIMIT: usize = 5; @@ -167,14 +172,18 @@ fn build_chain_layouts<'s>( // Block-stack any inline images (a captioned figure with // `keepNext` on its image paragraph would otherwise vanish — // the chain path formerly discarded the collected images). - let overlay = super::stack_block_images(&mut layout, &images, state.content_width); - super::apply_overlay_images(&mut layout, overlay); + // Copy-on-write only when there are images to stack (S9-1). + if !images.is_empty() { + let l = Arc::make_mut(&mut layout); + let overlay = super::stack_block_images(l, &images, state.content_width); + super::apply_overlay_images(l, overlay); + } out.push((resolved, layout, notes)); } else { // Non-text block (HR, table, etc.): contribute zero height. out.push(( ResolvedParaProps::default(), - ParagraphLayout { + Arc::new(ParagraphLayout { height: 0.0, width: 0.0, items: vec![], @@ -182,13 +191,13 @@ fn build_chain_layouts<'s>( last_baseline: 0.0, line_boundaries: vec![], parley_layout: None, - orig_to_clean: vec![0], - clean_to_orig: vec![0], + orig_to_clean: ByteIndexMap::Identity { len: 1 }, + clean_to_orig: ByteIndexMap::Identity { len: 1 }, indent_start: 0.0, indent_hanging: 0.0, drop_lines: 0, drop_shift: 0.0, - }, + }), Vec::new(), )); } diff --git a/loki-layout/src/flow_para_images.rs b/loki-layout/src/flow_para_images.rs new file mode 100644 index 00000000..16bfe9f8 --- /dev/null +++ b/loki-layout/src/flow_para_images.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Inline-image placement within a paragraph: block-stacking non-floating +//! images above the text and emitting `wrapNone` overlays. +//! +//! Extracted from `flow_para.rs` for the 300-line ceiling. Both functions +//! mutate a paragraph layout *after* shaping, which is why the caller must take +//! a private copy first — a shaped layout is shared with the paragraph cache +//! (Spec 09 S9-1), and mutating it in place would leak this paragraph's images +//! into every other placement of the same text. + +use loki_doc_model::content::float::{TextWrap, WrapSide}; + +use crate::geometry::LayoutRect; +use crate::items::{PositionedImage, PositionedItem}; +use crate::para::ParagraphLayout; +use crate::resolve::emu_to_pt; + +/// Block-stacks a paragraph's non-floating images above its text (gap #9) and +/// returns any `wrapNone` overlays for the caller to emit after floats. +/// +/// TODO(inline-image-flow): Parley has no inline image boxes, so images are a +/// block-level prefix — existing items shift down to make room. Shared by +/// [`flow_paragraph`] and the keep-with-next chain (`flow_para_chain`) so an +/// image in a `keepNext` paragraph (e.g. a captioned figure) is not dropped. +pub(crate) fn stack_block_images( + para_layout: &mut ParagraphLayout, + images: &[crate::resolve::CollectedImage], + content_width: f32, +) -> Vec<(bool, PositionedItem)> { + let mut total_image_height = 0.0f32; + let mut image_items: Vec = Vec::new(); + // Overlay floats (`wrapNone`): Word reserves no space for them, so instead + // of stacking above the text they float at a side-anchored position over + // the full-width text (or under it when `behind_text`). + let mut overlay_items: Vec<(bool, PositionedItem)> = Vec::new(); + for img in images { + if img.cx_emu == 0 && img.cy_emu == 0 { + continue; // zero-size image — skip without crashing + } + let w = emu_to_pt(img.cx_emu); + let h = emu_to_pt(img.cy_emu); + if let Some(f) = img.float.filter(|f| f.wrap == TextWrap::None) { + // Anchor to the same side `plan_float` would have chosen: text on + // the left (`side=Left`) means the object sits on the right. + let x = if matches!(f.side, WrapSide::Left) { + (content_width - w).max(0.0) + } else { + 0.0 + }; + overlay_items.push(( + f.behind_text, + PositionedItem::Image(PositionedImage { + rect: LayoutRect::new(x, 0.0, w, h), + src: img.src.clone(), + alt: img.alt.clone(), + }), + )); + continue; + } + image_items.push(PositionedItem::Image(PositionedImage { + rect: LayoutRect::new(0.0, total_image_height, w, h), + src: img.src.clone(), + alt: img.alt.clone(), + })); + total_image_height += h; + } + if total_image_height > 0.0 { + // Expand background fill to cover image area (first item when present). + if let Some(PositionedItem::FilledRect(bg)) = para_layout.items.first_mut() { + bg.rect.size.height += total_image_height; + } + // Shift all existing paragraph items down by total image height. + for item in &mut para_layout.items { + item.translate(0.0, total_image_height); + } + para_layout.height += total_image_height; + // Prepend image items (they render before paragraph text). + image_items.append(&mut para_layout.items); + para_layout.items = image_items; + } + overlay_items +} + +/// Emits `wrapNone` overlay images: behind-text ones under the whole paragraph +/// (drawn first), in-front ones over the text (drawn last). Neither reserves +/// vertical space nor shifts the text. +pub(crate) fn apply_overlay_images( + para_layout: &mut ParagraphLayout, + overlay_items: Vec<(bool, PositionedItem)>, +) { + for (behind, item) in overlay_items { + if behind { + para_layout.items.insert(0, item); + } else { + para_layout.items.push(item); + } + } +} diff --git a/loki-layout/src/flow_para_place.rs b/loki-layout/src/flow_para_place.rs index 56d48945..c54d552e 100644 --- a/loki-layout/src/flow_para_place.rs +++ b/loki-layout/src/flow_para_place.rs @@ -27,7 +27,7 @@ use super::{ pub(super) fn place_with_footnote_band( state: &mut FlowState, resolved: &ResolvedParaProps, - para_layout: ParagraphLayout, + para_layout: Arc, block_index: usize, text_empty: bool, reserve: f32, @@ -51,13 +51,19 @@ pub(super) fn place_with_footnote_band( /// /// `space_before` must already be reflected in `state.cursor_y` by the caller. /// +/// `para_layout` arrives as the shaping cache's own `Arc` (S9-1), so the editing +/// index shares that allocation instead of deep-copying it. Page items are +/// cloned out of it because they are translated into page coordinates, which the +/// shared paragraph-local layout must not be: that copy is the irreducible +/// per-placement cost, and it was already a clone on every cache hit. +/// /// # Errors /// /// Non-fatal issues are pushed onto `state.warnings` rather than returned. pub(super) fn place_paragraph_layout( state: &mut FlowState, resolved: &ResolvedParaProps, - para_layout: ParagraphLayout, + para_layout: Arc, block_index: usize, ) { if !state.mode.is_paginated() { @@ -65,9 +71,10 @@ pub(super) fn place_paragraph_layout( let dx = state.current_indent; if state.options.preserve_for_editing { // origin (dx, dy) matches the item translation below (lists indent dx). - push_editing_para(state, block_index, Arc::new(para_layout.clone()), (dx, dy)); + push_editing_para(state, block_index, Arc::clone(¶_layout), (dx, dy)); } - for mut item in para_layout.items { + for item in ¶_layout.items { + let mut item = item.clone(); item.translate(dx, dy); state.current_items.push(item); } @@ -100,10 +107,11 @@ pub(super) fn place_paragraph_layout( let dy = state.cursor_y; let dx = state.current_indent; if state.options.preserve_for_editing { - push_editing_para(state, block_index, Arc::new(para_layout.clone()), (0.0, dy)); + push_editing_para(state, block_index, Arc::clone(¶_layout), (0.0, dy)); } super::super::line_numbers::emit(state, ¶_layout, dy, 0.0, para_layout.height); - for mut item in para_layout.items { + for item in ¶_layout.items { + let mut item = item.clone(); item.translate(dx, dy); state.current_items.push(item); } @@ -121,11 +129,10 @@ pub(super) fn place_paragraph_layout( } let dx = state.current_indent; - let arc_layout = if state.options.preserve_for_editing { - Some(Arc::new(para_layout.clone())) - } else { - None - }; + let arc_layout = state + .options + .preserve_for_editing + .then(|| Arc::clone(¶_layout)); split_and_place_loop(state, resolved, ¶_layout, arc_layout, block_index, dx); state.cursor_y += resolved.space_after; } diff --git a/loki-layout/src/items.rs b/loki-layout/src/items.rs index 08751f83..ee37a494 100644 --- a/loki-layout/src/items.rs +++ b/loki-layout/src/items.rs @@ -8,12 +8,15 @@ //! layout space. The `loki-vello` crate translates these into Vello scene //! commands; `loki-layout` itself has no Vello or GPU types. -use std::sync::Arc; - use crate::color::LayoutColor; use crate::geometry::{LayoutPoint, LayoutRect}; use crate::hatch::PositionedHatch; +#[path = "items_glyph.rs"] +mod glyph; + +pub use glyph::{GlyphEntry, GlyphSynthesis, PositionedGlyphRun}; + /// A single renderer-agnostic draw item with an absolute position in layout /// space. /// @@ -107,66 +110,37 @@ impl PositionedItem { } } } -} -/// A positioned and shaped glyph run ready for rendering. -#[derive(Debug, Clone)] -pub struct PositionedGlyphRun { - /// Top-left origin of the run in layout space. - pub origin: LayoutPoint, - /// Raw font table data for the face used in this run. + /// Releases spare capacity in this item and anything nested inside it. /// - /// Kept as raw bytes to avoid `loki-layout` depending on Parley's glyph - /// types at the output level. `loki-vello` decodes this using the same - /// Parley version. - pub font_data: Arc>, - /// Font index within the font data (for TTC / font collections). - pub font_index: u32, - /// Font size in points. - pub font_size: f32, - /// Individual glyphs in this run. - pub glyphs: Vec, - /// Text color. - pub color: LayoutColor, - /// Synthesis flags (bold/italic synthesis). - pub synthesis: GlyphSynthesis, - /// Normalized variation coordinates (F2Dot14 raw i16, one per fvar axis) - /// for this run's selected face, as resolved by Parley. Non-empty only for - /// variable fonts — e.g. the bundled Arimo (Arial substitute) is a `wght` - /// variable font, so a bold run carries its `wght=700` coordinate here. - /// Both painters must apply these; rendering the default (all-zero) master - /// instead paints regular-weight glyphs with bold advances (gap: bold Arial - /// looked "wide but not bold"). - pub normalized_coords: Vec, - /// Hyperlink URL if this run is part of a link. `None` for non-link text. + /// Glyph runs are built by `push`, so `glyphs` carries up to 2× doubling + /// slack. That slack used to be discarded for free: the shaping cache was + /// populated with `result.clone()`, and cloning a `Vec` allocates exactly + /// `len`, so the *cached* copy was compact and the loose original was + /// transient. Sharing one allocation with the editing index (Spec 09 S9-1) + /// removed that clone and with it the accidental compaction — worth ~11 + /// B/char of long-lived residency, which the E0 sweep caught as a rise in + /// the read-only condition after an otherwise clean win. /// - /// A blue-tint underlay hint is rendered by `loki-vello`, a point resolves - /// to its URL via `ContinuousLayout::link_at` / `PageEditingData::link_at`, - /// and Ctrl/Cmd+click opens it in both paginated and reflow modes - /// (feature 5.11). - pub link_url: Option, -} - -/// A single glyph with its position relative to the run origin. -#[derive(Debug, Clone, Copy)] -pub struct GlyphEntry { - /// Glyph ID. - pub id: u16, - /// X position relative to the run origin. - pub x: f32, - /// Y position relative to the run origin (baseline offset). - pub y: f32, - /// Horizontal advance in points. - pub advance: f32, -} - -/// Font synthesis flags applied when the requested style is not available. -#[derive(Debug, Clone, Copy, Default)] -pub struct GlyphSynthesis { - /// Bold synthesis is active. - pub bold: bool, - /// Italic synthesis is active. - pub italic: bool, + /// Made explicit here rather than left to a clone, so the compaction has an + /// owner and survives the next refactor that removes a copy. + pub(crate) fn shrink_to_fit(&mut self) { + match self { + Self::GlyphRun(r) => r.glyphs.shrink_to_fit(), + Self::ClippedGroup { items, .. } | Self::RotatedGroup { items, .. } => { + items.shrink_to_fit(); + for item in items { + item.shrink_to_fit(); + } + } + Self::FilledRect(_) + | Self::HorizontalRule(_) + | Self::HatchRect(_) + | Self::BorderRect(_) + | Self::Image(_) + | Self::Decoration(_) => {} + } + } } /// A filled rectangle with a solid color. diff --git a/loki-layout/src/items_glyph.rs b/loki-layout/src/items_glyph.rs new file mode 100644 index 00000000..1bad7883 --- /dev/null +++ b/loki-layout/src/items_glyph.rs @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Shaped-glyph output types: [`PositionedGlyphRun`] and its parts. +//! +//! Extracted from `items.rs` for the 300-line ceiling. Re-exported from the +//! parent, so `crate::items::PositionedGlyphRun` and every public path through +//! `lib.rs` are unchanged. +//! +//! `glyphs` is the largest per-paragraph allocation in a layout, and the one +//! Spec 09 S9-1 made shared rather than copied per placement; see +//! [`super::PositionedItem::shrink_to_fit`] for why its spare capacity is now +//! released explicitly. + +use std::sync::Arc; + +use crate::color::LayoutColor; +use crate::geometry::LayoutPoint; + +/// A positioned and shaped glyph run ready for rendering. +#[derive(Debug, Clone)] +pub struct PositionedGlyphRun { + /// Top-left origin of the run in layout space. + pub origin: LayoutPoint, + /// Raw font table data for the face used in this run. + /// + /// Kept as raw bytes to avoid `loki-layout` depending on Parley's glyph + /// types at the output level. `loki-vello` decodes this using the same + /// Parley version. + pub font_data: Arc>, + /// Font index within the font data (for TTC / font collections). + pub font_index: u32, + /// Font size in points. + pub font_size: f32, + /// Individual glyphs in this run. + pub glyphs: Vec, + /// Text color. + pub color: LayoutColor, + /// Synthesis flags (bold/italic synthesis). + pub synthesis: GlyphSynthesis, + /// Normalized variation coordinates (F2Dot14 raw i16, one per fvar axis) + /// for this run's selected face, as resolved by Parley. Non-empty only for + /// variable fonts — e.g. the bundled Arimo (Arial substitute) is a `wght` + /// variable font, so a bold run carries its `wght=700` coordinate here. + /// Both painters must apply these; rendering the default (all-zero) master + /// instead paints regular-weight glyphs with bold advances (gap: bold Arial + /// looked "wide but not bold"). + pub normalized_coords: Vec, + /// Hyperlink URL if this run is part of a link. `None` for non-link text. + /// + /// A blue-tint underlay hint is rendered by `loki-vello`, a point resolves + /// to its URL via `ContinuousLayout::link_at` / `PageEditingData::link_at`, + /// and Ctrl/Cmd+click opens it in both paginated and reflow modes + /// (feature 5.11). + pub link_url: Option, +} + +/// A single glyph with its position relative to the run origin. +#[derive(Debug, Clone, Copy)] +pub struct GlyphEntry { + /// Glyph ID. + pub id: u16, + /// X position relative to the run origin. + pub x: f32, + /// Y position relative to the run origin (baseline offset). + pub y: f32, + /// Horizontal advance in points. + pub advance: f32, +} + +/// Font synthesis flags applied when the requested style is not available. +#[derive(Debug, Clone, Copy, Default)] +pub struct GlyphSynthesis { + /// Bold synthesis is active. + pub bold: bool, + /// Italic synthesis is active. + pub italic: bool, +} diff --git a/loki-layout/src/lib.rs b/loki-layout/src/lib.rs index b4d518cf..6333766e 100644 --- a/loki-layout/src/lib.rs +++ b/loki-layout/src/lib.rs @@ -67,8 +67,8 @@ pub use layout_entry::{layout_document, layout_paginated_full}; pub use mode::LayoutMode; pub use options::{FieldContext, LayoutOptions, RevisionDisplay, SpellState}; pub use para::{ - Affinity, CursorRect, HitTestResult, ParagraphLayout, ResolvedLineHeight, ResolvedParaProps, - StyleSpan, layout_paragraph, + Affinity, ByteIndexMap, CursorRect, HitTestResult, ParagraphLayout, ResolvedLineHeight, + ResolvedParaProps, StyleSpan, layout_paragraph, }; pub use resolve::{ CollectedImage, CollectedNote, emu_to_pt, flatten_paragraph, pts_to_f32, resolve_char_props, diff --git a/loki-layout/src/para.rs b/loki-layout/src/para.rs index 860e6a4c..a12cb652 100644 --- a/loki-layout/src/para.rs +++ b/loki-layout/src/para.rs @@ -20,6 +20,10 @@ use crate::items::{PositionedBorderRect, PositionedItem}; #[path = "para_build.rs"] mod build; +#[path = "para_clean.rs"] +mod clean; +#[path = "para_index_map.rs"] +mod index_map; #[path = "para_layout_types.rs"] mod layout_types; #[path = "para_query.rs"] @@ -33,6 +37,7 @@ mod types; #[path = "para_underlays.rs"] mod underlays; +pub use index_map::ByteIndexMap; pub use layout_types::{ Affinity, CursorRect, HitTestResult, ParagraphLayout, ResolvedParaProps, WrapBand, }; @@ -44,67 +49,6 @@ pub use types::{ use build::push_math_inline_boxes; pub(crate) use build::push_para_styles; -/// Strips characters Parley must not see (control chars and the BOM, keeping -/// `\t`/`\n`) and remaps the style spans onto the cleaned text. -/// -/// Returns `(clean_text, clean_spans, orig_to_clean, clean_to_orig)` — the two -/// byte-index maps let editor hit-testing translate between the original and -/// cleaned coordinate spaces. -fn clean_text_and_spans( - text: &str, - spans: &[StyleSpan], -) -> (String, Vec, Vec, Vec) { - let mut clean_text = String::with_capacity(text.len()); - let mut orig_to_clean = vec![0; text.len() + 1]; - let mut clean_to_orig = Vec::with_capacity(text.len() + 1); - - let mut orig_idx = 0; - let mut clean_idx = 0; - - for c in text.chars() { - let c_len = c.len_utf8(); - // Drop `\t`: a tab is pure positioning (an inline box). Left in, it - // shapes to a `.notdef` (fonts lacking a tab glyph, e.g. Arimo) whose - // advance stacks on the box and overshoots the stop; byte maps anyway. - let keep = c == '\n' || (!c.is_control() && c != '\u{feff}'); - if keep { - for i in 0..c_len { - orig_to_clean[orig_idx + i] = clean_idx + i; - clean_to_orig.push(orig_idx + i); - } - clean_text.push(c); - orig_idx += c_len; - clean_idx += c_len; - } else { - for i in 0..c_len { - orig_to_clean[orig_idx + i] = clean_idx; - } - orig_idx += c_len; - } - } - orig_to_clean[orig_idx] = clean_idx; - clean_to_orig.push(orig_idx); - - let clean_spans = spans - .iter() - .map(|span| { - let mut clean_span = span.clone(); - let start = orig_to_clean - .get(span.range.start) - .copied() - .unwrap_or(clean_idx); - let end = orig_to_clean - .get(span.range.end) - .copied() - .unwrap_or(clean_idx); - clean_span.range = start..end; - clean_span - }) - .collect(); - - (clean_text, clean_spans, orig_to_clean, clean_to_orig) -} - /// Inline-box id base for math placeholders, kept clear of the tab-stop ids /// (which count up from 0) so the two can coexist in one paragraph. const MATH_ID_BASE: u64 = 1 << 40; @@ -134,6 +78,10 @@ const END_ID: u64 = 1 << 30; /// laid out again (e.g. every paragraph except the edited one, on a keystroke) /// the cached layout is cloned instead of re-shaped. See /// [`crate::para_cache`]. +/// +/// The cache holds `Arc` (S9-1), so this owned-value entry +/// point clones once out of the shared entry. Callers inside the flow engine use +/// [`layout_paragraph_spelled`] and keep the `Arc`. pub fn layout_paragraph( resources: &mut FontResources, text_content: &str, @@ -143,7 +91,7 @@ pub fn layout_paragraph( display_scale: f32, preserve_for_editing: bool, ) -> ParagraphLayout { - layout_paragraph_spelled( + let shared = layout_paragraph_spelled( resources, text_content, style_spans, @@ -152,14 +100,21 @@ pub fn layout_paragraph( display_scale, preserve_for_editing, None, - ) + ); + // The caller wants ownership; the cache keeps its entry. + Arc::unwrap_or_clone(shared) } -/// [`layout_paragraph`] with an optional spell checker. +/// [`layout_paragraph`] with an optional spell checker, returning the cache's +/// own `Arc` rather than a copy. /// /// When `spell` is `Some`, misspelled words emit [`DecorationKind::Spelling`] /// squiggles. The checker's `generation` folds into the cache key so cached /// layouts are reused only while the dictionary/word-lists are unchanged. +/// +/// Callers that need to modify the layout — the flow engine injects inline +/// images, floats and picture bullets after shaping — use `Arc::make_mut`, which +/// copies only for the paragraphs that actually need it (S9-1). // One arg over the limit: the optional spell checker on the shaping hot path. #[allow(clippy::too_many_arguments)] pub(crate) fn layout_paragraph_spelled( @@ -171,7 +126,7 @@ pub(crate) fn layout_paragraph_spelled( display_scale: f32, preserve_for_editing: bool, spell: Option<&crate::SpellState>, -) -> ParagraphLayout { +) -> Arc { let spell_generation = spell.map_or(0, |s| s.generation); let key = crate::para_cache::para_key( text_content, @@ -195,8 +150,20 @@ pub(crate) fn layout_paragraph_spelled( preserve_for_editing, spell, ); - resources.para_cache.put(key, result.clone()); - result + // Moved into the `Arc`, not cloned into it: the cache and every consumer of + // this call now hold the same allocation. + // + // Trim first. Before S9-1 the cache held `result.clone()`, and `Vec::clone` + // allocates capacity == len, so the *tight* copy was cached and the + // push-grown original was transient. Moving the original in reverses that + // and retains its slack — worth ~15 B/char of read-only residency, which the + // E0 sweep caught as a rise in the non-editing condition. One realloc per + // cache miss buys it back; misses are the shaping path, so the cost is noise. + let mut result = result; + result.shrink_to_fit(); + let shared = Arc::new(result); + resources.para_cache.put(key, Arc::clone(&shared)); + shared } /// Prepends the paragraph's border and background-fill rects to `items` (so @@ -251,7 +218,7 @@ fn layout_paragraph_uncached( spell: Option<&crate::SpellState>, ) -> ParagraphLayout { let (mut clean_text, mut clean_spans, mut orig_to_clean, mut clean_to_orig) = - clean_text_and_spans(text_content, style_spans); + clean::clean_text_and_spans(text_content, style_spans); for span in &mut clean_spans { if let Some(ref name) = span.font_name { @@ -322,8 +289,8 @@ fn layout_paragraph_uncached( last_baseline: first_baseline, line_boundaries, parley_layout: preserve_for_editing.then(|| Arc::new(phantom)), - orig_to_clean, - clean_to_orig, + orig_to_clean: ByteIndexMap::from_indices(&orig_to_clean), + clean_to_orig: ByteIndexMap::from_indices(&clean_to_orig), indent_start: para_props.indent_start, indent_hanging: para_props.indent_hanging, drop_lines: 0, @@ -519,8 +486,8 @@ fn layout_paragraph_uncached( last_baseline: body.last_baseline, line_boundaries: body.line_boundaries, parley_layout: None, - orig_to_clean, - clean_to_orig, + orig_to_clean: ByteIndexMap::from_indices(&orig_to_clean), + clean_to_orig: ByteIndexMap::from_indices(&clean_to_orig), indent_start: para_props.indent_start, indent_hanging: para_props.indent_hanging, drop_lines: 0, @@ -727,8 +694,8 @@ fn layout_paragraph_uncached( last_baseline, line_boundaries, parley_layout, - orig_to_clean, - clean_to_orig, + orig_to_clean: ByteIndexMap::from_indices(&orig_to_clean), + clean_to_orig: ByteIndexMap::from_indices(&clean_to_orig), indent_start: para_props.indent_start, indent_hanging: para_props.indent_hanging, drop_lines, diff --git a/loki-layout/src/para_cache.rs b/loki-layout/src/para_cache.rs index 0369c1c5..703b2ab2 100644 --- a/loki-layout/src/para_cache.rs +++ b/loki-layout/src/para_cache.rs @@ -19,6 +19,7 @@ use std::collections::HashMap; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; +use std::sync::Arc; use crate::para::{ParagraphLayout, ResolvedParaProps, StyleSpan}; @@ -36,21 +37,37 @@ const CACHE_CAP: usize = 2048; /// working set survives rotation; everything not touched within one rotation is /// dropped. This bounds memory to roughly `2 × CACHE_CAP` entries without the /// per-entry bookkeeping of a true LRU. +/// +/// # Why `Arc` (Spec 09 S9-1) +/// +/// Entries are `Arc` so the cache and the **page editing +/// index** share one allocation instead of holding two deep copies. Before this, +/// a hit cloned the whole layout out of the cache and `place_paragraph_layout` +/// cloned it a second time for `PageParagraphData` — measured at 39.3 B/char of +/// per-placement editing residency (`docs/spikes/S09.0-layout-residency-census.md` +/// §10c). Handing out the `Arc` makes a hit a refcount bump. +/// +/// **Consequence for eviction:** the cache is now an owner of record for glyph +/// data, so dropping `editing_data` alone frees nothing while an entry survives. +/// S9-4 and S9-5 must release both owners, and per R9-07 this cache is bounded +/// by entry count rather than bytes — `CACHE_CAP` binds only past ~1000 pages, +/// so across the realistic range it holds every paragraph. #[derive(Default)] pub(crate) struct ParaCache { - current: HashMap, - previous: HashMap, + current: HashMap>, + previous: HashMap>, } impl ParaCache { - /// Returns a clone of the cached layout for `key`, if present. A hit in the - /// older generation is promoted so it is not lost at the next rotation. - pub(crate) fn get(&mut self, key: u64) -> Option { + /// Returns the cached layout for `key`, if present — a refcount bump, not a + /// copy. A hit in the older generation is promoted so it is not lost at the + /// next rotation. + pub(crate) fn get(&mut self, key: u64) -> Option> { if let Some(v) = self.current.get(&key) { - return Some(v.clone()); + return Some(Arc::clone(v)); } if let Some(v) = self.previous.remove(&key) { - let out = v.clone(); + let out = Arc::clone(&v); self.current.insert(key, v); return Some(out); } @@ -59,7 +76,7 @@ impl ParaCache { /// Inserts `value` under `key`, rotating generations when the current one is /// full. - pub(crate) fn put(&mut self, key: u64, value: ParagraphLayout) { + pub(crate) fn put(&mut self, key: u64, value: Arc) { if self.current.len() >= CACHE_CAP { self.previous = std::mem::take(&mut self.current); } @@ -132,165 +149,5 @@ pub(crate) fn para_key( } #[cfg(test)] -mod tests { - use crate::color::LayoutColor; - use crate::font::FontResources; - use crate::para::{ResolvedParaProps, StyleSpan, layout_paragraph}; - - fn resources() -> FontResources { - let mut r = FontResources::new(); - for p in [ - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", - ] { - if let Ok(data) = std::fs::read(p) { - r.register_font(data); - } - } - r - } - - fn span(text: &str) -> StyleSpan { - 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, - character_border: None, - letter_spacing: None, - font_variant: None, - word_spacing: None, - shadow: false, - emboss: false, - imprint: false, - link_url: None, - math: None, - scale: None, - kerning: None, - baseline_shift: None, - language: None, - } - } - - fn lay(r: &mut FontResources, text: &str, spans: &[StyleSpan], width: f32) { - let _ = layout_paragraph( - r, - text, - spans, - &ResolvedParaProps::default(), - width, - 1.0, - true, - ); - } - - #[test] - fn identical_inputs_hit_and_match() { - let mut r = resources(); - let text = "Hello cache world"; - let spans = [span(text)]; - - let first = layout_paragraph( - &mut r, - text, - &spans, - &ResolvedParaProps::default(), - 400.0, - 1.0, - true, - ); - assert_eq!( - r.para_cache.len(), - 1, - "first call should populate the cache" - ); - - let second = layout_paragraph( - &mut r, - text, - &spans, - &ResolvedParaProps::default(), - 400.0, - 1.0, - true, - ); - // Identical inputs must be a hit (no new entry) and reproduce the layout. - assert_eq!( - r.para_cache.len(), - 1, - "identical call should hit, not insert" - ); - assert_eq!(first.height, second.height); - assert_eq!(first.width, second.width); - assert_eq!(first.items.len(), second.items.len()); - } - - #[test] - fn changed_inputs_are_misses() { - let mut r = resources(); - let base = "alpha"; - - lay(&mut r, base, &[span(base)], 400.0); - assert_eq!(r.para_cache.len(), 1); - - // Different text. - lay(&mut r, "bravo", &[span("bravo")], 400.0); - assert_eq!(r.para_cache.len(), 2, "different text must miss"); - - // Different width, same text/spans. - lay(&mut r, base, &[span(base)], 200.0); - assert_eq!(r.para_cache.len(), 3, "different width must miss"); - - // Different char property (bold) on the same text. - let mut bold = span(base); - bold.bold = true; - lay(&mut r, base, &[bold], 400.0); - assert_eq!(r.para_cache.len(), 4, "different style span must miss"); - - // Different run language (gap #30): squiggle routing depends on it, so - // it must participate in the key (covered by the Debug fold). - let mut tagged = span(base); - tagged.language = Some("fr-FR".into()); - lay(&mut r, base, &[tagged], 400.0); - assert_eq!(r.para_cache.len(), 5, "different language must miss"); - } - - #[test] - fn clear_drops_all_entries() { - let mut r = resources(); - lay(&mut r, "one", &[span("one")], 400.0); - lay(&mut r, "two", &[span("two")], 400.0); - assert_eq!(r.para_cache.len(), 2); - - r.clear_paragraph_cache(); - assert_eq!(r.para_cache.len(), 0, "clear should drop every entry"); - - // A subsequent layout repopulates from scratch (miss, not stale hit). - lay(&mut r, "one", &[span("one")], 400.0); - assert_eq!(r.para_cache.len(), 1); - } - - #[test] - fn preserve_flag_is_part_of_key() { - let mut r = resources(); - let text = "preserve flag"; - let spans = [span(text)]; - let props = ResolvedParaProps::default(); - - let _ = layout_paragraph(&mut r, text, &spans, &props, 400.0, 1.0, true); - let _ = layout_paragraph(&mut r, text, &spans, &props, 400.0, 1.0, false); - assert_eq!( - r.para_cache.len(), - 2, - "preserve_for_editing must distinguish cache entries" - ); - } -} +#[path = "para_cache_tests.rs"] +mod tests; diff --git a/loki-layout/src/para_cache_tests.rs b/loki-layout/src/para_cache_tests.rs new file mode 100644 index 00000000..058798c2 --- /dev/null +++ b/loki-layout/src/para_cache_tests.rs @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for `para_cache` (extracted for the 300-line ceiling). + +use crate::color::LayoutColor; +use crate::font::FontResources; +use crate::para::{ResolvedParaProps, StyleSpan, layout_paragraph}; + +fn resources() -> FontResources { + let mut r = FontResources::new(); + for p in [ + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + ] { + if let Ok(data) = std::fs::read(p) { + r.register_font(data); + } + } + r +} + +fn span(text: &str) -> StyleSpan { + 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, + character_border: None, + letter_spacing: None, + font_variant: None, + word_spacing: None, + shadow: false, + emboss: false, + imprint: false, + link_url: None, + math: None, + scale: None, + kerning: None, + baseline_shift: None, + language: None, + } +} + +fn lay(r: &mut FontResources, text: &str, spans: &[StyleSpan], width: f32) { + let _ = layout_paragraph( + r, + text, + spans, + &ResolvedParaProps::default(), + width, + 1.0, + true, + ); +} + +#[test] +fn identical_inputs_hit_and_match() { + let mut r = resources(); + let text = "Hello cache world"; + let spans = [span(text)]; + + let first = layout_paragraph( + &mut r, + text, + &spans, + &ResolvedParaProps::default(), + 400.0, + 1.0, + true, + ); + assert_eq!( + r.para_cache.len(), + 1, + "first call should populate the cache" + ); + + let second = layout_paragraph( + &mut r, + text, + &spans, + &ResolvedParaProps::default(), + 400.0, + 1.0, + true, + ); + // Identical inputs must be a hit (no new entry) and reproduce the layout. + assert_eq!( + r.para_cache.len(), + 1, + "identical call should hit, not insert" + ); + assert_eq!(first.height, second.height); + assert_eq!(first.width, second.width); + assert_eq!(first.items.len(), second.items.len()); +} + +#[test] +fn changed_inputs_are_misses() { + let mut r = resources(); + let base = "alpha"; + + lay(&mut r, base, &[span(base)], 400.0); + assert_eq!(r.para_cache.len(), 1); + + // Different text. + lay(&mut r, "bravo", &[span("bravo")], 400.0); + assert_eq!(r.para_cache.len(), 2, "different text must miss"); + + // Different width, same text/spans. + lay(&mut r, base, &[span(base)], 200.0); + assert_eq!(r.para_cache.len(), 3, "different width must miss"); + + // Different char property (bold) on the same text. + let mut bold = span(base); + bold.bold = true; + lay(&mut r, base, &[bold], 400.0); + assert_eq!(r.para_cache.len(), 4, "different style span must miss"); + + // Different run language (gap #30): squiggle routing depends on it, so + // it must participate in the key (covered by the Debug fold). + let mut tagged = span(base); + tagged.language = Some("fr-FR".into()); + lay(&mut r, base, &[tagged], 400.0); + assert_eq!(r.para_cache.len(), 5, "different language must miss"); +} + +#[test] +fn clear_drops_all_entries() { + let mut r = resources(); + lay(&mut r, "one", &[span("one")], 400.0); + lay(&mut r, "two", &[span("two")], 400.0); + assert_eq!(r.para_cache.len(), 2); + + r.clear_paragraph_cache(); + assert_eq!(r.para_cache.len(), 0, "clear should drop every entry"); + + // A subsequent layout repopulates from scratch (miss, not stale hit). + lay(&mut r, "one", &[span("one")], 400.0); + assert_eq!(r.para_cache.len(), 1); +} + +#[test] +fn preserve_flag_is_part_of_key() { + let mut r = resources(); + let text = "preserve flag"; + let spans = [span(text)]; + let props = ResolvedParaProps::default(); + + let _ = layout_paragraph(&mut r, text, &spans, &props, 400.0, 1.0, true); + let _ = layout_paragraph(&mut r, text, &spans, &props, 400.0, 1.0, false); + assert_eq!( + r.para_cache.len(), + 2, + "preserve_for_editing must distinguish cache entries" + ); +} diff --git a/loki-layout/src/para_clean.rs b/loki-layout/src/para_clean.rs new file mode 100644 index 00000000..c2301ce2 --- /dev/null +++ b/loki-layout/src/para_clean.rs @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Text cleaning for Parley, and the byte-index maps it produces. +//! +//! Extracted from `para.rs` (300-line ceiling, technique 3): a self-contained +//! cluster with its own reason to change — what Parley must not be shown, and +//! how editor byte offsets translate across the removal. +//! +//! The two maps returned here are one `usize` per source byte each and are a +//! known residency item: Spec 09 S9-2 shrinks them to `u32`, and to an identity +//! representation for the common case where nothing was removed. + +use super::StyleSpan; + +/// Strips characters Parley must not see (control chars and the BOM, keeping +/// `\t`/`\n`) and remaps the style spans onto the cleaned text. +/// +/// Returns `(clean_text, clean_spans, orig_to_clean, clean_to_orig)` — the two +/// byte-index maps let editor hit-testing translate between the original and +/// cleaned coordinate spaces. +pub(super) fn clean_text_and_spans( + text: &str, + spans: &[StyleSpan], +) -> (String, Vec, Vec, Vec) { + let mut clean_text = String::with_capacity(text.len()); + let mut orig_to_clean = vec![0; text.len() + 1]; + let mut clean_to_orig = Vec::with_capacity(text.len() + 1); + + let mut orig_idx = 0; + let mut clean_idx = 0; + + for c in text.chars() { + let c_len = c.len_utf8(); + // Drop `\t`: a tab is pure positioning (an inline box). Left in, it + // shapes to a `.notdef` (fonts lacking a tab glyph, e.g. Arimo) whose + // advance stacks on the box and overshoots the stop; byte maps anyway. + let keep = c == '\n' || (!c.is_control() && c != '\u{feff}'); + if keep { + for i in 0..c_len { + orig_to_clean[orig_idx + i] = clean_idx + i; + clean_to_orig.push(orig_idx + i); + } + clean_text.push(c); + orig_idx += c_len; + clean_idx += c_len; + } else { + for i in 0..c_len { + orig_to_clean[orig_idx + i] = clean_idx; + } + orig_idx += c_len; + } + } + orig_to_clean[orig_idx] = clean_idx; + clean_to_orig.push(orig_idx); + + let clean_spans = spans + .iter() + .map(|span| { + let mut clean_span = span.clone(); + let start = orig_to_clean + .get(span.range.start) + .copied() + .unwrap_or(clean_idx); + let end = orig_to_clean + .get(span.range.end) + .copied() + .unwrap_or(clean_idx); + clean_span.range = start..end; + clean_span + }) + .collect(); + + (clean_text, clean_spans, orig_to_clean, clean_to_orig) +} diff --git a/loki-layout/src/para_index_map.rs b/loki-layout/src/para_index_map.rs new file mode 100644 index 00000000..71e04508 --- /dev/null +++ b/loki-layout/src/para_index_map.rs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Compact byte-index maps between a paragraph's original and cleaned text. +//! +//! Spec 09 S9-2. `clean_text_and_spans` removes characters Parley must not see +//! (control characters, the BOM, tabs) and produces two maps so editor offsets +//! survive the removal. Held as `Vec` they were one 8-byte entry per +//! source byte each — **~16 B/char for ASCII**, about a fifth of a paragraph's +//! editing residency, and plain index arithmetic rather than shaping data +//! (`docs/spikes/S09.0-layout-residency-census.md` §2.3). +//! +//! Two observations shrink that: +//! +//! 1. **Offsets are bounded by the paragraph's own length**, so `u32` suffices +//! and halves the cost. +//! 2. **Most paragraphs remove nothing at all**, and for those the map *is* the +//! identity function — representable in one `usize` instead of an array. +//! +//! Built as `Vec` during layout (the construction logic is unchanged and +//! needs random-access mutation for the drop-cap rebase) and compacted once, at +//! the point the map is stored, by [`ByteIndexMap::from_indices`]. + +/// A monotonic map from one byte-offset space to another, stored compactly. +/// +/// Indexing is clamping rather than panicking — see [`Self::get_clamped`] — to +/// match how every caller already used the `Vec`: `get(i)` falling back to the +/// last entry. Offsets past the end are a normal consequence of Parley's cursor +/// landing on the end sentinel, not an error. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ByteIndexMap { + /// Nothing was removed, so entry `i` is `i` for all `i < len`. + /// + /// `len` counts entries, i.e. `text.len() + 1` including the end sentinel. + Identity { + /// Number of entries this map covers. + len: usize, + }, + /// An explicit mapping, one entry per source byte plus the end sentinel. + /// + /// `Box<[u32]>` rather than `Vec`: the map is built once and never + /// grown, so the capacity word is dead weight and an exact allocation is + /// guaranteed rather than merely likely. + Mapped(Box<[u32]>), +} + +impl ByteIndexMap { + /// Compacts a freshly-built index vector. + /// + /// Detects the identity case in one pass — cheap next to shaping, and paid + /// only on a paragraph-cache miss. + /// + /// Entries are narrowed to `u32`, saturating at [`u32::MAX`]. A paragraph + /// long enough to overflow that is 4 GiB of text in a single block; it would + /// exhaust memory during shaping long before reaching here, so saturation is + /// a formality rather than a behaviour anyone can observe. It is a clamp and + /// not a panic because this is library code. + pub fn from_indices(indices: &[usize]) -> Self { + if indices.iter().enumerate().all(|(i, &v)| i == v) { + return Self::Identity { len: indices.len() }; + } + Self::Mapped( + indices + .iter() + .map(|&v| u32::try_from(v).unwrap_or(u32::MAX)) + .collect(), + ) + } + + /// Number of entries, including the end sentinel. + pub fn len(&self) -> usize { + match self { + Self::Identity { len } => *len, + Self::Mapped(v) => v.len(), + } + } + + /// Whether the map covers no offsets at all. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// The entry at `i`, or `None` if `i` is past the end. + pub fn get(&self, i: usize) -> Option { + match self { + Self::Identity { len } => (i < *len).then_some(i), + Self::Mapped(v) => v.get(i).map(|&x| x as usize), + } + } + + /// The last entry, or `None` for an empty map. + pub fn last(&self) -> Option { + match self { + Self::Identity { len } => len.checked_sub(1), + Self::Mapped(v) => v.last().map(|&x| x as usize), + } + } + + /// The entry at `i`, clamped to the last entry, or `0` for an empty map. + /// + /// This is what every call site did by hand with the `Vec`; naming it once + /// keeps the clamping policy in one place rather than restated at each use. + pub fn get_clamped(&self, i: usize) -> usize { + self.get(i) + .unwrap_or_else(|| self.last().unwrap_or_default()) + } +} + +#[cfg(test)] +#[path = "para_index_map_tests.rs"] +mod tests; diff --git a/loki-layout/src/para_index_map_tests.rs b/loki-layout/src/para_index_map_tests.rs new file mode 100644 index 00000000..7de1afdb --- /dev/null +++ b/loki-layout/src/para_index_map_tests.rs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for [`super::ByteIndexMap`] (extracted for the 300-line ceiling). +//! +//! The property that matters is that the compact form is *indistinguishable* +//! from the `Vec` it replaces at every offset, including past the end — +//! S9-2 is a representation change and must not be a behaviour change. + +use super::ByteIndexMap; + +/// The reference behaviour: what the call sites did by hand with a `Vec`. +fn reference(v: &[usize], i: usize) -> usize { + v.get(i) + .copied() + .unwrap_or_else(|| v.last().copied().unwrap_or(0)) +} + +fn agrees_with_reference(indices: &[usize]) { + let map = ByteIndexMap::from_indices(indices); + assert_eq!(map.len(), indices.len()); + assert_eq!(map.last(), indices.last().copied()); + // Probe past the end too: Parley's cursor lands on the end sentinel, and + // one past it is the case the clamp exists for. + for i in 0..indices.len() + 3 { + assert_eq!( + map.get_clamped(i), + reference(indices, i), + "offset {i} diverged for {indices:?}" + ); + assert_eq!(map.get(i), indices.get(i).copied(), "get({i}) diverged"); + } +} + +#[test] +fn identity_input_compacts_to_identity() { + let indices: Vec = (0..64).collect(); + assert_eq!( + ByteIndexMap::from_indices(&indices), + ByteIndexMap::Identity { len: 64 }, + "a map with nothing removed must not allocate an array" + ); + agrees_with_reference(&indices); +} + +#[test] +fn a_single_removed_byte_forces_the_mapped_form() { + // One character dropped at offset 3: offsets past it shift down by one. + let indices = vec![0, 1, 2, 3, 3, 4, 5]; + assert!(matches!( + ByteIndexMap::from_indices(&indices), + ByteIndexMap::Mapped(_) + )); + agrees_with_reference(&indices); +} + +#[test] +fn empty_and_singleton_maps_behave() { + let empty = ByteIndexMap::from_indices(&[]); + assert!(empty.is_empty()); + assert_eq!(empty.last(), None); + assert_eq!(empty.get(0), None); + // The documented fallback for an empty map is 0, matching the old `Vec` + // call sites — an evicted-style silent wrong answer is not wanted here. + assert_eq!(empty.get_clamped(0), 0); + agrees_with_reference(&[]); + + // The zero-height synthetic paragraph built by the keep-with-next chain. + agrees_with_reference(&[0]); +} + +#[test] +fn a_non_monotonic_map_still_round_trips() { + // Not produced by the cleaner, but the type must not silently reorder or + // dedupe: it is a representation, not a model of what the cleaner can emit. + agrees_with_reference(&[5, 0, 9, 2]); +} + +#[test] +fn the_mapped_form_is_exactly_sized() { + let indices = vec![0, 0, 1, 2, 2, 3]; + match ByteIndexMap::from_indices(&indices) { + ByteIndexMap::Mapped(v) => assert_eq!( + v.len(), + indices.len(), + "Box<[u32]> must be exact — the whole point is not carrying slack" + ), + ByteIndexMap::Identity { .. } => panic!("expected the mapped form"), + } +} diff --git a/loki-layout/src/para_layout_types.rs b/loki-layout/src/para_layout_types.rs index f76a20de..b6e7ad56 100644 --- a/loki-layout/src/para_layout_types.rs +++ b/loki-layout/src/para_layout_types.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use parley::Alignment; -use super::{ResolvedLineHeight, ResolvedListMarker, ResolvedTabStop}; +use super::{ByteIndexMap, ResolvedLineHeight, ResolvedListMarker, ResolvedTabStop}; use crate::color::LayoutColor; use crate::geometry::LayoutInsets; use crate::items::{BorderEdge, PositionedItem}; @@ -219,9 +219,9 @@ pub struct ParagraphLayout { /// the editing layer shares layouts across the page editing index. pub parley_layout: Option>>, /// Original to cleaned byte index mappings. - pub orig_to_clean: Vec, + pub orig_to_clean: ByteIndexMap, /// Cleaned to original byte index mappings. - pub clean_to_orig: Vec, + pub clean_to_orig: ByteIndexMap, /// Paragraph start (left) indent in points, applied to drawn glyphs. /// /// Retained so cursor / hit-test / selection geometry can include the same @@ -243,6 +243,25 @@ pub struct ParagraphLayout { pub drop_shift: f32, } +impl ParagraphLayout { + /// Releases spare capacity in every owned vector, glyph runs included. + /// + /// Called once per cache miss before the layout is shared (S9-1). These + /// vectors are built by `push`, so they carry up to 2× slack from doubling, + /// and a cached layout is long-lived — the slack is retained for as long as + /// the entry survives. It must reach the *nested* glyph vectors, not just + /// the top-level ones: the deep clone this replaces compacted every level, + /// and a shallow shrink recovers only about a quarter of what it did. Costs + /// one realloc per vector on the shaping path; cache hits never touch it. + pub(crate) fn shrink_to_fit(&mut self) { + self.items.shrink_to_fit(); + for item in &mut self.items { + item.shrink_to_fit(); + } + self.line_boundaries.shrink_to_fit(); + } +} + impl std::fmt::Debug for ParagraphLayout { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ParagraphLayout") diff --git a/loki-layout/src/para_query.rs b/loki-layout/src/para_query.rs index e2f238bc..b83eefd7 100644 --- a/loki-layout/src/para_query.rs +++ b/loki-layout/src/para_query.rs @@ -36,11 +36,7 @@ impl super::ParagraphLayout { let local_x = x - self.line_indent(line_index); let cursor = Cursor::from_point(layout, local_x, y); let byte_offset = cursor.index(); - let mapped_offset = self - .clean_to_orig - .get(byte_offset) - .copied() - .unwrap_or_else(|| self.clean_to_orig.last().copied().unwrap_or(0)); + let mapped_offset = self.clean_to_orig.get_clamped(byte_offset); let affinity = match cursor.affinity() { parley::Affinity::Upstream => Affinity::Upstream, parley::Affinity::Downstream => Affinity::Downstream, @@ -66,11 +62,7 @@ impl super::ParagraphLayout { /// when the paragraph has no lines. pub fn line_end_offset(&self, byte_offset: usize, text: &str) -> Option { let layout = self.parley_layout.as_ref()?; - let clean_offset = self - .orig_to_clean - .get(byte_offset) - .copied() - .unwrap_or_else(|| self.orig_to_clean.last().copied().unwrap_or(0)); + let clean_offset = self.orig_to_clean.get_clamped(byte_offset); // Find the line whose text range contains clean_offset, or fall back to // the last line (handles cursor positioned at text.len()). let line = layout @@ -84,11 +76,7 @@ impl super::ParagraphLayout { let range = line.text_range(); let end = range.end; - let mapped_end = self - .clean_to_orig - .get(end) - .copied() - .unwrap_or_else(|| self.clean_to_orig.last().copied().unwrap_or(0)); + let mapped_end = self.clean_to_orig.get_clamped(end); // Trim a trailing '\n' or '\r\n' so End lands before the newline byte, not after. // In loki-text, paragraph breaks are modelled as separate blocks, so @@ -112,11 +100,7 @@ impl super::ParagraphLayout { /// position by Parley. pub fn cursor_rect(&self, byte_offset: usize) -> Option { let layout = self.parley_layout.as_deref()?; - let clean_offset = self - .orig_to_clean - .get(byte_offset) - .copied() - .unwrap_or_else(|| self.orig_to_clean.last().copied().unwrap_or(0)); + let clean_offset = self.orig_to_clean.get_clamped(byte_offset); let cursor = Cursor::from_byte_index(layout, clean_offset, parley::Affinity::Downstream); // width=1.0 requests a 1-point wide caret geometry. let bb = cursor.geometry(layout, 1.0); @@ -148,12 +132,7 @@ impl super::ParagraphLayout { let Some(layout) = self.parley_layout.as_deref() else { return Vec::new(); }; - let to_clean = |b: usize| { - self.orig_to_clean - .get(b) - .copied() - .unwrap_or_else(|| self.orig_to_clean.last().copied().unwrap_or(0)) - }; + let to_clean = |b: usize| self.orig_to_clean.get_clamped(b); let (lo, hi) = if start <= end { (start, end) } else { diff --git a/loki-layout/src/result_tests.rs b/loki-layout/src/result_tests.rs index 90238902..cef4a067 100644 --- a/loki-layout/src/result_tests.rs +++ b/loki-layout/src/result_tests.rs @@ -7,7 +7,7 @@ use super::*; use crate::color::LayoutColor; use crate::geometry::{LayoutPoint, LayoutRect}; use crate::items::{GlyphEntry, GlyphSynthesis, PositionedGlyphRun, PositionedRect}; -use crate::para::ParagraphLayout; +use crate::para::{ByteIndexMap, ParagraphLayout}; fn make_filled(x: f32) -> PositionedItem { PositionedItem::FilledRect(PositionedRect { @@ -93,8 +93,8 @@ fn link_para(origin: (f32, f32), url: Option<&str>) -> PageParagraphData { last_baseline: 10.0, line_boundaries: Vec::new(), parley_layout: None, - orig_to_clean: Vec::new(), - clean_to_orig: Vec::new(), + orig_to_clean: ByteIndexMap::Identity { len: 0 }, + clean_to_orig: ByteIndexMap::Identity { len: 0 }, indent_start: 0.0, indent_hanging: 0.0, drop_lines: 0, diff --git a/loki-layout/tests/para_layout_sharing_tests.rs b/loki-layout/tests/para_layout_sharing_tests.rs new file mode 100644 index 00000000..fd33bb43 --- /dev/null +++ b/loki-layout/tests/para_layout_sharing_tests.rs @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Spec 09 S9-1: the shaping cache and the page editing index share **one** +//! `ParagraphLayout` allocation. +//! +//! Before S9-1, a cache hit cloned the whole layout out of `ParaCache` and +//! `place_paragraph_layout` cloned it a second time for `PageParagraphData`, so +//! every placement of a paragraph carried its own deep copy of the glyph items +//! and both byte-index maps — measured at 39.3 B/char of per-placement editing +//! residency (`docs/spikes/S09.0-layout-residency-census.md` §10b, §10c). +//! +//! The sharing is invisible to every behavioural test: identical output, fewer +//! allocations. So it needs a test that asserts *identity* rather than equality, +//! or the next refactor to reintroduce a clone will pass the whole suite. +//! `Arc::ptr_eq` is that assertion. +//! +//! The copy-on-write half matters just as much. Inline images, floats, and +//! picture bullets are injected after shaping, so those paragraphs must take a +//! private copy — sharing them would leak one paragraph's image into every other +//! placement of the same text. + +use std::sync::Arc; + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::{Block, StyledParagraph}; +use loki_doc_model::content::inline::{Inline, LinkTarget}; +use loki_doc_model::document::Document; +use loki_layout::{ + DocumentLayout, FontResources, LayoutMode, LayoutOptions, PaginatedLayout, layout_document, +}; + +fn resources() -> FontResources { + let mut r = FontResources::new(); + for p in [ + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ] { + if let Ok(data) = std::fs::read(p) { + r.register_font(data); + break; + } + } + r +} + +fn para(text: &str) -> Block { + Block::StyledPara(StyledParagraph { + style_id: None, + direct_para_props: None, + direct_char_props: None, + inlines: vec![Inline::Str(text.into())], + attr: NodeAttr::default(), + }) +} + +fn lay_out(doc: &Document) -> PaginatedLayout { + let mut r = resources(); + match layout_document( + &mut r, + doc, + LayoutMode::Paginated, + 1.0, + &LayoutOptions { + preserve_for_editing: true, + spell: None, + ..Default::default() + }, + ) { + DocumentLayout::Paginated(p) => p, + other => panic!("paginated mode returned {other:?}"), + } +} + +/// Every editing-index layout, in placement order. +fn editing_layouts(pages: &PaginatedLayout) -> Vec> { + pages + .pages + .iter() + .flat_map(|p| p.editing_data.iter()) + .flat_map(|e| e.paragraphs.iter()) + .map(|p| Arc::clone(&p.layout)) + .collect() +} + +/// Every editing entry for byte-identical paragraph content must be the *same* +/// allocation, not an equal copy. This is S9-1's whole point, and it is what the +/// duplication sweep measures as the per-placement coefficient `P`. +#[test] +fn identical_paragraphs_share_one_editing_layout() { + let mut doc = Document::new(); + doc.sections[0].blocks = (0..6).map(|_| para("Repeated boilerplate line")).collect(); + + let layouts = editing_layouts(&lay_out(&doc)); + + assert_eq!(layouts.len(), 6, "one editing entry per placed paragraph"); + for (i, l) in layouts.iter().enumerate().skip(1) { + assert!( + Arc::ptr_eq(&layouts[0], l), + "placement {i} holds its own copy of an identical paragraph's layout — \ + the cache and the editing index are no longer sharing one allocation (S9-1)" + ); + } +} + +/// Different content must not be conflated. The sharing is keyed on the cache +/// key, so this is really a check that the key still distinguishes text — a +/// failure here would be a correctness bug, not a residency one. +#[test] +fn different_paragraphs_do_not_share() { + let mut doc = Document::new(); + doc.sections[0].blocks = vec![para("first distinct line"), para("second distinct line")]; + + let layouts = editing_layouts(&lay_out(&doc)); + + assert_eq!(layouts.len(), 2); + assert!( + !Arc::ptr_eq(&layouts[0], &layouts[1]), + "distinct paragraph content shared one layout — the cache key is wrong" + ); +} + +/// Copy-on-write: a paragraph the flow mutates after shaping must take a private +/// copy, or the injected item leaks into every other placement of the same text. +/// +/// This is the hazard S9-1 introduces. Sharing is correct only for the layout as +/// shaped; inline images, floats and picture bullets are pushed into `items` +/// afterwards, so those paragraphs must diverge from the cache entry. Here two +/// paragraphs carry identical text and only one carries an image — if they came +/// back sharing one allocation, the plain paragraph would render the image too. +#[test] +fn a_mutated_paragraph_does_not_share_with_its_plain_twin() { + let text = "Identical caption text"; + let with_image = Block::StyledPara(StyledParagraph { + style_id: None, + direct_para_props: None, + direct_char_props: None, + inlines: vec![ + Inline::Str(text.into()), + Inline::Image( + NodeAttr::default(), + vec![], + LinkTarget { + url: "test-image.png".into(), + title: None, + }, + ), + ], + attr: NodeAttr::default(), + }); + + let mut doc = Document::new(); + doc.sections[0].blocks = vec![para(text), with_image]; + + let layouts = editing_layouts(&lay_out(&doc)); + assert_eq!(layouts.len(), 2); + assert!( + !Arc::ptr_eq(&layouts[0], &layouts[1]), + "the image-bearing paragraph shares the plain paragraph's layout — \ + the copy-on-write guard in flow_paragraph is not firing, so the image \ + would appear on both" + ); +} diff --git a/loki-renderer/src/document_view.rs b/loki-renderer/src/document_view.rs index 158d50bf..7cd70e06 100644 --- a/loki-renderer/src/document_view.rs +++ b/loki-renderer/src/document_view.rs @@ -5,7 +5,9 @@ use std::sync::{Arc, Mutex}; -#[cfg(any(not(target_os = "android"), android_gpu))] +// Must stay unconditional: this module is ungated in `lib.rs` and BOTH arms of +// `DocumentView` need the prelude. Gating it to the GPU path broke the Android +// CPU target while desktop stayed green — gate the module, never this import. use dioxus::prelude::*; // PageTile (and the wgpu paint path under it) is enabled on: desktop, and diff --git a/loki-text/Cargo.toml b/loki-text/Cargo.toml index c8e99497..7e5fda8e 100644 --- a/loki-text/Cargo.toml +++ b/loki-text/Cargo.toml @@ -15,6 +15,14 @@ crate-type = ["cdylib", "rlib"] name = "loki-text-desktop" path = "src/main.rs" +# Spec 09 S9-3 (S09.0 §10i): is the per-keystroke page scan measurable +# work today, or only an eviction blocker? Decides whether S9-3 is a +# latency fix or architecture. `harness = false` — plain main(), no libtest. +[[bench]] +name = "page_locate_latency" +path = "benches/page_locate_latency.rs" +harness = false + [features] # Compiles in the macro `Network` capability transport (ADR-0015 §8 decision 1, # 8B.5): enables `loki-macro-host/macro-net` (the `reqwest`/`rustls` fetcher) and diff --git a/loki-text/benches/page_locate_latency.rs b/loki-text/benches/page_locate_latency.rs new file mode 100644 index 00000000..ffacc4d2 --- /dev/null +++ b/loki-text/benches/page_locate_latency.rs @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! **Spec 09 S9-3 §10i** — is the per-keystroke page scan free today? +//! +//! `page_locate::recompute_page_index` walks pages from index 0 until it finds +//! the one holding the caret, dereferencing each page's `editing_data` and +//! scanning its paragraph list on the way. It runs on **every keystroke** +//! (`editor_keydown_text.rs`, `editor_keydown.rs`, `editor_keydown_ctrl.rs`). +//! +//! How many pages' `editing_data` it dereferences per call is **open** (Spec 09 +//! R9-19): the code's shape suggests the prefix `0..=M`, this bench's timing +//! points at all `N`, and neither is an observation of the thing itself. That +//! matters because under windowing every dereferenced page must be resident, so +//! the scan would defeat the windowing it is meant to enable (R9-04). +//! +//! But the access set is a residency argument, and this bench asks a different +//! question that decides how S9-3 should be *scoped*: **is the walk measurable +//! work today, with every page already resident?** If typing on page 300 costs +//! materially more than typing on page 1, this is a present-day typing-latency +//! defect on long documents — a user-visible fix adjacent to Spec 08's I-05 — +//! rather than groundwork for eviction. If it is noise, S9-3 stays architecture. +//! +//! Wall-clock, not allocations: nothing is allocated here, and the cost is +//! pointer-chasing over page and paragraph vectors. +//! +//! # What this bench does and does not show +//! +//! It shows **cost**: ~3.3 us at 445 pages, ~13.6 us at 889, flat in the caret's +//! page, with a guaranteed miss costing about the same as a hit. Against a ~16 ms +//! frame that settles the question it was built for — the scan is not a +//! present-day latency defect. +//! +//! It does **not** show control flow. The flat curve was once read as proof that +//! the `visible` early exit never fires (Spec 09 R9-18); a characterisation test +//! showed it fires on split-page geometry, so that claim survives only with a +//! geometry qualifier. A clock measures time, and a claim about which branch runs +//! needs its own observation (L9-018). +//! +//! # Known limitation: this bench varies the wrong axis +//! +//! It sweeps **caret position** while holding **geometry** fixed — every probe is +//! byte 0 of a single-page paragraph. Geometry is what selects the branch, so the +//! sweep varies the axis that does not matter and holds the one that does. The +//! four cases worth timing are byte 0 of a single-page paragraph (here), +//! mid-paragraph single-page, the first byte of a paragraph carried over from the +//! previous page, and the last byte of one continuing onto the next. +//! +//! Note the keystroke path is **none of the two geometries so far measured**: +//! typing is mid-paragraph at arbitrary offsets, and Q4 found pages starting +//! mid-paragraph to be the common case in prose. If the straddling cases are +//! cheap, R9-19's `N` prior is pessimistic for exactly the path that matters. +//! +//! What the timing *does* support, independently of that, is the residency +//! concern it was bundled with: cost is not proportional to `M` (page 0 and page +//! 444 cost the same) yet is superlinear in `N` (445 → 889 pages, 3.3 → 13.6 µs). +//! Something `N`-sized is touched per call regardless of caret position. Whether +//! that reaches `editing_data` — harmless if it is metadata, fatal if it is not — +//! is R9-19, and settling it needs the counting accessor, which is therefore a +//! prerequisite for S9-3 rather than a part of it. +//! +//! Run: `cargo bench -p loki-text --bench page_locate_latency` + +use std::hint::black_box; +use std::time::Instant; + +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::layout::page::PageLayout; +use loki_doc_model::layout::section::Section; +use loki_layout::{DocumentLayout, FontResources, LayoutMode, LayoutOptions, layout_document}; +use loki_text::editing::cursor::DocumentPosition; +use loki_text::editing::page_locate::recompute_page_index; + +/// Paragraphs in the test document. Sized to produce several hundred pages so +/// the near/far comparison spans a realistic long document rather than a toy. +const PARAS: &[usize] = &[4_000, 8_000]; + +/// Timed repetitions per position. The work is microseconds at most, so a single +/// call is below timer resolution. +const REPS: usize = 2_000; + +fn build_doc(paras: usize) -> Document { + let words = [ + "document", + "layout", + "paragraph", + "cursor", + "render", + "office", + "shaping", + "baseline", + "indent", + "column", + "measure", + "typeset", + ]; + let blocks: Vec = (0..paras) + .map(|i| { + let mut s = format!("{}. ", i + 1); + for j in 0..40 { + s.push_str(words[(i + j) % words.len()]); + s.push(' '); + } + Block::Para(vec![Inline::Str(s)]) + }) + .collect(); + let section = Section::with_layout_and_blocks(PageLayout::default(), blocks); + let mut doc = Document::new(); + doc.sections = vec![section]; + doc +} + +/// The block index of the first paragraph laid out on `page`, so the timed +/// position is one the scan can actually find. +fn first_block_on_page(layout: &loki_layout::PaginatedLayout, page: usize) -> Option { + layout + .pages + .get(page)? + .editing_data + .as_ref()? + .paragraphs + .first() + .map(|p| p.block_index) +} + +/// Median of `REPS` timed calls, in nanoseconds. Median rather than mean: a +/// scheduler hiccup in one repetition should not decide the comparison. +fn time_at(layout: &loki_layout::PaginatedLayout, block: usize) -> (u128, usize) { + // `page_index` is deliberately **stale** (0), not the answer. Seeding it with + // the correct page makes `new_page == pos.page_index` trivially true, so the + // function returns the same value whether it found the paragraph or scanned + // the whole document and found nothing — the timing would then be a number + // with no established meaning. A stale index is also the realistic case: the + // caret moved and the page has to be *re*-computed, which is why the + // function is called at all. + let pos = DocumentPosition { + page_index: 0, + paragraph_index: block, + byte_offset: 0, + path: Vec::new(), + }; + // Sentinel: the scan must actually locate the paragraph on `page`. If it + // does not, every timing below measures a failed lookup rather than the + // work being characterised (R9-13). + let found = recompute_page_index(layout, &pos).page_index; + // Warm the caches this position touches, outside the timed region (L9-011). + for _ in 0..64 { + black_box(recompute_page_index(layout, &pos)); + } + let mut samples = Vec::with_capacity(REPS); + for _ in 0..REPS { + let t = Instant::now(); + black_box(recompute_page_index(layout, &pos)); + samples.push(t.elapsed().as_nanos()); + } + samples.sort_unstable(); + (samples[samples.len() / 2], found) +} + +fn run(paras: usize) -> (usize, u128, u128, u128) { + let doc = build_doc(paras); + let mut resources = FontResources::new(); + let layout = layout_document( + &mut resources, + &doc, + LayoutMode::Paginated, + 1.0, + &LayoutOptions { + preserve_for_editing: true, + spell: None, + ..Default::default() + }, + ); + let DocumentLayout::Paginated(layout) = layout else { + eprintln!("paginated mode did not return a paginated layout"); + return (0, 0, 0, 0); + }; + + // Timer-resolution floor. Every median below is meaningless if the clock + // cannot resolve smaller than them, and near-identical medians across very + // different workloads is exactly what a coarse clock looks like (R9-13). + { + let mut samples = Vec::with_capacity(REPS); + for _ in 0..REPS { + let t = Instant::now(); + black_box(0u64); + samples.push(t.elapsed().as_nanos()); + } + samples.sort_unstable(); + eprintln!( + " timer floor (empty region): median {:>9} ns min {:>9} ns", + samples[samples.len() / 2], + samples[0] + ); + } + + let pages = layout.pages.len(); + eprintln!("\n ── {pages} pages, {paras} paragraphs ──────────────────────────"); + + // Sample across the document rather than only the ends: two points cannot + // distinguish "linear in M" from "a constant step somewhere". + let probes: Vec = [0usize, 1, pages / 8, pages / 4, pages / 2, pages - 1] + .into_iter() + .filter(|p| *p < pages) + .collect(); + + let mut first: Option = None; + let mut last = 0u128; + let full_scan; + for page in probes { + let Some(block) = first_block_on_page(&layout, page) else { + eprintln!(" page {page:>4}: no editing data — skipped"); + continue; + }; + let (ns, found) = time_at(&layout, block); + assert_eq!( + found, page, + "recompute_page_index resolved block {block} to page {found}, not {page} — \ + the probe is measuring a lookup that does not find its target" + ); + first.get_or_insert(ns); + last = ns; + eprintln!( + " page {page:>4} median {ns:>9} ns ({:.3} ms) resolved->{found}", + ns as f64 / 1e6 + ); + } + + // Decisive control: a block index that exists nowhere forces the loop to + // run to completion over every page with no early break and no cursor_rect. + // That is the scan and nothing else. If it costs about what a page-0 hit + // costs, the walk is genuinely cheap and the flat curve above is real; if it + // costs far more, the hit probes are not scanning as far as the code shape + // suggests and the flatness is an artefact. + { + let pos = DocumentPosition { + page_index: 0, + paragraph_index: paras + 10_000, + byte_offset: 0, + path: Vec::new(), + }; + for _ in 0..64 { + black_box(recompute_page_index(&layout, &pos)); + } + let mut samples = Vec::with_capacity(REPS); + for _ in 0..REPS { + let t = Instant::now(); + black_box(recompute_page_index(&layout, &pos)); + samples.push(t.elapsed().as_nanos()); + } + samples.sort_unstable(); + full_scan = samples[samples.len() / 2]; + eprintln!( + " full scan (block not present, all {pages} pages, no cursor_rect): \ + median {full_scan:>9} ns" + ); + } + + (pages, first.unwrap_or(0), last, full_scan) +} + +fn main() { + eprintln!("Spec 09 S9-3 — recompute_page_index cost by caret page"); + eprintln!( + " Runs on every keystroke. §10i modelled its access set as pages 0..=M, so\n cost should grow with the caret's page. Two document sizes test that: if the\n cost is flat in M but doubles with total page count, the loop is running to\n completion every time and the access set is the WHOLE document, not a prefix." + ); + + let mut prior: Option<(usize, u128)> = None; + for ¶s in PARAS { + let (pages, first, last, full) = run(paras); + if pages == 0 { + continue; + } + eprintln!( + " flat-in-M ratio (last page / first page): {:.2}×", + last as f64 / first.max(1) as f64 + ); + if let Some((prev_pages, prev_full)) = prior { + eprintln!( + " scales-with-N ratio vs previous size: pages {:.2}×, full scan {:.2}×", + pages as f64 / prev_pages as f64, + full as f64 / prev_full.max(1) as f64, + ); + } + prior = Some((pages, full)); + } + eprintln!( + "\n A keystroke has ~16 ms before it costs a frame. Read the absolute numbers\n against that, not against each other." + ); +} diff --git a/loki-text/src/editing/caret_reveal.rs b/loki-text/src/editing/caret_reveal.rs new file mode 100644 index 00000000..1c9660a9 --- /dev/null +++ b/loki-text/src/editing/caret_reveal.rs @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Caret geometry in **scroll-container content coordinates** (Spec 08 T1.3). +//! +//! `scroll_to_reveal` measures a target rect against +//! [`appthere_ui::ScrollMetrics::visible_rect`], which is in content space — +//! the same space `scroll_top` is in. This module produces the caret's rect in +//! that space, for both renderers. +//! +//! # Where this sits in the transform chain +//! +//! S0.3 documented the full chain (`docs/spikes/S0.3-coordinate-space-audit.md` +//! §1). This module covers steps 2 → 6 and stops short of step 7: it does *not* +//! subtract the scroll offset or add the canvas origin, because a content-space +//! rect is exactly what the reveal wants. `editing::hit_test` walks the same +//! chain in the opposite direction and must stay in agreement with it — the +//! shared constants below are the reason both land on the same pixel. +//! +//! Note the asymmetry S0.3 flags at step 5: `content_items` are content-area +//! local, so page margins must be added, while a paragraph's `origin` is +//! already relative to that content area. + +use loki_layout::{ContinuousLayout, PaginatedLayout}; + +use super::cursor::DocumentPosition; + +/// CSS pixels per layout point, before zoom (72 dpi → 96 dpi). +const PT_TO_PX: f32 = 96.0 / 72.0; + +/// Nominal caret width in layout points. `CursorRect` carries only `x`, `y` and +/// `height` — a caret is a zero-width line — but the reveal takes a rect, and a +/// zero width would let the horizontal axis treat the caret as already visible +/// when it sits exactly on the right edge. +const CARET_WIDTH_PT: f32 = 1.0; + +/// The caret identity a reveal is keyed on (ADR L08-019). +/// +/// A reveal must fire when the caret *moves*, and at no other time. Keying on +/// anything derived from scroll position instead — "is the caret still inside +/// the margin band" — produces I-20: the user turns the wheel, the caret's +/// viewport-relative position changes without the caret moving, the check +/// fails, and the reveal drags the view back. The wheel ends up capped at the +/// margin band, asymmetrically, because the margin is asymmetric. +/// +/// `anchor` is included so extending a selection with Shift+arrow reveals the +/// moving end even when the focus byte offset happens to land where it was. +#[derive(Clone, PartialEq, Debug)] +pub struct CaretRevision { + focus: DocumentPosition, + anchor: Option, +} + +impl CaretRevision { + /// Captures the current caret identity. + #[must_use] + pub fn new(focus: DocumentPosition, anchor: Option) -> Self { + Self { focus, anchor } + } +} + +/// Whether a reveal should fire, given the revision at the last reveal and the +/// revision now. +/// +/// The whole trigger rule, in one testable place: fire if and only if the caret +/// identity changed. A scroll cannot change it, so a scroll cannot trigger a +/// reveal — which is the property T1.4 asked for and idempotence never actually +/// provided. +/// +/// This also means a caret that stays put while the layout moves underneath it +/// — an async font load, an image resolving, a reflow after a style change — +/// does not yank the view (Spec 08 R25). That movement is real but it is not +/// the user asking to go anywhere. +#[must_use] +pub fn should_reveal(last: Option<&CaretRevision>, now: &CaretRevision) -> bool { + last != Some(now) +} + +/// Page stacking geometry, shared by the caret rect and the hit-test. +#[derive(Clone, Copy, Debug)] +pub struct PageStack { + /// Unscaled page height in CSS px. + pub page_height_px: f32, + /// Gap painted between pages, in CSS px. Not scaled by zoom — it is a + /// fixed CSS margin on the tile, which is why the slot below is + /// `page × zoom + gap` and not `(page + gap) × zoom`. + pub page_gap_px: f32, + /// Zoom fraction (1.0 = 100%). + pub zoom: f32, + /// Top padding of the scroll container in CSS px: content y = 0 is the + /// container's top edge, and the first page starts one padding below it. + pub content_top_px: f32, +} + +impl PageStack { + /// Vertical distance between the tops of consecutive pages. + #[must_use] + pub fn slot_px(&self) -> f32 { + self.page_height_px * self.zoom + self.page_gap_px + } + + /// Content-space y for a point `page_local_y_pt` down page `page_index`. + #[must_use] + pub fn content_y(&self, page_index: usize, page_local_y_pt: f32) -> f32 { + self.content_top_px + + page_index as f32 * self.slot_px() + + page_local_y_pt * self.px_per_pt() + } + + /// CSS pixels per layout point at the current zoom. + #[must_use] + pub fn px_per_pt(&self) -> f32 { + PT_TO_PX * self.zoom + } +} + +/// The caret's rect in content coordinates for the **paginated** renderer, as +/// `(x, y, width, height)` in CSS px. +/// +/// `None` when the position is not on the current layout — a stale caret after +/// an edit, or a layout that has not been recomputed yet. Callers treat that as +/// "nothing to reveal" rather than scrolling to a guess. +#[must_use] +pub fn caret_rect_paginated( + layout: &PaginatedLayout, + pos: &DocumentPosition, + stack: PageStack, +) -> Option<(f32, f32, f32, f32)> { + let page = layout.pages.get(pos.page_index)?; + let editing = page.editing_data.as_ref()?; + let para = editing + .paragraphs + .iter() + .find(|p| p.block_index == pos.paragraph_index && p.path == pos.path)?; + let rect = para.layout.cursor_rect(pos.byte_offset)?; + + // Paragraph-local → page-local: the paragraph's origin within the content + // area, plus the content area's own offset (the page margins). + let page_x_pt = rect.x + para.origin.0 + page.margins.left; + let page_y_pt = rect.y + para.origin.1 + page.margins.top; + + let scale = stack.px_per_pt(); + Some(( + page_x_pt * scale, + stack.content_y(pos.page_index, page_y_pt), + CARET_WIDTH_PT * scale, + rect.height * scale, + )) +} + +/// The caret's rect in content coordinates for the **reflow** renderer. +/// +/// Reflow bands stack with no gap and the whole flow is one coordinate space, +/// so this is a single scale plus the container padding. `scale` is the reflow +/// type scale, which stands in for zoom in that mode. +#[must_use] +pub fn caret_rect_reflow( + layout: &ContinuousLayout, + block_index: usize, + byte_offset: usize, + scale: f32, + content_top_px: f32, +) -> Option<(f32, f32, f32, f32)> { + let para = layout.paragraph(block_index)?; + let rect = para.layout.cursor_rect(byte_offset)?; + let px = PT_TO_PX * scale; + Some(( + (rect.x + para.origin.0) * px, + content_top_px + (rect.y + para.origin.1) * px, + CARET_WIDTH_PT * px, + rect.height * px, + )) +} + +/// Body line height in CSS px at the caret, used to size the reveal margin. +/// +/// Taken from the caret's own line rather than a constant: T1.3 requires the +/// margin to follow the live body style, so three trailing lines is three +/// *actual* lines at the current size and zoom, not 60 px regardless. +/// Falls back to a plausible single-spaced line when the caret has no rect yet. +#[must_use] +pub fn caret_line_height_px(caret_rect: Option<(f32, f32, f32, f32)>, fallback_px: f32) -> f32 { + match caret_rect { + Some((_, _, _, h)) if h > 0.5 => h, + _ => fallback_px, + } +} + +#[cfg(test)] +#[path = "caret_reveal_tests.rs"] +mod tests; diff --git a/loki-text/src/editing/caret_reveal_tests.rs b/loki-text/src/editing/caret_reveal_tests.rs new file mode 100644 index 00000000..c95051fd --- /dev/null +++ b/loki-text/src/editing/caret_reveal_tests.rs @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for the caret content-space geometry. + +use super::{PageStack, caret_line_height_px}; + +/// US Letter at 100%: 792 pt → 1056 CSS px, 24 px gap, 24 px container padding. +fn stack(zoom: f32) -> PageStack { + PageStack { + page_height_px: 1056.0, + page_gap_px: 24.0, + zoom, + content_top_px: 24.0, + } +} + +#[test] +fn slot_scales_the_page_but_not_the_gap() { + // The gap is a fixed CSS margin on the tile, so it must not be zoomed. + // Getting this wrong drifts the caret by one gap per page — invisible on + // page 1 and badly wrong on page 20. + assert_eq!(stack(1.0).slot_px(), 1080.0); + assert_eq!(stack(2.0).slot_px(), 2136.0); + assert_eq!(stack(0.5).slot_px(), 552.0); +} + +#[test] +fn first_page_starts_below_the_container_padding() { + // Content y = 0 is the scroll container's top edge, not the first page's. + assert_eq!(stack(1.0).content_y(0, 0.0), 24.0); +} + +#[test] +fn later_pages_accumulate_whole_slots() { + let s = stack(1.0); + assert_eq!(s.content_y(1, 0.0), 24.0 + 1080.0); + assert_eq!(s.content_y(4, 0.0), 24.0 + 4.0 * 1080.0); +} + +#[test] +fn page_local_points_convert_at_ninety_six_over_seventy_two() { + // 72 pt is one inch is 96 px at zoom 1. + let s = stack(1.0); + assert_eq!(s.content_y(0, 72.0), 24.0 + 96.0); + assert_eq!(s.px_per_pt(), 96.0 / 72.0); +} + +#[test] +fn zoom_scales_both_the_slot_and_the_in_page_offset() { + let s = stack(2.0); + // Page 1 top, plus a 72 pt margin inside it, both at 2x. + assert_eq!(s.content_y(1, 72.0), 24.0 + 2136.0 + 192.0); +} + +#[test] +fn line_height_prefers_the_caret_rect() { + assert_eq!( + caret_line_height_px(Some((0.0, 0.0, 1.0, 21.0)), 18.0), + 21.0 + ); +} + +#[test] +fn line_height_falls_back_when_the_caret_has_no_rect() { + assert_eq!(caret_line_height_px(None, 18.0), 18.0); + // A degenerate zero-height rect is not a usable line height either. + assert_eq!(caret_line_height_px(Some((0.0, 0.0, 1.0, 0.0)), 18.0), 18.0); +} + +#[test] +fn a_caret_on_a_later_page_is_far_down_the_content() { + // Guards the whole chain: page 9, one inch into the page, at 100%. + // 24 padding + 9 slots + 96 px = 9840. + let s = stack(1.0); + assert_eq!(s.content_y(9, 72.0), 24.0 + 9.0 * 1080.0 + 96.0); +} + +// ── Reveal trigger (L08-019 / I-20) ────────────────────────────────────────── + +use super::{CaretRevision, should_reveal}; +use crate::editing::cursor::DocumentPosition; + +fn pos(page: usize, para: usize, byte: usize) -> DocumentPosition { + DocumentPosition { + page_index: page, + paragraph_index: para, + byte_offset: byte, + path: Vec::new(), + } +} + +#[test] +fn first_reveal_always_fires() { + let now = CaretRevision::new(pos(0, 0, 0), None); + assert!(should_reveal(None, &now)); +} + +#[test] +fn an_unchanged_caret_does_not_re_reveal() { + // This is I-20 in miniature. The effect can re-run for any number of + // reasons — a scroll event, a re-render, a props change — and none of them + // moved the caret, so none of them may scroll the view. + let rev = CaretRevision::new(pos(3, 12, 40), None); + assert!(!should_reveal(Some(&rev), &rev.clone())); +} + +#[test] +fn typing_a_character_fires() { + let before = CaretRevision::new(pos(3, 12, 40), None); + let after = CaretRevision::new(pos(3, 12, 41), None); + assert!(should_reveal(Some(&before), &after)); +} + +#[test] +fn moving_to_another_page_fires() { + let before = CaretRevision::new(pos(3, 12, 40), None); + let after = CaretRevision::new(pos(4, 13, 0), None); + assert!(should_reveal(Some(&before), &after)); +} + +#[test] +fn extending_a_selection_fires_even_when_the_focus_is_unchanged() { + // Collapsing or extending at the same byte offset is still a caret change + // the user made; without the anchor in the revision it would be invisible. + let collapsed = CaretRevision::new(pos(1, 2, 10), None); + let extended = CaretRevision::new(pos(1, 2, 10), Some(pos(1, 2, 4))); + assert!(should_reveal(Some(&collapsed), &extended)); +} + +#[test] +fn entering_a_table_cell_fires() { + // Same page, paragraph and offset, different container path. + let outside = CaretRevision::new(pos(0, 5, 3), None); + let mut inside_pos = pos(0, 5, 3); + inside_pos.path = vec![loki_doc_model::PathStep::Cell { cell: 0, block: 0 }]; + let inside = CaretRevision::new(inside_pos, None); + assert!(should_reveal(Some(&outside), &inside)); +} diff --git a/loki-text/src/editing/mod.rs b/loki-text/src/editing/mod.rs index 6a279f57..2903227a 100644 --- a/loki-text/src/editing/mod.rs +++ b/loki-text/src/editing/mod.rs @@ -9,6 +9,7 @@ //! //! [`Cursor`]: loro::Cursor +pub mod caret_reveal; pub mod cursor; pub mod hit_test; pub mod navigation; diff --git a/loki-text/src/editing/page_locate.rs b/loki-text/src/editing/page_locate.rs index 2e515be1..161bb6c6 100644 --- a/loki-text/src/editing/page_locate.rs +++ b/loki-text/src/editing/page_locate.rs @@ -30,6 +30,13 @@ use super::cursor::DocumentPosition; #[path = "page_locate_tests.rs"] mod tests; +// Behaviour on real flow geometry, kept separate from the rule tests above: +// these pin what the function *does* so S9-3's replacement can be checked +// against observations rather than against the code's apparent intent (R9-18). +#[cfg(test)] +#[path = "page_locate_characterisation_tests.rs"] +mod characterisation_tests; + /// Geometry tolerance for the content-band fit checks (points). const BAND_EPSILON: f32 = 0.5; diff --git a/loki-text/src/editing/page_locate_characterisation_tests.rs b/loki-text/src/editing/page_locate_characterisation_tests.rs new file mode 100644 index 00000000..5cad053c --- /dev/null +++ b/loki-text/src/editing/page_locate_characterisation_tests.rs @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Characterisation of [`super::recompute_page_index`] against a **real** laid-out +//! document (Spec 09 R9-18). +//! +//! These are not specification tests. They assert what the function *does* on +//! geometry the flow engine actually produces, so that a future replacement — +//! S9-3 wants to make this a block→page index lookup — can be checked against +//! observed behaviour rather than against what the code appears to intend. A +//! rewrite built by reading the source reproduces the intent; if the intent and +//! the behaviour differ, that rewrite silently changes where the caret lands. +//! +//! The sibling `page_locate_tests.rs` builds `PaginatedLayout` values by hand, +//! which is right for exercising the decision rules in isolation and wrong for +//! this purpose: hand-built geometry is the geometry the author expected. These +//! lay out a document and use whatever comes out. +//! +//! # What prompted this +//! +//! A timing bench (`benches/page_locate_latency.rs`) found the call's cost flat +//! in the caret's page — the same at page 0 and page 444 — and a guaranteed full +//! scan costing the same as any hit. The reading offered at the time was that the +//! `visible` early exit never fires and the answer always comes from +//! `first_holder`, which was written into Spec 09 as R9-18. +//! +//! **These tests refute that.** `visible` does fire on real flow geometry: the +//! last byte of a split paragraph resolves to a later page, which only the band +//! check can produce. R9-18 is retracted, and the flat timing is left unexplained +//! rather than re-explained — swapping one inference for another is how the +//! first one got written down. + +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::layout::page::PageLayout; +use loki_doc_model::layout::section::Section; +use loki_layout::{ + DocumentLayout, FontResources, LayoutMode, LayoutOptions, PaginatedLayout, layout_document, +}; + +use super::recompute_page_index; +use crate::editing::cursor::DocumentPosition; + +/// Lays out `blocks` the way the app does, returning the real paginated result. +fn lay_out(blocks: Vec) -> PaginatedLayout { + let section = Section::with_layout_and_blocks(PageLayout::default(), blocks); + let mut doc = Document::new(); + doc.sections = vec![section]; + let mut resources = FontResources::new(); + match layout_document( + &mut resources, + &doc, + LayoutMode::Paginated, + 1.0, + &LayoutOptions { + preserve_for_editing: true, + spell: None, + ..Default::default() + }, + ) { + DocumentLayout::Paginated(p) => p, + other => panic!("paginated mode returned {other:?}"), + } +} + +fn para(text: String) -> Block { + Block::Para(vec![Inline::Str(text)]) +} + +/// Words enough to fill roughly `lines` lines of a default-width page. +fn filler(seed: usize, words: usize) -> String { + const POOL: &[&str] = &[ + "document", + "layout", + "paragraph", + "cursor", + "render", + "measure", + "baseline", + "column", + ]; + let mut s = String::new(); + for i in 0..words { + if i > 0 { + s.push(' '); + } + s.push_str(POOL[(seed + i) % POOL.len()]); + } + s +} + +/// Every page index at which `block` appears in the editing index. +fn pages_holding(layout: &PaginatedLayout, block: usize) -> Vec { + layout + .pages + .iter() + .enumerate() + .filter(|(_, page)| { + page.editing_data.as_ref().is_some_and(|ed| { + ed.paragraphs + .iter() + .any(|p| p.block_index == block && p.path.is_empty()) + }) + }) + .map(|(i, _)| i) + .collect() +} + +/// **The R9-18 discriminator.** +/// +/// For a paragraph on exactly one page, `visible` and `first_holder` give the +/// same answer, so no return value can tell them apart — which is why the bench +/// could only infer. A paragraph split across a page break separates them: +/// `first_holder` is the earlier page for *every* byte offset, while `visible` is +/// whichever page actually renders the caret's line. +/// +/// So a late byte offset resolving to the later page means `visible` fired — +/// and it does, which is what retracted R9-18. +#[test] +fn a_split_paragraph_resolves_late_bytes_to_the_later_page() { + // One paragraph long enough that the flow engine must break it across pages, + // preceded by filler so the break lands mid-paragraph rather than at its top. + let long_text = filler(0, 4_000); + let blocks = vec![para(filler(3, 200)), para(long_text.clone())]; + let layout = lay_out(blocks); + + let holders = pages_holding(&layout, 1); + assert!( + holders.len() >= 2, + "test premise: block 1 must span a page break, but it is on pages {holders:?} \ + of {} — increase the filler length if pagination changed", + layout.pages.len() + ); + let (first_page, later_page) = (holders[0], holders[1]); + + // Byte 0 is on the first fragment. + let at_start = recompute_page_index(&layout, &DocumentPosition::top_level(0, 1, 0)); + assert_eq!( + at_start.page_index, first_page, + "the paragraph's first byte should resolve to its first page" + ); + + // The last byte is rendered on the last page holding the paragraph. + let last_offset = long_text.len(); + let at_end = recompute_page_index(&layout, &DocumentPosition::top_level(0, 1, last_offset)); + + // The characterisation, stated as an observation rather than a rule. + // + // Passing means the answer is NOT `first_holder`'s, so the `visible` band + // check fired — which is what retracted R9-18. A replacement must reproduce + // this: resolving a split paragraph's late bytes to its first page would put + // the caret on the wrong page, and no existing test outside this file would + // notice, because the hand-built ones supply their own geometry. + assert_ne!( + at_end.page_index, first_page, + "the last byte of a split paragraph resolved to its FIRST page, which is \ + `first_holder`'s answer — the `visible` band check no longer fires on \ + real flow geometry. Either pagination changed under this test or a \ + replacement has reintroduced R9-18's behaviour for real." + ); + assert!( + at_end.page_index >= later_page, + "the last byte resolved to page {} but the paragraph continues to page \ + {later_page} or beyond ({holders:?})", + at_end.page_index + ); +} + +/// A paragraph living on exactly one page resolves to that page, from a stale +/// index, at both ends of its text. +/// +/// This is the case every keystroke hits, and the one the timing bench probed. +/// Both candidate branches agree here — which is precisely why it could not +/// discriminate — so it is recorded as behaviour a replacement must preserve +/// rather than as evidence about the branch. +#[test] +fn a_single_page_paragraph_resolves_from_a_stale_index() { + let blocks: Vec = (0..400).map(|i| para(filler(i, 60))).collect(); + let layout = lay_out(blocks); + assert!( + layout.pages.len() > 3, + "test premise: several pages, got {}", + layout.pages.len() + ); + + // Pick a block that sits on exactly one page, well into the document. + let (block, page) = (0..400) + .filter_map(|b| { + let h = pages_holding(&layout, b); + (h.len() == 1 && h[0] >= 2).then(|| (b, h[0])) + }) + .next() + .expect("some paragraph sits on exactly one page past page 1"); + + for offset in [0usize, 10] { + let out = recompute_page_index(&layout, &DocumentPosition::top_level(0, block, offset)); + assert_eq!( + out.page_index, page, + "block {block} at byte {offset} should resolve to its only page {page}" + ); + } +} + +/// A block index that exists nowhere leaves the position untouched. +/// +/// The `None, None, None` arm. Worth pinning because a block→page index will +/// return "not found" through a different route, and the current contract is to +/// return the input unchanged rather than to clamp or to zero. +#[test] +fn an_absent_block_leaves_the_position_unchanged() { + let blocks: Vec = (0..40).map(|i| para(filler(i, 60))).collect(); + let layout = lay_out(blocks); + + let pos = DocumentPosition::top_level(2, 9_999, 0); + let out = recompute_page_index(&layout, &pos); + assert_eq!( + out.page_index, 2, + "an unknown block must leave page_index alone, not reset it" + ); + assert_eq!(out.paragraph_index, pos.paragraph_index); + assert_eq!(out.byte_offset, pos.byte_offset); +} diff --git a/loki-text/src/lib.rs b/loki-text/src/lib.rs index 54fdd9b6..6d223bf6 100644 --- a/loki-text/src/lib.rs +++ b/loki-text/src/lib.rs @@ -28,88 +28,3 @@ pub mod window_state; // copy (Spec 01 audit A-14). `null_context`: init_android is a no-op here (the // JNI context comes from ndk_context) — see the macro docs. loki_app_shell::android_main!(tag = "LOKI", root = app::App, file_access = null_context); -#[cfg(target_os = "android")] -// COMPAT(android-16): On Android 16 (API 36) ANativeActivity_onCreate fires -// twice in rapid succession, spawning two concurrent android_main threads. -// A static OnceLock would also block legitimate activity-recreation relaunches -// within the same process (process reuse), so use a Mutex "is-running" -// flag instead: set on entry, cleared on exit, so concurrent duplicates are -// rejected while sequential re-entries (activity destroyed → recreated) succeed. -static ANDROID_MAIN_RUNNING: std::sync::Mutex = std::sync::Mutex::new(false); - -#[cfg(target_os = "android")] -#[unsafe(no_mangle)] -fn android_main(android_app: android_activity::AndroidApp) { - { - let mut running = ANDROID_MAIN_RUNNING - .lock() - .unwrap_or_else(|p| p.into_inner()); - if *running { - // Concurrent duplicate invocation on Android 16 — discard it. - return; - } - *running = true; - } - android_logger::init_once( - android_logger::Config::default() - .with_tag("LOKI") - .with_max_level(log::LevelFilter::Debug), - ); - // Route panic messages to logcat. The default panic hook writes to - // stderr, which Android discards — without this, any Rust panic (e.g. - // during GPU renderer init) is indistinguishable from a native crash. - std::panic::set_hook(Box::new(|info| { - log::error!("PANIC: {info}"); - })); - log::info!("android_main: start"); - // init_android is a no-op kept for API compatibility; the Application - // context used by all JNI calls comes from ndk_context, which - // android-activity initialises before android_main is called. - unsafe { loki_file_access::init_android(std::ptr::null_mut()) }; - let (top, bottom) = loki_file_access::query_insets_dp(); - log::info!("android_main: safe area insets top={top} bottom={bottom}"); - appthere_ui::set_safe_area_insets(appthere_ui::SafeAreaInsets { - top, - bottom, - ..Default::default() - }); - // Store the internal data path before android_app is moved, so that - // recent_documents can persist to a writable location on Android. - if let Some(data_path) = android_app.internal_data_path() { - crate::recent_documents::set_android_data_dir(data_path); - } - blitz_shell::set_android_app(android_app); - // Bridge Android soft-keyboard visibility back to the editor. A - // NativeActivity is never told when the *user* dismisses the keyboard - // (back button, swipe-down gesture, hide key), so the bottom safe area - // would stay reserved for a keyboard that is gone. loki-file-access installs - // a decor-view inset listener that reports every IME visibility change; - // blitz-shell re-queries the safe area in response (converging to 0 on a - // collapse). Register the bridge before installing the listener so the first - // callback is not dropped. - loki_file_access::set_ime_visibility_listener(Box::new(|visible| { - blitz_shell::notify_ime_visibility_changed(visible); - })); - loki_file_access::install_ime_listener(blitz_shell::current_android_app().activity_as_ptr()); - log::info!("android_main: i18n init"); - loki_i18n::init(); - log::info!("android_main: launching dioxus"); - // Register the bundled UI + metric-compatible fonts directly into the - // renderer's font collection at startup, so they resolve synchronously on - // Android instead of relying on the asynchronous `@font-face` `data:` URI - // fetch (which does not reliably run before first paint on Android, leaving - // UI chrome digits in a wide system fallback). See `loki_fonts::ui_font_blobs`. - dioxus::native::launch_cfg( - app::App, - vec![], - vec![Box::new( - dioxus::native::Config::new().with_fonts(loki_fonts::ui_font_blobs()), - )], - ); - log::info!("android_main: dioxus exited"); - // Clear the running flag so a subsequent activity-recreation relaunch - // (in the same process) is allowed to proceed. - *ANDROID_MAIN_RUNNING - .lock() - .unwrap_or_else(|p| p.into_inner()) = false; -} diff --git a/loki-text/src/routes/editor/editor_canvas.rs b/loki-text/src/routes/editor/editor_canvas.rs index 8d370f90..78b3202b 100644 --- a/loki-text/src/routes/editor/editor_canvas.rs +++ b/loki-text/src/routes/editor/editor_canvas.rs @@ -38,6 +38,8 @@ use loki_doc_model::loro_bridge::derive_loro_cursor; use loki_renderer::{DocumentView, RendererCursorPos, TileContext, ViewMode}; use super::editor_canvas_loading::loading_view; +use super::editor_canvas_spell::open_spell_panel_at; +use super::editor_caret_follow::CaretFollow; use super::editor_error_view::EditorErrorView; use super::editor_keydown::make_keydown_handler; use super::editor_pointer::{make_mousedown_handler, make_mousemove_handler, make_mouseup_handler}; @@ -47,7 +49,7 @@ use super::editor_pointer_touch::{ use super::editor_scrollbar::{ CanvasMounted, ScrollMetrics, ThumbDrag, horizontal_scrollbar, vertical_scrollbar, }; -use super::editor_spell::{SpellMenu, resolve_spell_menu}; +use super::editor_spell::SpellMenu; use crate::editing::cursor::{CursorState, DocumentPosition}; use crate::editing::hit_test::{link_at_point, open_or_run}; use crate::editing::{hit_test::hit_test_page, state::DocumentState, touch::TouchInteractionState}; @@ -60,47 +62,6 @@ use crate::error::LoadError; /// the real height. const DEFAULT_VIEWPORT_HEIGHT_PX: f64 = 800.0; -/// Right-click handler body: resolves the word under the tile-local coordinates -/// in `ctx` (accurate, via `element_coordinates` — no window-centring math), -/// selects it, and opens the spelling menu anchored at the cursor. A no-op when -/// there is no word at the point. -fn open_spell_panel_at( - ctx: TileContext, - doc_state: &Arc>, - loro_doc: Signal>, - service: &SpellService, - mut cursor_state: Signal, - mut spell_menu: Signal>, -) { - let layout_opt = { - let Ok(s) = doc_state.lock() else { return }; - s.paginated_layout.clone() - }; - let Some(layout) = layout_opt else { return }; - let Some(pos) = hit_test_page(ctx.page_index, ctx.x_pt, ctx.y_pt, &layout) else { - return; - }; - match resolve_spell_menu(loro_doc, service, pos.paragraph_index, pos.byte_offset) { - Some(mut menu) => { - // Anchor the floating menu at the cursor (window-relative coords). - menu.anchor_x = ctx.client_x; - menu.anchor_y = ctx.client_y; - // Select the whole word so the user sees what the suggestions apply to. - let word_pos = |byte_offset| { - DocumentPosition::top_level(pos.page_index, menu.paragraph_index, byte_offset) - }; - cursor_state.write().anchor = Some(word_pos(menu.byte_start)); - cursor_state.write().focus = Some(word_pos(menu.byte_end)); - spell_menu.set(Some(menu)); - } - // No word at the point — just place the caret. - None => { - cursor_state.write().anchor = Some(pos.clone()); - cursor_state.write().focus = Some(pos); - } - } -} - /// Renders the scrollable canvas area for the document editor. /// /// Plain function — no hooks allowed. All reactive state is passed in as @@ -294,6 +255,23 @@ pub(super) fn render_canvas_area( scroll_metrics, ), + // Keeps the caret on screen as it moves (Spec 08 I-05). A zero- + // output sensor component so the effect gets a hook scope without + // this plain function needing one — see `editor_caret_follow`. + CaretFollow { + doc_state: Arc::clone(&doc_state_context), + cursor_state, + scroll_metrics, + canvas_mounted, + is_dragging, + view_mode, + zoom_percent, + page_gap_px, + // The scroll container's own top padding: content y = 0 is its + // top edge, and the first page starts one padding below. + content_top_px: tokens::SPACE_6, + } + match &*document_load.value().read_unchecked() { // Gate on `total_pages > 0`: the document has loaded *and* the // first paginated layout is ready (published by the deferred diff --git a/loki-text/src/routes/editor/editor_canvas_spell.rs b/loki-text/src/routes/editor/editor_canvas_spell.rs new file mode 100644 index 00000000..dd675b48 --- /dev/null +++ b/loki-text/src/routes/editor/editor_canvas_spell.rs @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Right-click spelling-menu resolution for the document canvas. +//! +//! Extracted from `editor_canvas` (Spec 08 Phase 1): that file is over the +//! 300-line ceiling and pinned by the CI ratchet, so mounting the caret-follow +//! sensor there had to be paid for by taking something out. This handler body +//! is the natural candidate — it is already a free function, it is the only +//! part of the module that is about spelling rather than about the canvas, and +//! it has no shared state with the rest of the file. + +use std::sync::Arc; + +use dioxus::prelude::*; +use loki_app_shell::spell::SpellService; +use loki_renderer::TileContext; + +use super::editor_spell::{SpellMenu, resolve_spell_menu}; +use crate::editing::cursor::{CursorState, DocumentPosition}; +use crate::editing::{hit_test::hit_test_page, state::DocumentState}; + +/// Right-click handler body: resolves the word under the tile-local coordinates +/// in `ctx` (accurate, via `element_coordinates` — no window-centring math), +/// selects it, and opens the spelling menu anchored at the cursor. A no-op when +/// there is no word at the point. +pub(super) fn open_spell_panel_at( + ctx: TileContext, + doc_state: &Arc>, + loro_doc: Signal>, + service: &SpellService, + mut cursor_state: Signal, + mut spell_menu: Signal>, +) { + let layout_opt = { + let Ok(s) = doc_state.lock() else { return }; + s.paginated_layout.clone() + }; + let Some(layout) = layout_opt else { return }; + let Some(pos) = hit_test_page(ctx.page_index, ctx.x_pt, ctx.y_pt, &layout) else { + return; + }; + match resolve_spell_menu(loro_doc, service, pos.paragraph_index, pos.byte_offset) { + Some(mut menu) => { + // Anchor the floating menu at the cursor (window-relative coords). + menu.anchor_x = ctx.client_x; + menu.anchor_y = ctx.client_y; + // Select the whole word so the user sees what the suggestions apply to. + let word_pos = |byte_offset| { + DocumentPosition::top_level(pos.page_index, menu.paragraph_index, byte_offset) + }; + cursor_state.write().anchor = Some(word_pos(menu.byte_start)); + cursor_state.write().focus = Some(word_pos(menu.byte_end)); + spell_menu.set(Some(menu)); + } + // No word at the point — just place the caret. + None => { + cursor_state.write().anchor = Some(pos.clone()); + cursor_state.write().focus = Some(pos); + } + } +} diff --git a/loki-text/src/routes/editor/editor_caret_follow.rs b/loki-text/src/routes/editor/editor_caret_follow.rs new file mode 100644 index 00000000..d98f4b37 --- /dev/null +++ b/loki-text/src/routes/editor/editor_caret_follow.rs @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Keep the caret on screen while typing (Spec 08 I-05, T1.3–T1.5). +//! +//! # Why a component and not a hook call +//! +//! The effect needs a hook scope, and `render_canvas_area` is a plain function +//! that cannot call hooks. Hosting it in `editor_inner` would grow a file that +//! is already over the 300-line ceiling and pinned by the CI ratchet, so this +//! is a zero-output sensor component mounted inside the canvas subtree instead +//! — the same shape as `SafeAreaResizeSensor` and `AtViewportWidthSensor`, and +//! what ADR-0013 prescribes for anything that needs its own hook scope. +//! +//! # What triggers a reveal (T1.4, L08-019) +//! +//! A reveal fires on a change to the caret's [`CaretRevision`] — its identity — +//! and on nothing else: +//! +//! - typing, deletion and arrow/Home/End navigation move the caret → reveal; +//! - a wheel or touch scroll does not change the caret's identity → no reveal, +//! so the viewport never fights a user who has scrolled away to read; +//! - drag-select does move the caret, so it is gated explicitly on +//! `is_dragging`; +//! - clicking to place the caret moves it, but the click was inside the +//! viewport, so `reveal_offset` finds it already visible and does nothing. +//! +//! ## Why the guard exists, and why subscribing to `cursor_state` was not enough +//! +//! The first version of this file relied on effect subscriptions alone: the +//! effect read `cursor_state` and nothing else, so it looked as though only a +//! caret move could wake it. It also called `ViewportController::scroll_to_reveal`, +//! which read the metrics signal *reactively* — so the effect silently became +//! a subscriber to every scroll event too. Turning the wheel re-ran it, the +//! caret's position relative to the new offset was outside the margin band +//! because the user had just scrolled it there, and the reveal dragged the view +//! back. Wheel scrolling was capped at the margin band around the caret. That +//! is I-20, and it shipped through 31 unit tests, the full workspace suite, the +//! CI clippy command and eight script gates — it took thirty seconds in front +//! of a screen to find. +//! +//! The controller no longer subscribes (see its type docs), which stops the +//! loop. This guard is the second layer: because the trigger is caret identity +//! rather than anything derived from scroll position, a subscription +//! reintroduced by a later edit cannot restart it. Two independent reasons the +//! bug cannot come back, because one of them already looked sufficient. +//! +//! ## What the fix gives up, deliberately +//! +//! Changing zoom, or a relayout that moves the caret's rect while its identity +//! is unchanged, no longer scrolls the caret back into view. Under the buggy +//! version it did — as a side effect of the subscription, not by design. Keeping +//! the caret visible across a zoom change is a reasonable feature, but it is a +//! *different* one, and the only way to get it from here is to reintroduce the +//! recomputation that caused I-20. If it is wanted, add an explicit zoom trigger +//! that reveals once per zoom change; do not widen this effect's subscriptions. +//! The same reasoning covers R25: a caret that stays put while an async font +//! load or image resolution shifts the layout under it will not yank the view. +//! +//! # I-21: the trigger point, and what inspection has already ruled out +//! +//! The reveal fires when `caret_top + caret_height + 3 × line` passes the +//! viewport bottom — that is, when fewer than three line-heights of space +//! remain below the caret's own line. The screen test reports it firing +//! *earlier* than that. T1.9 gives four candidate causes; two are ruled out +//! here, and the `tracing::debug!` below separates the rest in one observation. +//! +//! **Ruled out — the margin arithmetic.** `RevealMargin::caret_lines` is one +//! line leading, three trailing, and `reveal_offset` adds the target's own +//! height before the trailing term. Three clear lines below the caret's line is +//! what the code computes, matching T1.3's wording. +//! +//! **Ruled out — chrome inside `client_height`.** The custom scrollbars are +//! siblings of the scroll container, not children (`editor_canvas`: the +//! vertical bar is beside it in the row, the horizontal bar below it in the +//! column), so neither steals visible height. +//! +//! **Ruled out — a missing zoom factor.** The margin is derived from the caret +//! rect's height, which is already in CSS px at the current zoom, so it scales +//! with zoom by construction rather than by a separate multiply. +//! +//! **Still open, and separated by the log line.** +//! +//! 1. *A fixed pixel term.* `content_top_px` is the container's 24 px top +//! padding, and the caret rect is offset by it on the assumption that +//! `scrollTop = 0` sits at the top of the padding box. If Blitz places the +//! scroll origin after the padding instead, every caret rect is 24 px low +//! and the trigger comes 24 px early — constant across zoom and font size. +//! Signature in the log: `caret_bottom` exceeds the caret's true on-screen +//! position by a constant. +//! 2. *Three lines is simply too generous.* Signature: `caret_bottom` and +//! `visible_bottom - 3 × line` agree, and the reveal is behaving exactly as +//! specified. Then it is a taste change and the value moves — but only then. +//! +//! **The value has deliberately not been tuned.** T1.9 is explicit that three +//! of the four causes are bugs, and lowering the constant would mask any of +//! them while making the symptom go away. +//! +//! # The soft keyboard (T1.5) needs no special case +//! +//! T1.5 requires the reveal to target the safe area rather than the window when +//! a soft keyboard is up. It already does, because the reveal measures against +//! the scroll container's **own measured** `client_height` rather than the +//! window: `routes::shell` sizes the shell `calc(100vh - inset_total)`, and the +//! Android inset query folds `WindowInsets.Type.ime()` into that total (S0.4), +//! so the container physically shrinks when the keyboard appears and the +//! measurement follows. Where no IME exists — desktop, or Android with a +//! hardware keyboard — the inset is zero and the safe area *is* the window, +//! which is exactly what T1.5 asks for. Adding a second, explicit safe-area +//! term here would double-count it. +//! +//! # Why there is no debounce timer +//! +//! T1.4 asks for debouncing during fast typing. None is needed, and adding one +//! would make the caret lag the text: `reveal_offset` is idempotent — it +//! returns `None` whenever the caret is already visible with its margins — and +//! when it does scroll it moves the **minimum** distance. Successive keystrokes +//! therefore produce either nothing or a few pixels of follow, never the +//! oscillation a debounce would be protecting against. Revisit if a device says +//! otherwise; it is a one-line change to `scroll_to`'s call site. +//! +//! Note what that argument does *not* cover, and what I-20 proved: idempotence +//! says a reveal is harmless when the caret is already visible. It says nothing +//! about *when* the reveal runs. Not fighting the user is a property of the +//! trigger, not of the reveal — which is why it now lives in the guard above +//! rather than being inferred from `reveal_offset`'s behaviour. + +use std::sync::{Arc, Mutex}; + +use appthere_ui::{RevealMargin, ScrollMetrics, use_viewport_controller}; +use dioxus::prelude::*; +use loki_renderer::ViewMode; + +use super::editor_caret_follow_geom::{CaretGeometryInput, caret_content_rect}; +use super::editor_responsive::zoom_fraction; +use super::editor_scrollbar::CanvasMounted; +use crate::editing::caret_reveal::{CaretRevision, caret_line_height_px, should_reveal}; +use crate::editing::cursor::CursorState; +use crate::editing::state::DocumentState; + +/// Line height assumed when the caret has no measured rect yet (11 pt single +/// spaced at 100%). Only used for the reveal margin, never for placement. +const FALLBACK_LINE_PX: f32 = 18.0; + +#[derive(Clone, Props)] +pub(super) struct CaretFollowProps { + pub(super) doc_state: Arc>, + pub(super) cursor_state: Signal, + pub(super) scroll_metrics: Signal, + pub(super) canvas_mounted: CanvasMounted, + /// True while a pointer drag-select is in progress; suppresses the reveal. + pub(super) is_dragging: Signal, + pub(super) view_mode: Signal, + pub(super) zoom_percent: Signal, + pub(super) page_gap_px: f32, + /// Top padding of the scroll container, in CSS px. + pub(super) content_top_px: f32, +} + +impl PartialEq for CaretFollowProps { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.doc_state, &other.doc_state) + && self.cursor_state == other.cursor_state + && self.scroll_metrics == other.scroll_metrics + && self.canvas_mounted == other.canvas_mounted + && self.is_dragging == other.is_dragging + && self.view_mode == other.view_mode + && self.zoom_percent == other.zoom_percent + && self.page_gap_px == other.page_gap_px + && self.content_top_px == other.content_top_px + } +} + +/// Zero-output sensor that scrolls the caret into view when it moves. +// A Dioxus component must be PascalCase to be usable as `CaretFollow {}` in +// rsx. `#[component]` would supply this allow itself, but it derives the props +// struct from the argument list and so requires every prop to be `PartialEq` — +// `Arc>` is not, hence the hand-written props above with +// their `Arc::ptr_eq` comparison. Same trade as `PageTile` and `ReflowDocView`, +// which are the other two components in the workspace taking a props struct +// directly. +#[allow(non_snake_case)] +pub(super) fn CaretFollow(props: CaretFollowProps) -> Element { + let mut controller = use_viewport_controller(props.scroll_metrics, props.canvas_mounted); + let doc_state = Arc::clone(&props.doc_state); + let cursor_state = props.cursor_state; + let is_dragging = props.is_dragging; + let view_mode = props.view_mode; + let zoom_percent = props.zoom_percent; + let metrics = props.scroll_metrics; + let page_gap_px = props.page_gap_px; + let content_top_px = props.content_top_px; + // Caret identity at the last reveal. The reveal fires on a change to this + // and on nothing else (L08-019). + let mut last_revision = use_signal(|| None::); + + use_effect(move || { + // Subscribes this effect to caret movement. + let (focus, anchor) = { + let cs = cursor_state.read(); + (cs.focus.clone(), cs.anchor.clone()) + }; + let Some(focus) = focus else { return }; + // The trigger gate. The effect may re-run for reasons that are not the + // caret moving; those must not scroll the view. Checked before any + // geometry work, so a spurious wake is also cheap. + let revision = CaretRevision::new(focus.clone(), anchor); + if !should_reveal(last_revision.peek().as_ref(), &revision) { + return; + } + // T1.4: a drag is the user choosing where to look; do not move it. + if *is_dragging.peek() { + return; + } + let m = *metrics.peek(); + if !m.is_measured() { + return; + } + + let rect = caret_content_rect( + &doc_state, + &focus, + CaretGeometryInput { + view_mode: *view_mode.peek(), + client_width: m.client_width, + zoom: zoom_fraction(*zoom_percent.peek()), + page_gap_px, + content_top_px, + }, + ); + + let Some(rect) = rect else { return }; + // Record the revision only once the geometry resolved. Recording it on + // a frame where the layout was stale would mark a caret move as handled + // without ever revealing it, and the next keystroke at the same + // position would not retry. + last_revision.set(Some(revision)); + // T1.3: the margin is three body lines below and one above, measured + // from the caret's own line so it holds at every size and zoom. + let line = caret_line_height_px(Some(rect), FALLBACK_LINE_PX); + // I-21 instrument. The reported symptom — "triggers higher in the + // viewport than 3 lines" — has four possible causes that a screen test + // cannot tell apart by eye but these numbers separate immediately: + // compare `caret_bottom` against `visible_bottom - 3 × line`. If they + // agree, the margin is doing exactly what it says and 3 lines is simply + // too generous (a taste change); if they disagree, the shortfall is the + // bug, and whether it is constant, scales with zoom, or scales with + // line height names which one. See the module docs. + tracing::debug!( + target: "loki_text::caret_follow", + caret_top = rect.1, + caret_bottom = rect.1 + rect.3, + line_px = line, + scroll_top = m.scroll_top, + visible_bottom = m.scroll_top + m.client_height, + trailing_margin_px = line * 3.0, + "caret reveal evaluated", + ); + // Instant, always. A smooth caret-follow would lag the text at typing + // speed; smooth is reserved for discrete jumps (Find, Go To Page). + controller.scroll_to_reveal( + rect, + RevealMargin::caret_lines(line), + ScrollBehavior::Instant, + ); + }); + + rsx! {} +} diff --git a/loki-text/src/routes/editor/editor_caret_follow_geom.rs b/loki-text/src/routes/editor/editor_caret_follow_geom.rs new file mode 100644 index 00000000..9c8cccad --- /dev/null +++ b/loki-text/src/routes/editor/editor_caret_follow_geom.rs @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Resolving the caret's content-space rect for whichever renderer is active. +//! +//! Extracted from `editor_caret_follow` so neither file approaches the 300-line +//! ceiling — that module is mostly the trigger rule and the reasoning behind it +//! (see its docs for I-20), and this is the geometry lookup, which is a +//! separable concern with a different reason to change. +//! +//! Both branches end in `editing::caret_reveal`, which owns the arithmetic; the +//! work here is getting the right layout and the right scale factor for the +//! current mode. + +use std::sync::{Arc, Mutex}; + +use loki_renderer::ViewMode; +use loki_renderer::render_layout::{reflow_layout_content_width_pt, reflow_type_scale}; + +use crate::editing::caret_reveal::{PageStack, caret_rect_paginated, caret_rect_reflow}; +use crate::editing::cursor::DocumentPosition; +use crate::editing::state::{DocumentState, ensure_reflow_layout}; + +/// Everything the resolution needs that is not the caret itself. +#[derive(Clone, Copy)] +pub(super) struct CaretGeometryInput { + /// Active renderer. + pub(super) view_mode: ViewMode, + /// Measured visible width of the scroll container, in CSS px. Drives the + /// reflow layout width and its responsive type scale. + pub(super) client_width: f32, + /// Zoom fraction (1.0 = 100%); paginated only. + pub(super) zoom: f32, + /// Inter-page gap in CSS px; paginated only. + pub(super) page_gap_px: f32, + /// Scroll container top padding, in CSS px. + pub(super) content_top_px: f32, +} + +/// The caret's rect in scroll-container content coordinates, or `None` when the +/// geometry cannot be resolved yet. +/// +/// `None` is a normal state, not an error: the layout may not have been +/// recomputed since the last edit, or the container may not be measured. The +/// caller treats it as "nothing to reveal" and retries on the next caret move, +/// rather than scrolling to a guess. +pub(super) fn caret_content_rect( + doc_state: &Arc>, + focus: &DocumentPosition, + input: CaretGeometryInput, +) -> Option<(f32, f32, f32, f32)> { + if input.view_mode == ViewMode::Reflow { + if input.client_width <= 1.0 { + return None; + } + let scale = reflow_type_scale(input.client_width); + let content_w = reflow_layout_content_width_pt(input.client_width); + let layout = ensure_reflow_layout(doc_state, content_w)?; + return caret_rect_reflow( + &layout, + focus.paragraph_index, + focus.byte_offset, + scale, + input.content_top_px, + ); + } + + // One lock for both reads: taking it twice could observe a layout and a + // page height from either side of a relayout, which is how a caret lands + // one page off. + let (layout, page_height_px) = { + let state = doc_state.lock().ok()?; + (state.paginated_layout.clone()?, state.page_height_px) + }; + let stack = PageStack { + page_height_px, + page_gap_px: input.page_gap_px, + zoom: input.zoom, + content_top_px: input.content_top_px, + }; + caret_rect_paginated(&layout, focus, stack) +} diff --git a/loki-text/src/routes/editor/editor_scrollbar.rs b/loki-text/src/routes/editor/editor_scrollbar.rs index 1600e96f..45f3ccae 100644 --- a/loki-text/src/routes/editor/editor_scrollbar.rs +++ b/loki-text/src/routes/editor/editor_scrollbar.rs @@ -39,26 +39,13 @@ const TRACK_PX: f32 = 12.0; const MIN_THUMB_FRAC: f32 = 0.08; /// Live scroll geometry for the canvas container, mirrored from the most recent -/// DOM `scroll` event. All values are logical pixels; `scroll_width` / -/// `scroll_height` are the scrollable distance (see module docs). Defaults to -/// all-zero (pre-first-scroll), which callers treat as "not yet measured". -#[derive(Clone, Copy, PartialEq, Default)] -pub(super) struct ScrollMetrics { - pub scroll_top: f32, - pub scroll_left: f32, - pub scroll_width: f32, - pub scroll_height: f32, - pub client_width: f32, - pub client_height: f32, -} - -impl ScrollMetrics { - /// True when the content can be scrolled horizontally — the only case in - /// which the bottom scrollbar is shown. - fn can_scroll_x(&self) -> bool { - self.client_width > 0.0 && self.scroll_width > 0.5 - } -} +/// DOM `scroll` event. +/// +/// Now `appthere_ui::ScrollMetrics` — the caret-follow controller (Spec 08 +/// T1.2) needs exactly these six numbers, and a second copy would drift from +/// this one the way viewport width drifted before Spec 01 audit A-1. Re-exported +/// under the old path so the editor's call sites are unchanged. +pub(super) use appthere_ui::ScrollMetrics; /// Returns `(thumb_fraction, start_fraction)` of the track for one axis. /// diff --git a/loki-text/src/routes/editor/mod.rs b/loki-text/src/routes/editor/mod.rs index 50016c32..cbb295ac 100644 --- a/loki-text/src/routes/editor/mod.rs +++ b/loki-text/src/routes/editor/mod.rs @@ -11,6 +11,9 @@ mod editor_canvas; mod editor_canvas_loading; +mod editor_canvas_spell; +mod editor_caret_follow; +mod editor_caret_follow_geom; mod editor_color_panel; mod editor_compact; mod editor_dirty; diff --git a/loki-vello/src/scene_cursor_tests.rs b/loki-vello/src/scene_cursor_tests.rs index dc02057b..6d9cdf95 100644 --- a/loki-vello/src/scene_cursor_tests.rs +++ b/loki-vello/src/scene_cursor_tests.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use vello::kurbo::Point; -use loki_layout::{CellRotation, CursorRect, PageParagraphData, ParagraphLayout}; +use loki_layout::{ByteIndexMap, CellRotation, CursorRect, PageParagraphData, ParagraphLayout}; use super::{cursor_paint_transform, paint_cursor}; use crate::scene::{SelectionHandle, SelectionHandleKind, SelectionRect}; @@ -23,8 +23,8 @@ fn para(origin: (f32, f32), rotation: Option) -> PageParagraphData last_baseline: 10.0, line_boundaries: Vec::new(), parley_layout: None, - orig_to_clean: Vec::new(), - clean_to_orig: Vec::new(), + orig_to_clean: ByteIndexMap::Identity { len: 0 }, + clean_to_orig: ByteIndexMap::Identity { len: 0 }, indent_start: 0.0, indent_hanging: 0.0, drop_lines: 0, diff --git a/scripts/check-arc-get-mut.py b/scripts/check-arc-get-mut.py new file mode 100755 index 00000000..ad087208 --- /dev/null +++ b/scripts/check-arc-get-mut.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 AppThere Loki contributors +"""`Arc::get_mut` ban in the layout crate (Spec 09 L9-016). + +Spec 09 S9-1 made `ParaCache` hand out `Arc`, so the shaping +cache and the page editing index share one allocation. Mutating a shared layout +must therefore go through `Arc::make_mut`, which takes a private copy. That much +is enforced by the type: `Arc` yields `&T`, so a forgotten copy is an E0596 +borrow error rather than a silent cross-placement corruption (R9-14). + +`Arc::get_mut` is the one shape that defeats it: + + if let Some(l) = Arc::get_mut(&mut layout) { l.items.push(item) } + +That compiles, returns `None` whenever the value is shared — which, for a cached +layout, is always — and reports nothing. The mutation is silently skipped. It is +the same failure as a sentinel that cannot distinguish "nothing to do" from "I +could not do it" (L9-009), and this gate exists because the shape was predicted +before anyone wrote it, rather than found after an incident. + +`get_mut` on `Vec`, `HashMap`, `RefCell` and friends is unaffected — only the +`Arc::` associated function is matched, which is the only form `Arc::get_mut` +can be written in (it is deliberately not a method, so `.get_mut()` never +resolves to it). + +Scope: `loki-layout/src/**.rs`, where layout sharing is load-bearing. Widen +`SCOPES` if another crate starts sharing `Arc`s that are mutated in place. + +Comments are stripped before matching, on purpose. The suppression ratchet +text-matches `let _ =` anywhere in a file including prose, so documenting the +pattern it polices trips it (Spec 08 §8). A gate that cannot be explained in the +code it guards is a gate people work around; this one lets you name the hazard +in a doc comment and still fails on a real call. + +Usage: + scripts/check-arc-get-mut.py +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +# Crates where `Arc` sharing is load-bearing and in-place mutation is a hazard. +SCOPES = ("loki-layout/src/",) + +# `Arc::get_mut`, however the path is spelled: `Arc::`, `sync::Arc::`, or a +# fully-qualified `std::sync::Arc::`. +GET_MUT = re.compile(r"\bArc::get_mut\b") + +# Everything from `//` to end of line. Crude — it also truncates a `//` inside a +# string literal — but this gate only ever needs the code *before* a comment, so +# over-truncating can produce a false negative on a pathological line, never a +# false positive on prose. Erring that way is deliberate: a gate that fires on +# documentation gets disabled. +LINE_COMMENT = re.compile(r"//.*$") + + +def in_scope(rel: str) -> bool: + return rel.endswith(".rs") and any(rel.startswith(s) for s in SCOPES) + + +def main() -> int: + out = subprocess.check_output(["git", "ls-files", "*.rs"], cwd=REPO, text=True) + failures: list[str] = [] + scanned = 0 + for rel in out.splitlines(): + if not rel or not in_scope(rel): + continue + scanned += 1 + text = (REPO / rel).read_text(encoding="utf-8", errors="replace") + for i, line in enumerate(text.splitlines(), 1): + if GET_MUT.search(LINE_COMMENT.sub("", line)): + failures.append(f"{rel}:{i}") + + if failures: + print(f"Arc::get_mut gate: {len(failures)} violation(s):\n") + for v in failures: + print(f" ✗ {v}: `Arc::get_mut` on a shared layout") + print( + "\n`Arc::get_mut` returns None whenever the value is shared, so this\n" + "silently skips the mutation instead of taking a private copy. Use\n" + "`Arc::make_mut`, which clones on write and cannot fail (Spec 09\n" + "L9-016, R9-14). If you genuinely need the fallible form, the value\n" + "is not a shared layout and does not belong in this crate's scope." + ) + return 1 + + print( + f"Arc::get_mut gate: OK — {scanned} file(s) in " + f"{', '.join(SCOPES)}; copy-on-write goes through Arc::make_mut." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/file-ceiling-baseline.txt b/scripts/file-ceiling-baseline.txt index ecd8b067..fd75b2f7 100644 --- a/scripts/file-ceiling-baseline.txt +++ b/scripts/file-ceiling-baseline.txt @@ -3,7 +3,7 @@ # these may not GROW, and must be removed once split to <= 300 lines. # Regenerate with: scripts/check-file-ceiling.py --update -1014 loki-spreadsheet/src/routes/editor/editor_inner.rs +1013 loki-spreadsheet/src/routes/editor/editor_inner.rs 800 loki-text/src/routes/editor/editor_inner.rs -770 loki-layout/src/para.rs -460 loki-text/src/routes/editor/editor_canvas.rs +767 loki-layout/src/para.rs +432 loki-text/src/routes/editor/editor_canvas.rs diff --git a/scripts/suppressions-baseline.txt b/scripts/suppressions-baseline.txt index 4e571b2a..de14ce7a 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: 515 `let _ =`, 184 `#[allow]` across 159 files. +# Totals: 515 `let _ =`, 185 `#[allow]` across 160 files. 66 1 loki-ooxml/src/docx/write/document_drawing.rs 47 0 loki-ooxml/src/docx/write/styles.rs @@ -146,6 +146,7 @@ 0 1 loki-text/build.rs 0 1 loki-text/src/lib.rs 0 1 loki-text/src/routes/editor/editor_canvas.rs +0 1 loki-text/src/routes/editor/editor_caret_follow.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