Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9fb11f9
Spec 08 Phase 0: six spike findings documents
claude Jul 25, 2026
2533939
Spec 08 Phase 0.5: restore the Android build and gate it in CI
claude Jul 25, 2026
09f0d10
Spec 08 Phase 0.5: prove the CI-blindness half of the negative test
claude Jul 25, 2026
fe2b291
Spec 08 Phase 1: scroll plumbing and caret follow (I-05)
claude Jul 25, 2026
f886c1a
Spec 08 Phase 1: fix the wheel-scroll regression (I-20), narrow I-21
claude Jul 25, 2026
a206483
Spec 09 Phase 0: answer Q1-Q7 with a layout residency census
claude Jul 26, 2026
0b0d2e4
Spec 09 E0: run the gating experiment — census confirmed, total corre…
claude Jul 26, 2026
a32fe36
Spec 09: extend E0 to real documents — the per-character model does n…
claude Jul 26, 2026
fbc9fb6
Spec 09 E0: the instrument was order-dependent — corpus figures corre…
claude Jul 26, 2026
340ab0c
Spec 09 E0: decompose residency into content-keyed and per-placement
claude Jul 26, 2026
67d8c86
Spec 09 S9-1: record the predicted C/P split before implementing (L9-…
claude Jul 26, 2026
ffb6bdf
Spec 09 S9-1: share one ParagraphLayout between the cache and the edi…
claude Jul 26, 2026
10ea537
Spec 09: answer R9-14, and predict S9-2's split before implementing (…
claude Jul 26, 2026
84d5856
Spec 09 S9-2: compact the byte-index maps, and confirm the null predi…
claude Jul 26, 2026
d2fde5b
Spec 09: gate Arc::get_mut, measure R9-15, derive S9-3's governing me…
claude Jul 26, 2026
ab4f90b
Spec 09: refute R9-16, measure the per-keystroke scan, record the con…
claude Jul 26, 2026
e7b4686
Spec 09: characterise recompute_page_index — R9-18 is refuted and ret…
claude Jul 26, 2026
83fef2c
Spec 09: unbundle the retraction — R9-18 gone, R9-19 carries the acce…
claude Jul 26, 2026
fbe45b2
Spec 09: R9-18 is over-generalised, not false; the bench varied the w…
claude Jul 26, 2026
2e504c5
Fix Android CPU build: DocumentView's dioxus prelude was gated to the…
claude Jul 26, 2026
68d9350
Trim the document_view comment back under the file ceiling
claude Jul 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions appthere-ui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
225 changes: 225 additions & 0 deletions appthere-ui/src/device_profile.rs
Original file line number Diff line number Diff line change
@@ -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<f32>,
}

/// 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<u64>,
/// GPU capability class.
pub gpu_class: GpuClass,
/// The current display's physical characteristics.
pub display: Option<PhysicalDisplay>,
/// 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<DeviceProfile>,
}

/// 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<DeviceProfile> {
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::<AtDeviceProfileContext>() {
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::<AtDeviceProfileContext>() 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;
Loading
Loading