collective

Browse / github.com/storytold/effectcraft / patch

ui: the Android touch pass — finger scrolls, the widened bar target, UI Scale 50 %

ui: the Android touch pass — finger scrolls, the widened bar target, UI Scale 50 % The fourth of the parts the v1 review asked #1508 to be split into — the touch/pointer work in ui-egui plus the edge of it that reaches prefs. Builds on #2361 (`split/app-lib`); the browser part that follows (#-split/android-file-browser) stacks on this and on #2364 (`split/android-entry`). **What it adds, commit by commit** (each is #1508's own commit, replayed onto the parts' bases): - Menu bodies grew drag-to-scroll: `menu_scroll_area` + `menu_drag` (`DragScroll::Always`) replace egui's touch-gated default so any pointer can drag a menu taller than the window — a finger drag on the Edit m

Status
open
Score
0.00
Adopters
0 other operators
Author
Kaedo17 (imported from pull request #2365, open)
Base
991904888a20b7da72b2a4880b21c7d912154736
Size
+1299 −673 in 17 files
Review
pending
Shared
2026-10-28
Id
05d5c5b98712ecf012cde3a6

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock apps/effectcraft/Cargo.toml apps/effectcraft/src/android_storage.rs apps/effectcraft/src/audio_out.rs apps/effectcraft/src/lib.rs apps/effectcraft/src/main.rs crates/engine/src/prefs.rs crates/engine/src/tests_settings.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/panels/settings.rs crates/ui-egui/src/theme.rs crates/ui-egui/src/widgets.rs crates/ui-egui/tests/it/shared_docking_ui.rs crates/ui-egui/tests/it/ui_panels.rs docs/architecture.md docs/gaps.md docs/preferences.md

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -1174,6 +1174,7 @@ dependencies = [ name = "effectcraft" version = "0.16.0" dependencies = [+ "android-activity",  "cpal",  "effectcraft-engine",  "effectcraft-host",diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml--- a/apps/effectcraft/Cargo.toml+++ b/apps/effectcraft/Cargo.toml@@ -6,6 +6,10 @@ edition.workspace = true license.workspace = true default-run = "effectcraft" +[lib]+# The desktop binary and Android's entry shim (apps/effectcraft-android) share this library.+crate-type = ["rlib"]+ [lints] workspace = true @@ -15,17 +19,29 @@ effectcraft-host = { workspace = true } effectcraft-ui-egui = { workspace = true } egui = { workspace = true } # `links`: links (Help menu, Discord, Settings) open in the system browser.-eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }+eframe = { workspace = true, features = ["default_fonts", "wgpu", "links"] } serde = { workspace = true } serde_json = { workspace = true } # The window's monitors, size and position (src/window.rs), and macOS event-loop options (no app # activation when driven by an agent). The same winit eframe runs on. winit = { version = "0.30", default-features = false }-rfd = { workspace = true } pollster = { workspace = true } cpal = { workspace = true } log = { workspace = true } +[target.'cfg(target_os = "android")'.dependencies]+# The NativeActivity handle eframe's event loop needs (`NativeOptions.android_app`); the+# same 0.6 instance winit and eframe use, so the handle passes across crates as one type.+android-activity = { version = "0.6", default-features = false, features = ["native-activity"] }+[target.'cfg(not(target_os = "android"))'.dependencies]+# File and message dialogs. rfd has no Android backend (it doesn't compile there), so+# Android builds leave the file hooks empty and the commands fail with+# "no file dialog available".+rfd = { workspace = true }+# Screen-reader support and the desktop windowing backends. eframe 0.36 only offers+# `accesskit` with `android-game-activity`, and Wayland/X11 don't exist on Android.+eframe = { workspace = true, features = ["accesskit", "wayland", "x11"] }+ [target.'cfg(target_os = "linux")'.dependencies] # Settings ▸ Appearance ▸ Sync with System: the XDG desktop portal's colour scheme. Already in # Cargo.lock (accesskit, through eframe), so it adds no new crates.diff --git a/apps/effectcraft/src/android_storage.rs b/apps/effectcraft/src/android_storage.rsnew file mode 100644--- /dev/null+++ b/apps/effectcraft/src/android_storage.rs@@ -0,0 +1,62 @@+//! Android's app-private storage: `Projects/`, `Exports/` and `config/` below the+//! `AndroidApp::internal_data_path` directory.+//!+//! Android has no system file picker in v1 (rfd has no backend there), so saves, render outputs+//! and settings default to private storage: no permissions, no Storage Access Framework+//! round-trip, though invisible to the Files app (the in-app browser can additionally reach+//! shared storage once "All files access" is granted). Pure path logic, unit-tested on the host;+//! the handle comes from `run_android` (`apps/effectcraft/src/lib.rs`).++use std::path::{Path, PathBuf};++/// The storage folders below the app-private base directory.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct Layout {+    /// Saved `.ecproj`/`.ecprojx` projects (the Open Project prompt lists these).+    pub projects: PathBuf,+    /// Render output, frame stills and other saved files.+    pub exports: PathBuf,+    /// Settings, auto-save and crash-recovery state (`session.config`).+    pub config: PathBuf,+}++/// Split the app-private `base` into its folders.+pub fn layout(base: &Path) -> Layout {+    Layout { projects: base.join("Projects"), exports: base.join("Exports"), config: base.join("config") }+}++/// Create the folders (`Err` names the first failure; the app then keeps that feature off+/// rather than crashing).+pub fn ensure(layout: &Layout) -> Result<(), String> {+    for dir in [&layout.projects, &layout.exports, &layout.config] {+        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.to_string_lossy()))?;+    }+    Ok(())+}++#[cfg(test)]+mod tests {+    use super::*;++    fn temp_base(tag: &str) -> PathBuf {+        std::env::temp_dir().join(format!("ec-android-storage-{tag}-{}", std::process::id()))+    }++    #[test]+    fn layout_splits_the_base_into_projects_exports_and_config() {+        let base = Path::new("/data/data/ai.storyteller.effectcraft/files");+        assert_eq!(layout(base), Layout { projects: base.join("Projects"), exports: base.join("Exports"), config: base.join("config") });+    }++    #[test]+    fn ensure_creates_every_folder() {+        let base = temp_base("ok");+        let _ = std::fs::remove_dir_all(&base);+        let l = layout(&base);+        ensure(&l).unwrap();+        assert!(l.projects.is_dir() && l.exports.is_dir() && l.config.is_dir());+        // Re-running over existing folders is fine.+        ensure(&l).unwrap();+        let _ = std::fs::remove_dir_all(&base);+    }+}diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs--- a/apps/effectcraft/src/audio_out.rs+++ b/apps/effectcraft/src/audio_out.rs@@ -7,7 +7,7 @@ //! channels: silence), converting to the device's sample format.  use std::sync::Arc;-use std::sync::atomic::{AtomicU64, Ordering};+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc; use std::thread::JoinHandle; use std::time::Duration;@@ -22,6 +22,24 @@ fn preferred_buffer_frames(rate: u32) -> u32 {     (rate / 100).clamp(256, 2048) } +/// Frames between "handed over" and "audible": the device's output latency plus one callback's+/// buffer. `dev_lat` is `playback − callback` from the stream timestamps, trusted only while it+/// can be a real output latency: cpal's AAudio backend (Android) reports `playback` on+/// CLOCK_MONOTONIC (absolute, since boot) but `callback` since the stream was created, so their+/// difference is the device's uptime. An uptime-sized latency holds the preview's audio clock+/// behind the frames the device consumed — the picture freezes on the first frame while the+/// sound plays on. A value no device outputs is dropped (the buffer alone still counts).+fn sync_latency_frames(dev_lat: f64, rate: f64, buffer_frames: usize) -> u64 {+    const MAX_LATENCY_SECS: f64 = 1.0;+    static WARNED: AtomicBool = AtomicBool::new(false);+    let plausible = dev_lat.is_finite() && (0.0..=MAX_LATENCY_SECS).contains(&dev_lat);+    if !plausible && !WARNED.swap(true, Ordering::Relaxed) {+        log::warn!("audio output: implausible device latency {dev_lat}s; syncing A/V to this buffer alone");+    }+    let secs = if plausible { dev_lat } else { 0.0 };+    (secs * rate) as u64 + buffer_frames as u64+}+ /// Wait until the feeder has mixed a block, or until preview is stopped. Starting the stream /// before that fills the device with silence, so the first real samples are heard late (#554). fn wait_to_play(feed: &AudioFeed, stop: &mpsc::Receiver<()>) -> bool {@@ -115,7 +133,7 @@ fn run<T: SizedSample + FromSample<f32>>(             // Frames between "handed over" and "audible": device latency plus this buffer.             let ts = info.timestamp();             let dev_lat = ts.playback.duration_since(&ts.callback).map(|d| d.as_secs_f64()).unwrap_or(0.0);-            latency.store((dev_lat * rate) as u64 + frames as u64, Ordering::Relaxed);+            latency.store(sync_latency_frames(dev_lat, rate, frames), Ordering::Relaxed);         },         |e| log::warn!("audio output: {e}"),         None,@@ -230,6 +248,28 @@ mod tests {         assert_eq!(preferred_buffer_frames(192_000), 1920);     } +    /// Real device latencies (ALSA/Pulse/PipeWire report a few to a few hundred milliseconds)+    /// are used as they are, plus the callback's own buffer.+    #[test]+    fn sync_latency_keeps_a_plausible_device_latency() {+        assert_eq!(sync_latency_frames(0.02, 48_000.0, 480), 960 + 480);+        assert_eq!(sync_latency_frames(0.0, 48_000.0, 480), 480);+        assert_eq!(sync_latency_frames(0.3, 44_100.0, 441), 13_230 + 441);+    }++    /// cpal's AAudio backend (Android) subtracts two different clocks — `playback` is+    /// CLOCK_MONOTONIC since boot, `callback` since the stream was created — so the difference+    /// is the tablet's uptime. That must not become the A/V-sync latency: pinned there, the+    /// preview's clock never leaves its first frame (picture frozen while the sound plays).+    #[test]+    fn sync_latency_drops_an_uptime_sized_device_latency() {+        // A tablet up ~5 days at 48 kHz would otherwise ask for ~2e10 frames of latency.+        assert_eq!(sync_latency_frames(432_000.0, 48_000.0, 480), 480);+        assert_eq!(sync_latency_frames(f64::INFINITY, 48_000.0, 480), 480);+        assert_eq!(sync_latency_frames(f64::NAN, 48_000.0, 480), 480);+        assert_eq!(sync_latency_frames(-0.5, 48_000.0, 480), 480);+    }+     #[test]     fn wait_to_play_returns_once_the_feeder_has_samples() {         let feed = Arc::new(AudioFeed::default());diff --git a/apps/effectcraft/src/lib.rs b/apps/effectcraft/src/lib.rsnew file mode 100644--- /dev/null+++ b/apps/effectcraft/src/lib.rs@@ -0,0 +1,779 @@+//! EffectCraft app library: the window, hooks and launch logic shared by the desktop+//! binary (`main.rs`, whose `main` calls [`run`]) and Android's NativeActivity entry point+//! (`apps/effectcraft-android`, whose `android_main` calls `run_android`).+//!+//! Usage: `effectcraft [--control <port>] [--demo|--empty|--home] [--] [project.ecproj | media files…]`+//!+//! An unknown option or a bad port prints an error and exits with status 1 before anything starts.+//!+//! Without a project the app starts with an empty Untitled Project, as After Effects does;+//! `--demo` opens the demo project instead (also Help ▸ Open Demo Project).+//!+//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;+//! see `effectcraft_ui_egui::control` for the methods.+//!+//! On Android there are no command-line arguments, so the app opens an empty Untitled Project.+//! There is no system file picker (rfd has no Android backend): Save, Open Project, exports,+//! folder picks and media import are served by the in-app browser instead. It browses the+//! app-private storage (`Projects/`, `Exports/`) always, and shared storage+//! (`/storage/emulated/0`) too once the user grants "All files access" — the grant is checked+//! and requested over JNI by the entry crate (`effectcraft-android`), which opts out of this+//! workspace's `unsafe_code = "forbid"`; this crate stays unsafe-free and only receives+//! [`SharedStorageAccess`] as plain closures.+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]++// Built everywhere so its tests run on every platform; only Android uses it (its callers are+// cfg(android), so elsewhere these tests are the reason it compiles at all).+#[cfg_attr(not(target_os = "android"), allow(dead_code))]+mod android_storage;+mod appearance;+// Built everywhere so its tests run on every platform; only Linux AppImages use it.+#[cfg_attr(not(target_os = "linux"), allow(dead_code))]+mod appimage;+#[cfg(target_os = "macos")]+mod apple_events;+mod audio_out;+mod control_server;+mod launch_guard;+#[cfg(target_os = "macos")]+mod native_menu;+// Built everywhere so it stays checked; the macOS and Linux apps use it. Android has no rfd+// at all (no backend there), so it isn't built for Android.+#[cfg(not(target_os = "android"))]+#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]+mod sheet_dialogs;+mod window;++use effectcraft_ui_egui::EffectcraftApp;+use serde_json::json;++/// The app icon for the window, taskbar and Dock (assets/app-icon, regenerated by+/// `packaging/icons.sh`). macOS gets the padded variant so it sits on Apple's icon grid in the Dock.+/// The 256 px app icon (Windows, Linux; the AppImage's desktop integration installs it too).+#[cfg(not(target_os = "macos"))]+const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png");++fn app_icon() -> egui::IconData {+    #[cfg(target_os = "macos")]+    let png: &[u8] = include_bytes!("../../../assets/app-icon/effectcraft-macos-512.png");+    #[cfg(not(target_os = "macos"))]+    let png: &[u8] = ICON_PNG;+    eframe::icon_data::from_png_bytes(png).unwrap_or_default()+}++/// Both native JSON projects and Save a Copy As XML projects are openable on launch.+/// Keep this classification shared by project opening and the subsequent media-import pass.+fn is_project_file(path: &str) -> bool {+    std::path::Path::new(path)+        .extension()+        .and_then(|ext| ext.to_str())+        .is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx"))+}++/// winit 0.30 cannot deliver native Wayland file drops. XWayland can.+#[cfg(target_os = "linux")]+fn wayland_drop_notice(wayland_display: Option<&std::ffi::OsStr>, backend: Option<&std::ffi::OsStr>) -> Option<&'static str> {+    (wayland_display.is_some_and(|v| !v.is_empty()) && backend != Some(std::ffi::OsStr::new("x11")))+        .then_some("Native Wayland file drops are unavailable. Use File ▸ Import ▸ File… or run via XWayland (see README, Linux).")+}++/// The command line.+#[derive(Debug, Default, PartialEq)]+struct Args {+    control_port: Option<u16>,+    files: Vec<String>,+    /// Open the demo project when no project is given (`--demo`).+    demo: bool,+    /// Show or skip the Home screen (`--home`), else the Startup preference decides.+    home: Option<bool>,+}++/// Parse the arguments after the program name; `Ok(None)` for `--version`. `env_port` is+/// `EFFECTCRAFT_CONTROL_PORT`, used when `--control` isn't given. An unknown option or a bad port+/// is an error, so it is neither imported as a file nor dropped; after `--` every argument is a file.+fn parse_args(args: impl IntoIterator<Item = String>, env_port: Result<String, std::env::VarError>) -> Result<Option<Args>, String> {+    let mut out = Args::default();+    let mut args = args.into_iter();+    let mut options = true;+    while let Some(a) = args.next() {+        if !options {+            out.files.push(a);+            continue;+        }+        match a.as_str() {+            "--" => options = false,+            "--control" => out.control_port = Some(control_port("--control", args.next().as_deref())?),+            "--demo" => out.demo = true,+            "--empty" => out.demo = false,+            "--home" => out.home = Some(true),+            "--version" => return Ok(None),+            _ => match a.strip_prefix("--control=") {+                Some(v) => out.control_port = Some(control_port("--control", Some(v))?),+                // macOS LaunchServices may pass a process serial number (`-psn_0_1234`), notably on+                // the first launch of a downloaded app; it is not an option of ours.+                None if a.starts_with("-psn_") => {}+                None if a.starts_with('-') && a != "-" => return Err(format!("unknown option `{a}`")),+                None => out.files.push(a),+            },+        }+    }+    if out.control_port.is_none() {+        out.control_port = match env_port {+            Ok(v) if v.is_empty() => None,+            Ok(v) => Some(control_port("EFFECTCRAFT_CONTROL_PORT", Some(&v))?),+            Err(std::env::VarError::NotPresent) => None,+            Err(std::env::VarError::NotUnicode(v)) => Some(control_port("EFFECTCRAFT_CONTROL_PORT", Some(&v.to_string_lossy()))?),+        };+    }+    Ok(Some(out))+}++/// A control-channel port from 1 to 65535 (0 would listen on a port nobody is told); `name` is+/// the flag or variable it came from.+fn control_port(name: &str, value: Option<&str>) -> Result<u16, String> {+    let need = format!("{name} needs a port number from 1 to 65535");+    match value {+        None => Err(need),+        Some(v) => v.parse().ok().filter(|&p| p != 0).ok_or(format!("{need}, not `{v}`")),+    }+}++/// Open the window and run the app until its window closes. The desktop binary calls this+/// from `main`; Android's NativeActivity entry point calls [`run_android`] from `android_main`+/// (which has no arguments, so an empty Untitled Project opens).+pub fn run() -> eframe::Result {+    #[cfg(target_os = "android")]+    {+        run_inner(None, None)+    }+    #[cfg(not(target_os = "android"))]+    {+        run_inner()+    }+}++/// Android's "All files access" capabilities (`MANAGE_EXTERNAL_STORAGE`), built by the entry+/// crate — which owns the JNI to the activity's JVM, since this workspace forbids `unsafe` —+/// and handed to the in-app file browser as hooks: whether shared storage may be browsed, and+/// how to ask for the grant. `None` when the entry crate could not reach the JVM (the browser+/// then only ever offers the app-private root).+#[cfg(target_os = "android")]+pub struct SharedStorageAccess {+    /// Whether the grant holds right now (re-checked whenever the browser shows the root).+    pub granted: Box<dyn Fn() -> bool>,+    /// Ask for it: the system settings page on Android 11+, the permission dialog before that.+    pub ask: Box<dyn Fn()>,+}++/// Android entry point: like [`run`], but with the activity's handle, which eframe's event+/// loop cannot be created without — leaving it out ends the activity as soon as it starts —+/// and with the optional shared-storage capabilities [`SharedStorageAccess`] the entry crate+/// resolves through JNI.+#[cfg(target_os = "android")]+pub fn run_android(android_app: android_activity::AndroidApp, access: Option<SharedStorageAccess>) -> eframe::Result {+    run_inner(Some(android_app), access)+}++/// Shared startup; `android_app` is the NativeActivity handle eframe needs on Android.+fn run_inner(+    #[cfg(target_os = "android")] android_app: Option<android_activity::AndroidApp>,+    #[cfg(target_os = "android")] access: Option<SharedStorageAccess>,+) -> eframe::Result {+    // First, so the panic hook and every start-up warning are recorded. Records go to stderr+    // and the session log; Help ▸ Enable Logging writes through this logger too, and warnings+    // feed the compatibility report.+    effectcraft_engine::logging::install();+    effectcraft_engine::logging::install_panic_hook();+    let Args { control_port, files, demo, home } = match parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT")) {+        Ok(Some(args)) => args,+        Ok(None) => {+            println!("effectcraft {}", env!("CARGO_PKG_VERSION"));+            return Ok(());+        }+        // Before the session log, preferences and the window: nothing has started yet.+        Err(e) => {+            eprintln!("effectcraft: {e}");+            std::process::exit(1);+        }+    };+    // The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs+    // kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so+    // `--version` leaves no file behind; records logged until now are written to it first.+    if let Some(dir) = effectcraft_host::config_dir() {+        match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) {+            Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),+            Err(e) => log::warn!("no log file: {e}"),+        }+    }+    // The font menus list the installed fonts: read their names while the window opens.+    effectcraft_engine::text::fonts::scan_system_in_background();+    // Wayland shows the window's icon from its desktop entry: an AppImage brings its own.+    #[cfg(target_os = "linux")]+    appimage::integrate_from_env(ICON_PNG);+    // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch+    // stopped as its window opened (a crashing graphics driver, #243).+    let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some());+    let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL);+    let notice = launch.notice.take();+    // eframe cannot create its event loop on Android without the activity's handle; without+    // it `run_native` fails before the window exists and the activity ends on launch.+    // The handle also resolves the app-private storage below (saves, exports, settings).+    #[cfg(target_os = "android")]+    let android_files: Option<std::path::PathBuf> = android_app.as_ref().and_then(|app| {+        let dir = app.internal_data_path();+        if dir.is_none() {+            log::warn!("android storage: no internal data path; save and open stay unavailable");+        }+        dir+    });+    // Shared storage (`/storage/emulated/0`) is offered as a second browse root beside the+    // app-private directory, so saves/exports/autosaves can land outside the app dir. Reaching it+    // needs the user to grant "All files access" in system settings, which the browser asks for+    // (and the app can only detect, never grant silently).+    #[cfg(target_os = "android")]+    let android_shared: Option<std::path::PathBuf> = android_app.as_ref().map(|_| std::path::PathBuf::from("/storage/emulated/0"));+    let options = eframe::NativeOptions {+        viewport: egui::ViewportBuilder::default()+            .with_title("EffectCraft")+            .with_inner_size(window::DEFAULT_SIZE)+            .with_min_inner_size(window::MIN_SIZE)+            .with_drag_and_drop(true)+            // Started for an agent (`--control`): open without taking the user's keyboard focus.+            .with_active(control_port.is_none())+            .with_icon(app_icon())+            // Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID.+            .with_app_id(appimage::APP_ID),+        event_loop_builder: agent_event_loop(control_port.is_some()),+        wgpu_options: wgpu_options(launch.backends),+        // The activity's handle resolved above (Android only; the event loop needs it).+        #[cfg(target_os = "android")]+        android_app,+        ..Default::default()+    };+    // Start-up milestones go to stderr and the session log, so a window that never appears+    // shows how far start-up got (#234).+    log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION"));+    // Registered before the event loop starts, so it catches the Finder event that launched us as+    // well as later ones. Lives until the event loop returns; the app creator only borrows it.+    #[cfg(target_os = "macos")]+    let apple_events = apple_events::AppleEvents::install();+    #[cfg(target_os = "macos")]+    let apple_events = &apple_events;+    let result = eframe::run_native(+        "EffectCraft",+        options,+        Box::new(move |cc| {+            match &cc.wgpu_render_state {+                Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()),+                None => log::info!("graphics: no wgpu device"),+            }+            // This executable owns the shared device's handlers. Install them before+            // EffectCraft's compositor pipelines are built, without changing handlers from+            // inside Gpu::new. eframe has already constructed its presentation renderer here.+            let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx);+            if let Some(rs) = &cc.wgpu_render_state {+                let errors = gpu_failures.clone();+                rs.device.on_uncaptured_error(std::sync::Arc::new(move |error| {+                    let message = format!("uncaptured GPU error: {error}");+                    if errors.report(&message, false) {+                        log::error!("{message}");+                    }+                }));+                let lost = gpu_failures.clone();+                rs.device.set_device_lost_callback(move |reason, message| {+                    lost.report(&format!("GPU device lost ({reason:?}): {message}"), true);+                });+            }+            let mut session = effectcraft_host::session();+            // Lazy open and non-blocking auto-save: footage is checked and auto-saves are+            // written on background threads (M13.14).+            session.check_footage_on_open = true;+            session.autosave.background = true;+            // Settings, shortcut presets and the crash-recovery sentinel live in the platform+            // config directory. Agent-driven runs (`--control`) skip crash recovery.+            if let Some(dir) = effectcraft_host::config_dir() {+                // WebAssembly effect plug-ins in <config>/Plug-ins load before the menus are built.+                let plugins = dir.join("Plug-ins");+                if plugins.is_dir() {+                    let _ = session.execute("effect.plugins.load", json!({"folder": plugins.to_string_lossy()}));+                }+                session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));+            }+            // Android has no platform config directory: settings, auto-save and crash recovery+            // live in app-private storage instead (quietly off when it is unavailable).+            #[cfg(target_os = "android")]+            let android_layout = android_files.as_deref().map(android_storage::layout);+            #[cfg(target_os = "android")]+            if let Some(l) = &android_layout {+                if let Err(e) = android_storage::ensure(l) {+                    log::warn!("android storage: {e}");+                }+                session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(l.config.clone())));+            }+            session.load_settings();+            // Fit the window to its monitor, or put it back where it was (still hidden).+            let saved_window = session.config.as_deref().and_then(window::load);+            let opened = cc.winit_window().map(|w| window::restore(w, saved_window));+            // Render output defaults to app-private Exports on Android: the working directory is+            // `/` there and nothing else in the fallback chain resolves to somewhere writable.+            #[cfg(target_os = "android")]+            if let Some(l) = &android_layout+                && session.prefs.export.default_output_folder.trim().is_empty()+            {+                session.prefs.export.default_output_folder = l.exports.to_string_lossy().to_string();+            }+            let recovery = if control_port.is_none() { session.begin_recovery() } else { None };+            let project = files.iter().find(|f| is_project_file(f)).cloned();+            if let Some(p) = project {+                if let Err(e) = session.execute("file.open", json!({"path": p})) {+                    log::warn!("{p}: {e}");+                }+            } else if demo {+                let _ = session.execute("file.openDemoProject", json!({}));+            }+            let media: Vec<String> = files.iter().filter(|f| !is_project_file(f)).cloned().collect();+            if !media.is_empty()+                && let Err(e) = session.execute("file.import", json!({"paths": media}))+            {+                log::warn!("import: {e}");+            }+            let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());+            // The ScriptUI panels open when the app last quit open again (a failing one is+            // reported in a toast and forgotten).+            let _ = session.execute("window.restoreScriptPanels", json!({}));+            let mut app = EffectcraftApp::new(session);+            app.set_gpu_failure_bridge(gpu_failures);+            app.ui.start_screen = show_home;+            if let Some(n) = notice {+                app.ui.status = n;+            }+            if let Some(r) = recovery {+                app.offer_recovery(r);+            }+            #[cfg(target_os = "linux")]+            if let Some(note) = wayland_drop_notice(std::env::var_os("WAYLAND_DISPLAY").as_deref(), std::env::var_os("WINIT_UNIX_BACKEND").as_deref())+                && app.ui.status.is_empty()+            {+                app.ui.status = note.into();+            }+            // No file dialogs on Android (rfd has no backend there): the hooks stay empty and+            // the commands fail with "no file dialog available".+            #[cfg(not(target_os = "android"))]+            {+                app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {+                    rfd::FileDialog::new()+                        .add_filter("Media", exts)+                        .pick_files()+                        .unwrap_or_default()+                        .into_iter()+                        .map(|p| p.to_string_lossy().to_string())+                        .collect()+                }));+                app.hooks.pick_save = Some(Box::new(|name: &str| {+                    rfd::FileDialog::new()+                        .add_filter("EffectCraft Project", &["ecproj"])+                        .set_file_name(name)+                        .save_file()+                        .map(|p| p.to_string_lossy().to_string())+                }));+                // The window closes after a lost graphics device (#591); say why, unless an agent drives+                // the app through the control channel. Android has no message dialogs either.+                if control_port.is_none() {+                    app.hooks.device_lost = Some(Box::new(|saved: Option<Result<String, String>>| {+                        let saved = match saved {+                            Some(Ok(path)) => format!("\n\nYour unsaved changes were saved to:\n{path}\n\nReopen EffectCraft to recover them."),+                            Some(Err(e)) => format!("\n\nYour unsaved changes could not be saved ({e})."),+                            None => String::new(),+                        };+                        rfd::MessageDialog::new()+                            .set_level(rfd::MessageLevel::Error)+                            .set_title("EffectCraft: graphics device lost")+                            .set_description(format!("The graphics driver stopped responding, so EffectCraft has to close.{saved}"))+                            .show();+                    }));+                }+                app.hooks.pick_open_project = Some(Box::new(|| {+                    rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string())+                }));+                app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));+                app.hooks.pick_save_file = Some(Box::new(|name: &str, ext: &str| {+                    // A default with a folder that exists opens the dialog there.+                    let path = std::path::Path::new(name);+                    // (No filter for a name without an extension, such as a folder to create.)+                    let mut d = if ext.is_empty() { rfd::FileDialog::new() } else { rfd::FileDialog::new().add_filter(ext, &[ext]) };+                    if let Some(dir) = path.parent().filter(|d| d.is_dir()) {+                        d = d.set_directory(dir);+                    }+                    let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+                    d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string())+                }));+            }+            app.hooks.system_theme = appearance::service();+            app.hooks.audio_device = Some(Box::new(audio_out::open));+            app.hooks.audio_devices = Some(Box::new(audio_out::devices));+            // The in-app Save / Open Project prompts read and write here (nothing when the+            // storage directory was unavailable: the commands keep their "no dialog" error).+            // Shared storage is a second browse root, gated on the "All files access" grant.+            #[cfg(target_os = "android")]+            {+                if let Some(base) = android_files {+                    app.hooks.app_files_dir = Some(base);+                }+                if let Some(a) = access {+                    // The shared root joins the grant hooks: with them absent (Android 10's+                    // decline in `capture`, or a JVM the app could not reach) the browser+                    // would list a root it cannot read inside, next to a grant prompt+                    // nothing answers.+                    if let Some(root) = android_shared {+                        app.hooks.shared_storage_root = Some(root);+                    }+                    app.hooks.shared_storage_granted = Some(a.granted);+                    app.hooks.shared_storage_grant = Some(a.ask);+                }+            }+            // The commands' file dialogs don't block the frame where the platform's backend+            // would (#511, #1275): on macOS as sheets instead of nested modal loops, on Linux+            // through the desktop portal from a worker thread — a blocking portal dialog leaves+            // the main thread waiting on its answer without pumping events, so the window stops+            // answering the compositor and the desktop offers to force-quit it while the Open+            // dialog is up. Windows' blocking dialog pumps its own messages; Android has no+            // rfd at all (the in-app browser serves the dialogs).+            #[cfg(any(target_os = "macos", target_os = "linux"))]+            {+                let ctx = cc.egui_ctx.clone();+                app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));+            }+            #[cfg(target_os = "macos")]+            {+                app.hooks.app_action = Some(Box::new(native_menu::app_action));+                app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text));+            }+            if let Some(port) = control_port {+                disable_app_nap();+                let rx = control_server::start(port, cc.egui_ctx.clone());+                app = app.with_control(rx);+            }+            log::info!("app created; the window shows after its first frame");+            Ok(Box::new(Desktop {+                #[cfg(target_os = "macos")]+                menu: native_menu::NativeBar::new(&cc.egui_ctx),+                #[cfg(target_os = "macos")]+                apple_events: apple_events.connect(&cc.egui_ctx),+                app,+                window: window::Tracker::new(opened, saved_window, control_port.is_none()),+                launch,+                drawn: false,+            }))+        }),+    );+    match &result {+        Ok(()) => log::info!("the window closed"),+        Err(e) => {+            if on_gl {+                launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());+            }+            startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));+        }+    }+    result+}++/// The window's wgpu device asks for the adapter's own limits (textures up to 16384 px)+/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500,+/// #198) still opens the app, and the compositor then checks what it needs and falls back to+/// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one.+fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfiguration {+    use eframe::egui_wgpu::WgpuSetup;+    let mut config = eframe::WgpuConfiguration::default();+    if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {+        if let Some(b) = backends {+            setup.instance_descriptor.backends = b;+        }+        // Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs+        // a compute shader when the device is created, one that Metal can't compile on an Intel+        // Iris Pro: the device is lost and the window never opens.+        // `WGPU_VALIDATION_INDIRECT_CALL=1` turns the check back on.+        setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();+        setup.device_descriptor = std::sync::Arc::new(|adapter| device_descriptor(adapter.limits()));+    }+    config+}++/// The window device's descriptor: the adapter's limits ([`device_limits`]) and `MemoryUsage`,+/// so memory the compositor frees on the shared device goes back to the driver.+fn device_descriptor(adapter: eframe::wgpu::Limits) -> eframe::wgpu::DeviceDescriptor<'static> {+    eframe::wgpu::DeviceDescriptor {+        label: Some("EffectCraft device"),+        required_limits: device_limits(adapter),+        memory_hints: eframe::wgpu::MemoryHints::MemoryUsage,+        ..Default::default()+    }+}++/// The limits to request from an adapter that offers `adapter`: all of them, with textures+/// capped at 16384 px like the headless renderer's device.+fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits {+    eframe::wgpu::Limits { max_texture_dimension_2d: adapter.max_texture_dimension_2d.min(16384), ..adapter }+}++/// The window couldn't open (no usable graphics adapter or device): say so instead of quitting+/// silently, since release builds on Windows have no console. `gl_next`: the launch guard has+/// the next launch try OpenGL. Android has no message dialogs; the message is logged (stderr+/// reaches logcat) and the activity's thread ends.+fn startup_failed(e: &eframe::Error, gl_next: bool) {+    let next = if gl_next { "The next launch will try OpenGL instead. " } else { "" };+    let message = format!(+        "EffectCraft couldn't start its window: {e}\n\n{next}Updating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."+    );+    // Standard error and the session log; standard error alone when RUST_LOG turned errors off.+    if log::log_enabled!(log::Level::Error) {+        log::error!("{message}");+    } else {+        eprintln!("effectcraft: {message}");+    }+    // No message dialog on Android (rfd has no backend there): stderr already carries the+    // message into logcat, and the activity's thread ends.+    #[cfg(not(target_os = "android"))]+    let _ = rfd::MessageDialog::new()+        .set_level(rfd::MessageLevel::Error)+        .set_title("EffectCraft")+        .set_description(message)+        .set_buttons(rfd::MessageButtons::Ok)+        .show();+}++/// The desktop app: the egui app plus, on macOS, the system menu bar.+struct Desktop {+    app: EffectcraftApp,+    #[cfg(target_os = "macos")]+    menu: native_menu::NativeBar,+    /// Documents opened from Finder, the Dock or `open` (macOS).+    #[cfg(target_os = "macos")]+    apple_events: apple_events::Opened,+    /// The window's size and position, saved for the next launch.+    window: window::Tracker,+    launch: launch_guard::Launch,+    /// The first frame was drawn (logged once).+    drawn: bool,+}++impl eframe::App for Desktop {+    fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {+        #[cfg(target_os = "macos")]+        self.menu.update(&mut self.app, ctx);+        self.app.logic(ctx, frame);+    }++    fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {+        self.app.ui(ui, frame);+        self.window.frame(ui.ctx(), self.app.session.config.as_deref());+        if !self.drawn {+            self.drawn = true;+            log::info!("first frame drawn");+        }+        // The launch guard's marker goes once the window has kept drawing for a few seconds: a+        // driver may still crash as the window is shown and its surface re-created.+        if let Some(left) = self.launch.frame_drawn() {+            ui.ctx().request_repaint_after(left);+        }+    }++    fn on_exit(&mut self) {+        self.launch.exited();+        self.window.save(self.app.session.config.as_deref());+        self.app.on_exit();+    }++    fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {+        #[cfg(target_os = "macos")]+        self.apple_events.feed(self.app.is_ready(), ctx, raw_input);+        self.app.raw_input_hook(ctx, raw_input);+    }+}++/// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard+/// focus stays where it is (macOS; winit activates ignoring other apps by default).+fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {+    #[cfg(target_os = "macos")]+    if agent {+        return Some(Box::new(|b| {+            use winit::platform::macos::EventLoopBuilderExtMacOS;+            b.with_activate_ignoring_other_apps(false);+        }));+    }+    let _ = agent;+    None+}++/// Agents drive the app while its window is covered or on another Space. macOS App Nap would+/// throttle the event loop then, stalling the control channel; opt out for `--control` runs.+fn disable_app_nap() {+    #[cfg(target_os = "macos")]+    {+        use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString};+        let info = NSProcessInfo::processInfo();+        let reason = NSString::from_str("EffectCraft control channel");+        let opts = NSActivityOptions::UserInitiatedAllowingIdleSystemSleep | NSActivityOptions::LatencyCritical;+        // Leaked on purpose: the activity lasts for the life of the process.+        std::mem::forget(info.beginActivityWithOptions_reason(opts, &reason));+    }+}++#[cfg(test)]+mod tests {+    use super::{Args, parse_args, sheet_dialogs};+    use effectcraft_ui_egui::{DialogDone, FileDialog};+    use std::env::VarError;++    fn parse_env(args: &[&str], env_port: Result<String, VarError>) -> Result<Option<Args>, String> {+        parse_args(args.iter().map(|a| a.to_string()), env_port)+    }++    fn parse(args: &[&str]) -> Option<Args> {+        parse_env(args, Err(VarError::NotPresent)).unwrap()+    }++    fn fails(args: &[&str]) -> String {+        parse_env(args, Err(VarError::NotPresent)).unwrap_err()+    }++    #[cfg(target_os = "linux")]+    #[test]+    fn wayland_drop_notice_recommends_import_or_xwayland() {+        use std::ffi::OsStr;+        let warning = super::wayland_drop_notice(Some(OsStr::new("wayland-0")), None).unwrap();+        assert!(warning.contains("File ▸ Import") && warning.contains("XWayland"));+        assert!(super::wayland_drop_notice(None, None).is_none());+        assert!(super::wayland_drop_notice(Some(OsStr::new("")), None).is_none());+        assert!(super::wayland_drop_notice(Some(OsStr::new("wayland-0")), Some(OsStr::new("x11"))).is_none());+    }++    /// The window's wgpu instance skips indirect-call validation (Intel Metal, #1153).+    #[test]+    fn the_window_instance_skips_indirect_call_validation() {+        if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_some() {+            return;+        }+        let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = super::wgpu_options(None).wgpu_setup else {+            panic!("the default setup creates its own instance");+        };+        // Intel Metal can't compile the validation's compute shader, and the device is lost.+        assert!(!setup.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));+    }++    /// The window's device never asks for more than the adapter offers (#198).+    #[test]+    fn device_limits_fit_the_adapter() {+        use eframe::wgpu::Limits;+        for adapter in [Limits::downlevel_defaults(), Limits::downlevel_webgl2_defaults(), Limits::default()] {+            let l = super::device_limits(adapter.clone());+            assert!(l.check_limits(&adapter), "{l:?}");+            assert_eq!(l.max_storage_buffers_per_shader_stage, adapter.max_storage_buffers_per_shader_stage);+        }+        let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };+        assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);+    }++    /// The window device asks for `MemoryUsage`: the adapter's limits, unchanged.+    #[test]+    fn the_window_device_favours_memory_usage() {+        use eframe::wgpu::Limits;+        let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };+        let d = super::device_descriptor(big);+        assert!(matches!(d.memory_hints, eframe::wgpu::MemoryHints::MemoryUsage));+        assert_eq!(d.required_limits.max_texture_dimension_2d, 16384);+        assert_eq!(d.label, Some("EffectCraft device"));+    }++    /// A positional .ecprojx is a project file, not an unsupported media import (#528).+    #[test]+    fn launch_classifies_xml_and_json_projects_as_projects() {+        for path in ["Copy.ecprojx", "/tmp/Scene.ecproj", "C:\\\\work\\\\Shot.ECPROJX"] {+            assert!(super::is_project_file(path), "{path}");+        }+        for path in ["scene.ecprojx.png", "scene.mp4", "scene", "copy.ecprojx.bak"] {+            assert!(!super::is_project_file(path), "{path}");+        }+        let files = ["scene.ecprojx".to_string(), "clip.mov".to_string()];+        assert_eq!(files.iter().find(|f| super::is_project_file(f)).map(String::as_str), Some("scene.ecprojx"));+        assert_eq!(files.iter().filter(|f| !super::is_project_file(f)).map(String::as_str).collect::<Vec<_>>(), vec!["clip.mov"],);+    }++    /// Launching without a project opens an empty project, not the demo (#204).+    #[test]+    fn no_arguments_start_an_empty_project() {+        assert_eq!(parse(&[]), Some(Args::default()));+        assert!(parse(&["--demo"]).is_some_and(|a| a.demo));+        assert!(parse(&["--demo", "--empty"]).is_some_and(|a| !a.demo));+        let a = parse(&["--control", "9877", "--home", "a.ecproj"]).unwrap_or_default();+        assert_eq!((a.control_port, a.home, a.files), (Some(9877), Some(true), vec!["a.ecproj".to_string()]));+        assert_eq!(parse(&["--version"]), None);+        assert_eq!(parse_env(&[], Ok("9000".into())).unwrap().map(|a| a.control_port), Some(Some(9000)));+    }++    /// A missing, non-numeric or zero `--control` value stops the launch instead of starting+    /// without a control server, and the argument after `--control` isn't reinterpreted (#997).+    #[test]+    fn control_needs_a_port_from_1_to_65535() {+        let need = "--control needs a port number from 1 to 65535";+        assert_eq!(fails(&["--control"]), need);+        assert_eq!(fails(&["--control", "--demo"]), format!("{need}, not `--demo`"));+        assert_eq!(fails(&["--control", "--version"]), format!("{need}, not `--version`"));+        for bad in ["abc", "0", "70000", "-1", ""] {+            assert_eq!(fails(&["--control", bad]), format!("{need}, not `{bad}`"));+            assert_eq!(fails(&[&format!("--control={bad}")]), format!("{need}, not `{bad}`"));+        }+        assert_eq!(parse(&["--control=9877"]).map(|a| a.control_port), Some(Some(9877)));+        assert_eq!(parse(&["--control", "65535"]).map(|a| a.control_port), Some(Some(65535)));+    }++    /// An unknown option is an error, not a file to import (#997); `--` ends the options.+    #[test]+    fn unknown_options_are_errors_not_files() {+        for bad in ["--automation-read-root", "--automation-write-root=/tmp", "-h", "--Demo", "--demo=yes"] {+            assert_eq!(fails(&[bad, "/tmp/folder"]), format!("unknown option `{bad}`"));+        }+        // Parsed left to right: an error before `--version` wins.+        assert_eq!(fails(&["--nope", "--version"]), "unknown option `--nope`");+        // Finder's process serial number is ignored, not imported or refused.+        assert_eq!(parse(&["-psn_0_12345"]).map(|a| a.files), Some(vec![]));+        let a = parse(&["clip.mov", "-", "--", "--demo", "-name.png"]).unwrap_or_default();+        assert_eq!(a.files, ["clip.mov", "-", "--demo", "-name.png"]);+        assert!(!a.demo);+    }++    /// `--control` wins over `EFFECTCRAFT_CONTROL_PORT`; the variable is checked only when it is+    /// used, so a bad value doesn't stop `--control` or `--version`.+    #[test]+    fn control_port_variable_is_checked_when_used() {+        let need = "EFFECTCRAFT_CONTROL_PORT needs a port number from 1 to 65535";+        assert_eq!(parse_env(&[], Ok("abc".into())).unwrap_err(), format!("{need}, not `abc`"));+        assert_eq!(parse_env(&[], Ok("0".into())).unwrap_err(), format!("{need}, not `0`"));+        assert_eq!(parse_env(&[], Ok(String::new())), Ok(Some(Args::default())));+        assert_eq!(parse_env(&["--control", "9877"], Ok("abc".into())).unwrap().map(|a| a.control_port), Some(Some(9877)));+        assert_eq!(parse_env(&["--version"], Ok("abc".into())), Ok(None));+        #[cfg(unix)]+        {+            use std::os::unix::ffi::OsStringExt;+            let not_utf8 = VarError::NotUnicode(std::ffi::OsString::from_vec(vec![0x39, 0xff]));+            assert_eq!(parse_env(&[], Err(not_utf8)).unwrap_err(), format!("{need}, not `9\u{fffd}`"));+        }+    }++    /// #1275: a blocking desktop-portal dialog on Linux leaves the window not answering the+    /// compositor while it is open, so the commands' file dialogs go through the non-blocking+    /// host hook there (as they do on macOS, #511). Windows' dialog pumps its own messages.+    #[test]+    fn commands_file_dialogs_go_through_the_non_blocking_hook() {+        let mut app = crate::EffectcraftApp::new(effectcraft_engine::Session::default());+        #[cfg(any(target_os = "macos", target_os = "linux"))]+        {+            let ctx = egui::Context::default();+            app.hooks.file_dialog_async = Some(Box::new(move |dialog: FileDialog, done: DialogDone| sheet_dialogs::show(dialog, done, ctx.clone())));+        }+        #[cfg(any(target_os = "macos", target_os = "linux"))]+        assert!(app.hooks.file_dialog_async.is_some(), "the commands' file dialogs must not block the frame");+        #[cfg(not(any(target_os = "macos", target_os = "linux")))]+        assert!(app.hooks.file_dialog_async.is_none(), "the blocking dialog pumps its own messages here");+    }+}diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs--- a/apps/effectcraft/src/main.rs+++ b/apps/effectcraft/src/main.rs@@ -1,647 +1,7 @@-//! EffectCraft desktop app.-//!-//! Usage: `effectcraft [--control <port>] [--demo|--empty|--home] [--] [project.ecproj | media files…]`-//!-//! An unknown option or a bad port prints an error and exits with status 1 before anything starts.-//!-//! Without a project the app starts with an empty Untitled Project, as After Effects does;-//! `--demo` opens the demo project instead (also Help ▸ Open Demo Project).-//!-//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;-//! see `effectcraft_ui_egui::control` for the methods.+//! EffectCraft desktop app binary. The app itself is the library (`lib.rs`, [`effectcraft::run`]);+//! Android's NativeActivity entry point is the `effectcraft-android` crate. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]-#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]--// Built everywhere so its tests run on every platform; only Linux AppImages use it.-mod appearance;-#[cfg_attr(not(target_os = "linux"), allow(dead_code))]-mod appimage;-#[cfg(target_os = "macos")]-mod apple_events;-mod audio_out;-mod control_server;-mod launch_guard;-#[cfg(target_os = "macos")]-mod native_menu;-// Built everywhere so it stays checked; the macOS and Linux apps use it.-#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]-mod sheet_dialogs;-mod window;--use effectcraft_ui_egui::EffectcraftApp;-use serde_json::json;--/// The app icon for the window, taskbar and Dock (assets/app-icon, regenerated by-/// `packaging/icons.sh`). macOS gets the padded variant so it sits on Apple's icon grid in the Dock.-/// The 256 px app icon (Windows, Linux; the AppImage's desktop integration installs it too).-#[cfg(not(target_os = "macos"))]-const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png");--fn app_icon() -> egui::IconData {-    #[cfg(target_os = "macos")]-    let png: &[u8] = include_bytes!("../../../assets/app-icon/effectcraft-macos-512.png");-    #[cfg(not(target_os = "macos"))]-    let png: &[u8] = ICON_PNG;-    eframe::icon_data::from_png_bytes(png).unwrap_or_default()-}--/// Both native JSON projects and Save a Copy As XML projects are openable on launch.-/// Keep this classification shared by project opening and the subsequent media-import pass.-fn is_project_file(path: &str) -> bool {-    std::path::Path::new(path)-        .extension()-        .and_then(|ext| ext.to_str())-        .is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx"))-}--/// winit 0.30 cannot deliver native Wayland file drops. XWayland can.-#[cfg(target_os = "linux")]-fn wayland_drop_notice(wayland_display: Option<&std::ffi::OsStr>, backend: Option<&std::ffi::OsStr>) -> Option<&'static str> {-    (wayland_display.is_some_and(|v| !v.is_empty()) && backend != Some(std::ffi::OsStr::new("x11")))-        .then_some("Native Wayland file drops are unavailable. Use File ▸ Import ▸ File… or run via XWayland (see README, Linux).")-}--/// The command line.-#[derive(Debug, Default, PartialEq)]-struct Args {-    control_port: Option<u16>,-    files: Vec<String>,-    /// Open the demo project when no project is given (`--demo`).-    demo: bool,-    /// Show or skip the Home screen (`--home`), else the Startup preference decides.-    home: Option<bool>,-}--/// Parse the arguments after the program name; `Ok(None)` for `--version`. `env_port` is-/// `EFFECTCRAFT_CONTROL_PORT`, used when `--control` isn't given. An unknown option or a bad port-/// is an error, so it is neither imported as a file nor dropped; after `--` every argument is a file.-fn parse_args(args: impl IntoIterator<Item = String>, env_port: Result<String, std::env::VarError>) -> Result<Option<Args>, String> {-    let mut out = Args::default();-    let mut args = args.into_iter();-    let mut options = true;-    while let Some(a) = args.next() {-        if !options {-            out.files.push(a);-            continue;-        }-        match a.as_str() {-            "--" => options = false,-            "--control" => out.control_port = Some(control_port("--control", args.next().as_deref())?),-            "--demo" => out.demo = true,-            "--empty" => out.demo = false,-            "--home" => out.home = Some(true),-            "--version" => return Ok(None),-            _ => match a.strip_prefix("--control=") {-                Some(v) => out.control_port = Some(control_port("--control", Some(v))?),-                // macOS LaunchServices may pass a process serial number (`-psn_0_1234`), notably on-                // the first launch of a downloaded app; it is not an option of ours.-                None if a.starts_with("-psn_") => {}-                None if a.starts_with('-') && a != "-" => return Err(format!("unknown option `{a}`")),-                None => out.files.push(a),-            },-        }-    }-    if out.control_port.is_none() {-        out.control_port = match env_port {-            Ok(v) if v.is_empty() => None,-            Ok(v) => Some(control_port("EFFECTCRAFT_CONTROL_PORT", Some(&v))?),-            Err(std::env::VarError::NotPresent) => None,-            Err(std::env::VarError::NotUnicode(v)) => Some(control_port("EFFECTCRAFT_CONTROL_PORT", Some(&v.to_string_lossy()))?),-        };-    }-    Ok(Some(out))-}--/// A control-channel port from 1 to 65535 (0 would listen on a port nobody is told); `name` is-/// the flag or variable it came from.-fn control_port(name: &str, value: Option<&str>) -> Result<u16, String> {-    let need = format!("{name} needs a port number from 1 to 65535");-    match value {-        None => Err(need),-        Some(v) => v.parse().ok().filter(|&p| p != 0).ok_or(format!("{need}, not `{v}`")),-    }-}--/// The commands' file dialogs open without blocking the frame where the host's backend would:-/// on macOS as sheets instead of nested modal loops (#511), on Linux through the desktop-/// portal from a worker thread — a blocking portal dialog leaves the main thread waiting on its-/// answer without pumping events, so the window stops answering the compositor and the desktop-/// offers to force-quit it while the Open dialog is up (#1275). Windows' blocking dialog pumps-/// its own messages, so it keeps [`effectcraft_ui_egui::Hooks::pick_files`] and friends.-fn install_file_dialogs(app: &mut EffectcraftApp, ctx: &egui::Context) {-    #[cfg(any(target_os = "macos", target_os = "linux"))]-    {-        let ctx = ctx.clone();-        app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));-    }-    #[cfg(not(any(target_os = "macos", target_os = "linux")))]-    let _ = (app, ctx);-}  fn main() -> eframe::Result {-    // First, so the panic hook and every start-up warning are recorded. Records go to stderr-    // and the session log; Help ▸ Enable Logging writes through this logger too, and warnings-    // feed the compatibility report.-    effectcraft_engine::logging::install();-    effectcraft_engine::logging::install_panic_hook();-    let Args { control_port, files, demo, home } = match parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT")) {-        Ok(Some(args)) => args,-        Ok(None) => {-            println!("effectcraft {}", env!("CARGO_PKG_VERSION"));-            return Ok(());-        }-        // Before the session log, preferences and the window: nothing has started yet.-        Err(e) => {-            eprintln!("effectcraft: {e}");-            std::process::exit(1);-        }-    };-    // The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs-    // kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so-    // `--version` leaves no file behind; records logged until now are written to it first.-    if let Some(dir) = effectcraft_host::config_dir() {-        match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) {-            Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),-            Err(e) => log::warn!("no log file: {e}"),-        }-    }-    // The font menus list the installed fonts: read their names while the window opens.-    effectcraft_engine::text::fonts::scan_system_in_background();-    // Wayland shows the window's icon from its desktop entry: an AppImage brings its own.-    #[cfg(target_os = "linux")]-    appimage::integrate_from_env(ICON_PNG);-    // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch-    // stopped as its window opened (a crashing graphics driver, #243).-    let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some());-    let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL);-    let notice = launch.notice.take();-    let options = eframe::NativeOptions {-        viewport: egui::ViewportBuilder::default()-            .with_title("EffectCraft")-            .with_inner_size(window::DEFAULT_SIZE)-            .with_min_inner_size(window::MIN_SIZE)-            .with_drag_and_drop(true)-            // Started for an agent (`--control`): open without taking the user's keyboard focus.-            .with_active(control_port.is_none())-            .with_icon(app_icon())-            // Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID.-            .with_app_id(appimage::APP_ID),-        event_loop_builder: agent_event_loop(control_port.is_some()),-        wgpu_options: wgpu_options(launch.backends),-        ..Default::default()-    };-    // Start-up milestones go to stderr and the session log, so a window that never appears-    // shows how far start-up got (#234).-    log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION"));-    // Registered before the event loop starts, so it catches the Finder event that launched us as-    // well as later ones. Lives until the event loop returns; the app creator only borrows it.-    #[cfg(target_os = "macos")]-    let apple_events = apple_events::AppleEvents::install();-    #[cfg(target_os = "macos")]-    let apple_events = &apple_events;-    let result = eframe::run_native(-        "EffectCraft",-        options,-        Box::new(move |cc| {-            match &cc.wgpu_render_state {-                Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()),-                None => log::info!("graphics: no wgpu device"),-            }-            // This executable owns the shared device's handlers. Install them before-            // EffectCraft's compositor pipelines are built, without changing handlers from-            // inside Gpu::new. eframe has already constructed its presentation renderer here.-            let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx);-            if let Some(rs) = &cc.wgpu_render_state {-                let errors = gpu_failures.clone();-                rs.device.on_uncaptured_error(std::sync::Arc::new(move |error| {-                    let message = format!("uncaptured GPU error: {error}");-                    if errors.report(&message, false) {-                        log::error!("{message}");-                    }-                }));-                let lost = gpu_failures.clone();-                rs.device.set_device_lost_callback(move |reason, message| {-                    lost.report(&format!("GPU device lost ({reason:?}): {message}"), true);-                });-            }-            let mut session = effectcraft_host::session();-            // Lazy open and non-blocking auto-save: footage is checked and auto-saves are-            // written on background threads (M13.14).-            session.check_footage_on_open = true;-            session.autosave.background = true;-            // Settings, shortcut presets and the crash-recovery sentinel live in the platform-            // config directory. Agent-driven runs (`--control`) skip crash recovery.-            if let Some(dir) = effectcraft_host::config_dir() {-                // WebAssembly effect plug-ins in <config>/Plug-ins load before the menus are built.-                let plugins = dir.join("Plug-ins");-                if plugins.is_dir() {-                    let _ = session.execute("effect.plugins.load", json!({"folder": plugins.to_string_lossy()}));-                }-                session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));-            }-            session.load_settings();-            // Fit the window to its monitor, or put it back where it was (still hidden).-            let saved_window = session.config.as_deref().and_then(window::load);-            let opened = cc.winit_window().map(|w| window::restore(w, saved_window));-            let recovery = if control_port.is_none() { session.begin_recovery() } else { None };-            let project = files.iter().find(|f| is_project_file(f)).cloned();-            if let Some(p) = project {-                if let Err(e) = session.execute("file.open", json!({"path": p})) {-                    log::warn!("{p}: {e}");-                }-            } else if demo {-                let _ = session.execute("file.openDemoProject", json!({}));-            }-            let media: Vec<String> = files.iter().filter(|f| !is_project_file(f)).cloned().collect();-            if !media.is_empty()-                && let Err(e) = session.execute("file.import", json!({"paths": media}))-            {-                log::warn!("import: {e}");-            }-            let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());-            // The ScriptUI panels open when the app last quit open again (a failing one is-            // reported in a toast and forgotten).-            let _ = session.execute("window.restoreScriptPanels", json!({}));-            let mut app = EffectcraftApp::new(session);-            app.set_gpu_failure_bridge(gpu_failures);-            app.ui.start_screen = show_home;-            if let Some(n) = notice {-                app.ui.status = n;-            }-            if let Some(r) = recovery {-                app.offer_recovery(r);-            }-            #[cfg(target_os = "linux")]-            if let Some(note) = wayland_drop_notice(std::env::var_os("WAYLAND_DISPLAY").as_deref(), std::env::var_os("WINIT_UNIX_BACKEND").as_deref())-                && app.ui.status.is_empty()-            {-                app.ui.status = note.into();-            }-            app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {-                rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()-            }));-            app.hooks.pick_save = Some(Box::new(|name: &str| {-                rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())-            }));-            // The window closes after a lost graphics device (#591); say why, unless an agent drives-            // the app through the control channel.-            if control_port.is_none() {-                app.hooks.device_lost = Some(Box::new(|saved: Option<Result<String, String>>| {-                    let saved = match saved {-                        Some(Ok(path)) => format!("\n\nYour unsaved changes were saved to:\n{path}\n\nReopen EffectCraft to recover them."),-                        Some(Err(e)) => format!("\n\nYour unsaved changes could not be saved ({e})."),-                        None => String::new(),-                    };-                    rfd::MessageDialog::new()-                        .set_level(rfd::MessageLevel::Error)-                        .set_title("EffectCraft: graphics device lost")-                        .set_description(format!("The graphics driver stopped responding, so EffectCraft has to close.{saved}"))-                        .show();-                }));-            }-            app.hooks.pick_open_project = Some(Box::new(|| {-                rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string())-            }));-            app.hooks.system_theme = appearance::service();-            app.hooks.audio_device = Some(Box::new(audio_out::open));-            app.hooks.audio_devices = Some(Box::new(audio_out::devices));-            app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));-            app.hooks.pick_save_file = Some(Box::new(|name: &str, ext: &str| {-                // A default with a folder that exists opens the dialog there.-                let path = std::path::Path::new(name);-                // (No filter for a name without an extension, such as a folder to create.)-                let mut d = if ext.is_empty() { rfd::FileDialog::new() } else { rfd::FileDialog::new().add_filter(ext, &[ext]) };-                if let Some(dir) = path.parent().filter(|d| d.is_dir()) {-                    d = d.set_directory(dir);-                }-                let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();-                d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string())-            }));-            // The commands' file dialogs don't block the frame where the platform's backend-            // would (#511, #1275); see [`install_file_dialogs`].-            install_file_dialogs(&mut app, &cc.egui_ctx);-            #[cfg(target_os = "macos")]-            {-                app.hooks.app_action = Some(Box::new(native_menu::app_action));-                app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text));-            }-            if let Some(port) = control_port {-                disable_app_nap();-                let rx = control_server::start(port, cc.egui_ctx.clone());-                app = app.with_control(rx);-            }-            log::info!("app created; the window shows after its first frame");-            Ok(Box::new(Desktop {-                #[cfg(target_os = "macos")]-                menu: native_menu::NativeBar::new(&cc.egui_ctx),-                #[cfg(target_os = "macos")]-                apple_events: apple_events.connect(&cc.egui_ctx),-                app,-                window: window::Tracker::new(opened, saved_window, control_port.is_none()),-                launch,-                drawn: false,-            }))-        }),-    );-    match &result {-        Ok(()) => log::info!("the window closed"),-        Err(e) => {-            if on_gl {-                launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());-            }-            startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));-        }-    }-    result-}--/// The window's wgpu device asks for the adapter's own limits (textures up to 16384 px)-/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500,-/// #198) still opens the app, and the compositor then checks what it needs and falls back to-/// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one.-fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfiguration {-    use eframe::egui_wgpu::WgpuSetup;-    let mut config = eframe::WgpuConfiguration::default();-    if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {-        if let Some(b) = backends {-            setup.instance_descriptor.backends = b;-        }-        // Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs-        // a compute shader when the device is created, one that Metal can't compile on an Intel-        // Iris Pro: the device is lost and the window never opens.-        // `WGPU_VALIDATION_INDIRECT_CALL=1` turns the check back on.-        setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();-        setup.device_descriptor = std::sync::Arc::new(|adapter| device_descriptor(adapter.limits()));-    }-    config-}--/// The window device's descriptor: the adapter's limits ([`device_limits`]) and `MemoryUsage`,-/// so memory the compositor frees on the shared device goes back to the driver.-fn device_descriptor(adapter: eframe::wgpu::Limits) -> eframe::wgpu::DeviceDescriptor<'static> {-    eframe::wgpu::DeviceDescriptor {-        label: Some("EffectCraft device"),-        required_limits: device_limits(adapter),-        memory_hints: eframe::wgpu::MemoryHints::MemoryUsage,-        ..Default::default()-    }-}--/// The limits to request from an adapter that offers `adapter`: all of them, with textures-/// capped at 16384 px like the headless renderer's device.-fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits {-    eframe::wgpu::Limits { max_texture_dimension_2d: adapter.max_texture_dimension_2d.min(16384), ..adapter }-}--/// The window couldn't open (no usable graphics adapter or device): say so instead of quitting-/// silently, since release builds on Windows have no console. `gl_next`: the launch guard has-/// the next launch try OpenGL.-fn startup_failed(e: &eframe::Error, gl_next: bool) {-    let next = if gl_next { "The next launch will try OpenGL instead. " } else { "" };-    let message = format!(-        "EffectCraft couldn't start its window: {e}\n\n{next}Updating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."-    );-    // Standard error and the session log; standard error alone when RUST_LOG turned errors off.-    if log::log_enabled!(log::Level::Error) {-        log::error!("{message}");-    } else {-        eprintln!("effectcraft: {message}");-    }-    let _ = rfd::MessageDialog::new()-        .set_level(rfd::MessageLevel::Error)-        .set_title("EffectCraft")-        .set_description(message)-        .set_buttons(rfd::MessageButtons::Ok)-        .show();-}--/// The desktop app: the egui app plus, on macOS, the system menu bar.-struct Desktop {-    app: EffectcraftApp,-    #[cfg(target_os = "macos")]-    menu: native_menu::NativeBar,-    /// Documents opened from Finder, the Dock or `open` (macOS).-    #[cfg(target_os = "macos")]-    apple_events: apple_events::Opened,-    /// The window's size and position, saved for the next launch.-    window: window::Tracker,-    launch: launch_guard::Launch,-    /// The first frame was drawn (logged once).-    drawn: bool,-}--impl eframe::App for Desktop {-    fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {-        #[cfg(target_os = "macos")]-        self.menu.update(&mut self.app, ctx);-        self.app.logic(ctx, frame);-    }--    fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {-        self.app.ui(ui, frame);-        self.window.frame(ui.ctx(), self.app.session.config.as_deref());-        if !self.drawn {-            self.drawn = true;-            log::info!("first frame drawn");-        }-        // The launch guard's marker goes once the window has kept drawing for a few seconds: a-        // driver may still crash as the window is shown and its surface re-created.-        if let Some(left) = self.launch.frame_drawn() {-            ui.ctx().request_repaint_after(left);-        }-    }--    fn on_exit(&mut self) {-        self.launch.exited();-        self.window.save(self.app.session.config.as_deref());-        self.app.on_exit();-    }--    fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {-        #[cfg(target_os = "macos")]-        self.apple_events.feed(self.app.is_ready(), ctx, raw_input);-        self.app.raw_input_hook(ctx, raw_input);-    }-}--/// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard-/// focus stays where it is (macOS; winit activates ignoring other apps by default).-fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {-    #[cfg(target_os = "macos")]-    if agent {-        return Some(Box::new(|b| {-            use winit::platform::macos::EventLoopBuilderExtMacOS;-            b.with_activate_ignoring_other_apps(false);-        }));-    }-    let _ = agent;-    None-}--/// Agents drive the app while its window is covered or on another Space. macOS App Nap would-/// throttle the event loop then, stalling the control channel; opt out for `--control` runs.-fn disable_app_nap() {-    #[cfg(target_os = "macos")]-    {-        use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString};-        let info = NSProcessInfo::processInfo();-        let reason = NSString::from_str("EffectCraft control channel");-        let opts = NSActivityOptions::UserInitiatedAllowingIdleSystemSleep | NSActivityOptions::LatencyCritical;-        // Leaked on purpose: the activity lasts for the life of the process.-        std::mem::forget(info.beginActivityWithOptions_reason(opts, &reason));-    }-}--#[cfg(test)]-mod tests {-    use super::{Args, parse_args};-    use std::env::VarError;--    fn parse_env(args: &[&str], env_port: Result<String, VarError>) -> Result<Option<Args>, String> {-        parse_args(args.iter().map(|a| a.to_string()), env_port)-    }--    fn parse(args: &[&str]) -> Option<Args> {-        parse_env(args, Err(VarError::NotPresent)).unwrap()-    }--    fn fails(args: &[&str]) -> String {-        parse_env(args, Err(VarError::NotPresent)).unwrap_err()-    }--    #[cfg(target_os = "linux")]-    #[test]-    fn wayland_drop_notice_recommends_import_or_xwayland() {-        use std::ffi::OsStr;-        let warning = super::wayland_drop_notice(Some(OsStr::new("wayland-0")), None).unwrap();-        assert!(warning.contains("File ▸ Import") && warning.contains("XWayland"));-        assert!(super::wayland_drop_notice(None, None).is_none());-        assert!(super::wayland_drop_notice(Some(OsStr::new("")), None).is_none());-        assert!(super::wayland_drop_notice(Some(OsStr::new("wayland-0")), Some(OsStr::new("x11"))).is_none());-    }--    /// The window's wgpu instance skips indirect-call validation (Intel Metal, #1153).-    #[test]-    fn the_window_instance_skips_indirect_call_validation() {-        if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_some() {-            return;-        }-        let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = super::wgpu_options(None).wgpu_setup else {-            panic!("the default setup creates its own instance");-        };-        // Intel Metal can't compile the validation's compute shader, and the device is lost.-        assert!(!setup.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));-    }--    /// The window's device never asks for more than the adapter offers (#198).-    #[test]-    fn device_limits_fit_the_adapter() {-        use eframe::wgpu::Limits;-        for adapter in [Limits::downlevel_defaults(), Limits::downlevel_webgl2_defaults(), Limits::default()] {-            let l = super::device_limits(adapter.clone());-            assert!(l.check_limits(&adapter), "{l:?}");-            assert_eq!(l.max_storage_buffers_per_shader_stage, adapter.max_storage_buffers_per_shader_stage);-        }-        let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };-        assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);-    }--    /// The window device asks for `MemoryUsage`: the adapter's limits, unchanged.-    #[test]-    fn the_window_device_favours_memory_usage() {-        use eframe::wgpu::Limits;-        let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };-        let d = super::device_descriptor(big);-        assert!(matches!(d.memory_hints, eframe::wgpu::MemoryHints::MemoryUsage));-        assert_eq!(d.required_limits.max_texture_dimension_2d, 16384);-        assert_eq!(d.label, Some("EffectCraft device"));-    }--    /// A positional .ecprojx is a project file, not an unsupported media import (#528).-    #[test]-    fn launch_classifies_xml_and_json_projects_as_projects() {-        for path in ["Copy.ecprojx", "/tmp/Scene.ecproj", "C:\\\\work\\\\Shot.ECPROJX"] {-            assert!(super::is_project_file(path), "{path}");-        }-        for path in ["scene.ecprojx.png", "scene.mp4", "scene", "copy.ecprojx.bak"] {-            assert!(!super::is_project_file(path), "{path}");-        }-        let files = ["scene.ecprojx".to_string(), "clip.mov".to_string()];-        assert_eq!(files.iter().find(|f| super::is_project_file(f)).map(String::as_str), Some("scene.ecprojx"));-        assert_eq!(files.iter().filter(|f| !super::is_project_file(f)).map(String::as_str).collect::<Vec<_>>(), vec!["clip.mov"],);-    }--    /// Launching without a project opens an empty project, not the demo (#204).-    #[test]-    fn no_arguments_start_an_empty_project() {-        assert_eq!(parse(&[]), Some(Args::default()));-        assert!(parse(&["--demo"]).is_some_and(|a| a.demo));-        assert!(parse(&["--demo", "--empty"]).is_some_and(|a| !a.demo));-        let a = parse(&["--control", "9877", "--home", "a.ecproj"]).unwrap_or_default();-        assert_eq!((a.control_port, a.home, a.files), (Some(9877), Some(true), vec!["a.ecproj".to_string()]));-        assert_eq!(parse(&["--version"]), None);-        assert_eq!(parse_env(&[], Ok("9000".into())).unwrap().map(|a| a.control_port), Some(Some(9000)));-    }--    /// A missing, non-numeric or zero `--control` value stops the launch instead of starting-    /// without a control server, and the argument after `--control` isn't reinterpreted (#997).-    #[test]-    fn control_needs_a_port_from_1_to_65535() {-        let need = "--control needs a port number from 1 to 65535";-        assert_eq!(fails(&["--control"]), need);-        assert_eq!(fails(&["--control", "--demo"]), format!("{need}, not `--demo`"));-        assert_eq!(fails(&["--control", "--version"]), format!("{need}, not `--version`"));-        for bad in ["abc", "0", "70000", "-1", ""] {-            assert_eq!(fails(&["--control", bad]), format!("{need}, not `{bad}`"));-            assert_eq!(fails(&[&format!("--control={bad}")]), format!("{need}, not `{bad}`"));-        }-        assert_eq!(parse(&["--control=9877"]).map(|a| a.control_port), Some(Some(9877)));-        assert_eq!(parse(&["--control", "65535"]).map(|a| a.control_port), Some(Some(65535)));-    }--    /// An unknown option is an error, not a file to import (#997); `--` ends the options.-    #[test]-    fn unknown_options_are_errors_not_files() {-        for bad in ["--automation-read-root", "--automation-write-root=/tmp", "-h", "--Demo", "--demo=yes"] {-            assert_eq!(fails(&[bad, "/tmp/folder"]), format!("unknown option `{bad}`"));-        }-        // Parsed left to right: an error before `--version` wins.-        assert_eq!(fails(&["--nope", "--version"]), "unknown option `--nope`");-        // Finder's process serial number is ignored, not imported or refused.-        assert_eq!(parse(&["-psn_0_12345"]).map(|a| a.files), Some(vec![]));-        let a = parse(&["clip.mov", "-", "--", "--demo", "-name.png"]).unwrap_or_default();-        assert_eq!(a.files, ["clip.mov", "-", "--demo", "-name.png"]);-        assert!(!a.demo);-    }--    /// `--control` wins over `EFFECTCRAFT_CONTROL_PORT`; the variable is checked only when it is-    /// used, so a bad value doesn't stop `--control` or `--version`.-    #[test]-    fn control_port_variable_is_checked_when_used() {-        let need = "EFFECTCRAFT_CONTROL_PORT needs a port number from 1 to 65535";-        assert_eq!(parse_env(&[], Ok("abc".into())).unwrap_err(), format!("{need}, not `abc`"));-        assert_eq!(parse_env(&[], Ok("0".into())).unwrap_err(), format!("{need}, not `0`"));-        assert_eq!(parse_env(&[], Ok(String::new())), Ok(Some(Args::default())));-        assert_eq!(parse_env(&["--control", "9877"], Ok("abc".into())).unwrap().map(|a| a.control_port), Some(Some(9877)));-        assert_eq!(parse_env(&["--version"], Ok("abc".into())), Ok(None));-        #[cfg(unix)]-        {-            use std::os::unix::ffi::OsStringExt;-            let not_utf8 = VarError::NotUnicode(std::ffi::OsString::from_vec(vec![0x39, 0xff]));-            assert_eq!(parse_env(&[], Err(not_utf8)).unwrap_err(), format!("{need}, not `9\u{fffd}`"));-        }-    }--    /// #1275: a blocking desktop-portal dialog on Linux leaves the window not answering the-    /// compositor while it is open, so the commands' file dialogs go through the non-blocking-    /// host hook there (as they do on macOS, #511). Windows' dialog pumps its own messages.-    #[test]-    fn commands_file_dialogs_do_not_block_the_frame() {-        let mut app = effectcraft_ui_egui::EffectcraftApp::new(effectcraft_engine::Session::default());-        super::install_file_dialogs(&mut app, &egui::Context::default());-        #[cfg(any(target_os = "macos", target_os = "linux"))]-        assert!(app.hooks.file_dialog_async.is_some(), "the commands' file dialogs must not block the frame");-        #[cfg(not(any(target_os = "macos", target_os = "linux")))]-        assert!(app.hooks.file_dialog_async.is_none(), "the blocking dialog pumps its own messages here");-    }+    effectcraft::run() }diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs--- a/crates/engine/src/prefs.rs+++ b/crates/engine/src/prefs.rs@@ -184,7 +184,7 @@ page!(Appearance {     favorite_themes: Vec<String> = Vec::new(),     /// User interface brightness, -1 (darker) … 1 (lighter).     brightness: f64 = 0.0,-    /// The size of the whole interface in percent (75–200), on top of the display's own scale.+    /// The size of the whole interface in percent (50–200), on top of the display's own scale.     ui_scale: u32 = 100,     use_label_color_for_handles: bool = true,     use_label_color_for_tabs: bool = true,@@ -641,7 +641,7 @@ impl Prefs {             _ => {}         }         self.appearance.brightness = self.appearance.brightness.clamp(-1.0, 1.0);-        self.appearance.ui_scale = self.appearance.ui_scale.clamp(75, 200);+        self.appearance.ui_scale = self.appearance.ui_scale.clamp(50, 200);         let defaults = default_labels();         self.labels.truncate(defaults.len());         for d in defaults.iter().skip(self.labels.len()) {@@ -1265,7 +1265,7 @@ pub fn pages() -> Vec<Page> {                 s("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true),                 s("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true),                 s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true),-                s("appearance.uiScale", "UI Scale", Kind::Int(75, 200, "%"), true),+                s("appearance.uiScale", "UI Scale", Kind::Int(50, 200, "%"), true),                 Section("Labels and Colors"),                 s("appearance.useLabelColorForHandles", "Use Label Color for Layer Handles and Paths", B, true),                 s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, true),diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs--- a/crates/engine/src/tests_settings.rs+++ b/crates/engine/src/tests_settings.rs@@ -87,10 +87,10 @@ fn prefs_set_validates_keys_types_and_ranges() {     assert!(p.reset(Some("bogus")).is_err()); } -/// Settings ▸ Appearance ▸ UI Scale (#284): a percentage, 100 by default, kept within 75–200;+/// Settings ▸ Appearance ▸ UI Scale (#284): a percentage, 100 by default, kept within 50–200; /// the dialog types or drags any whole percentage in that range (#682). #[test]-fn ui_scale_is_a_percentage_from_75_to_200() {+fn ui_scale_is_a_percentage_from_50_to_200() {     let mut p = Prefs::default();     assert_eq!(p.appearance.ui_scale, 100);     p.set("appearance.uiScale", json!("150")).unwrap();@@ -99,9 +99,9 @@ fn ui_scale_is_a_percentage_from_75_to_200() {     p.set("appearance.uiScale", json!(500)).unwrap();     assert_eq!(p.appearance.ui_scale, 200);     p.set("appearance.uiScale", json!(10)).unwrap();-    assert_eq!(p.appearance.ui_scale, 75);+    assert_eq!(p.appearance.ui_scale, 50);     let page = crate::prefs::pages().into_iter().find(|pg| pg.id == "appearance").unwrap();-    let field = crate::prefs::Item::Setting { key: "appearance.uiScale", label: "UI Scale", kind: crate::prefs::Kind::Int(75, 200, "%"), live: true };+    let field = crate::prefs::Item::Setting { key: "appearance.uiScale", label: "UI Scale", kind: crate::prefs::Kind::Int(50, 200, "%"), live: true };     assert!(page.items.contains(&field), "Settings ▸ Appearance offers it");     // Older settings files without it get the default.     assert_eq!(Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "light"}}"#).appearance.ui_scale, 100);diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -169,6 +169,24 @@ pub struct Hooks {     /// a drag over the window (#511), and on Linux a blocking desktop-portal dialog never pumps     /// events, so the compositor marks the window as not responding (#1275).     pub file_dialog_async: Option<Box<dyn Fn(FileDialog, DialogDone)>>,+    /// The app-private storage directory on Android (`AndroidApp::internal_data_path`), where the+    /// in-app Save / Open Project prompts read and write. `None` elsewhere (or when it could not+    /// be resolved): the commands fail with "no file dialog available", as before.+    #[cfg(target_os = "android")]+    pub app_files_dir: Option<std::path::PathBuf>,+    /// Android's shared internal-storage root (`/storage/emulated/0`), offered as a second+    /// browse root beside the app-private directory once "All files access" is granted.+    /// `None` elsewhere (or when it could not be resolved).+    #[cfg(target_os = "android")]+    pub shared_storage_root: Option<std::path::PathBuf>,+    /// Whether Android's "All files access" (`MANAGE_EXTERNAL_STORAGE`) is currently granted.+    /// `None` elsewhere.+    #[cfg(target_os = "android")]+    pub shared_storage_granted: Option<Box<dyn Fn() -> bool>>,+    /// Asks the user to grant "All files access" (the system settings page on Android 11+, the+    /// permission dialog before that). `None` elsewhere.+    #[cfg(target_os = "android")]+    pub shared_storage_grant: Option<Box<dyn Fn()>>,     /// Tells the user the graphics device was lost and the window is closing, once. `saved` is     /// the auto-save of unsaved changes (`None`: nothing was unsaved), its path or why it failed.     pub device_lost: Option<Box<dyn Fn(Option<Result<String, String>>)>>,@@ -1626,6 +1644,9 @@ impl EffectcraftApp {         self.run_dialog_answers();         self.auto.begin_frame();         self.frames.set_context(&ctx);+        // A touch screen's bars: a fingertip has to land on a scroll bar (widgets.rs), and a+        // theme change rebuilds the styles, so the widening is re-checked every frame.+        widgets::touch_scroll_target(&ctx);         if self.session.render_job.is_some() {             self.session.poll_render();             ctx.request_repaint_after(std::time::Duration::from_millis(100));diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs--- a/crates/ui-egui/src/panels/settings.rs+++ b/crates/ui-egui/src/panels/settings.rs@@ -119,7 +119,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value                                 Kind::Int(lo, hi, unit) => {                                     let mut x = v.as_i64().unwrap_or(0);                                     // A typed number applies once entered: typing 85 into UI Scale-                                    // doesn't resize the interface to 75 % at its first digit.+                                    // doesn't resize the interface to 50 % at its first digit.                                     let drag = egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25).update_while_editing(false);                                     let r = ui.add(crate::widgets::drag_value(drag));                                     if !unit.is_empty() {diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs--- a/crates/ui-egui/src/theme.rs+++ b/crates/ui-egui/src/theme.rs@@ -781,15 +781,21 @@ mod tests {         ctx.run_ui(Default::default(), |_| {}).textures_delta.clear();         for family in [FontFamily::Proportional, FontFamily::Monospace, FontFamily::Name("medium".into()), FontFamily::Name("semibold".into())] {             let id = FontId::new(12.0, family);-            // Not has_glyph: it compares face keys, so Inter's `•` reads as missing when no face in-            // the stack has egui's `◻` and Inter's `?` becomes the replacement, as happens once-            // another test has loaded a system face without `◻` (the fallback tries loaded faces first).-            let uv = |s: String| ctx.fonts_mut(|f| f.layout_no_wrap(s, id.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect);-            let missing = uv("\u{10ffff}".into());-            for c in "▸•›·▶◀●○⚠…×".chars() {-                let drawn = uv(c.to_string());-                assert_ne!((drawn.min, drawn.max), (missing.min, missing.max), "{c} has no glyph in {id:?}");-            }+            ctx.fonts_mut(|fonts| {+                // Not `has_glyph`: it compares face keys and reports a false negative whenever a+                // real glyph shares the replacement face, as `script_font_tests` notes. It called+                // every one of these symbols missing while all of them drew, and which way it went+                // turned on what the machine had installed. Compare the atlas entry like that+                // module does, which is what "has a glyph" has to mean anyway.+                let missing = fonts.layout_no_wrap("\u{10ffff}".into(), id.clone(), egui::Color32::WHITE);+                let missing_uv = missing.rows[0].glyphs[0].uv_rect;+                for c in "▸•›·▶◀●○⚠…×".chars() {+                    let rendered = fonts.layout_no_wrap(c.to_string(), id.clone(), egui::Color32::WHITE);+                    let uv = rendered.rows[0].glyphs[0].uv_rect;+                    assert!(uv.size.x > 0.0 && uv.size.y > 0.0, "{c} draws nothing in {id:?}");+                    assert_ne!((uv.min, uv.max), (missing_uv.min, missing_uv.max), "{c} draws a missing-glyph box in {id:?}");+                }+            });         }     } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs--- a/crates/ui-egui/src/widgets.rs+++ b/crates/ui-egui/src/widgets.rs@@ -423,7 +423,13 @@ pub fn scroll_bar(ui: &mut Ui, auto: &mut Registry, key: &str, track: Rect, scro     let thumb_len = (len * len / (len + max.max(0.0)).max(1.0)).max(16.0).min(len).max(0.0);     let travel = (len - thumb_len).max(1.0);     let thumb_at = |s: f32| track.min[a] + (len - thumb_len) * if max > 0.0 { (s / max).clamp(0.0, 1.0) } else { 0.0 };-    let resp = ui.interact(track.expand2(if vertical { vec2(3.0, 0.0) } else { vec2(0.0, 3.0) }), egui::Id::new(key), Sense::drag());+    // The hit a finger lands on: a 4-point track becomes a 20-point target, widened only across+    // the bar's thin axis (#1). A mouse always had this; a touch never could. The long axis stays+    // put because it reaches neighbouring UI: a horizontal bar's expanded x would put its hit+    // directly under the empty list area beside it, and egui's hit test then claims the click for+    // the drag-only bar and the panel's own right-click menu never opens (kittest caught this).+    let hit = track.expand2(if vertical { vec2(8.0, 0.0) } else { vec2(0.0, 8.0) });+    let resp = ui.interact(hit, egui::Id::new(key), Sense::drag());     let pressed = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.any_pressed());     let mut s = scroll;     if let Some(p) = resp.interact_pointer_pos().filter(|p| pressed && !(thumb_at(scroll)..=thumb_at(scroll) + thumb_len).contains(&p[a])) {@@ -447,9 +453,9 @@ pub fn scroll_bar(ui: &mut Ui, auto: &mut Registry, key: &str, track: Rect, scro }  /// Vertical scrolling for a panel that lays out its own rows: [`PanelScroll::begin`] gives the-/// offset to draw them at (moved by the mouse wheel over `area`), [`PanelScroll::end`] takes the-/// rows' height and draws a [`scroll_bar`] on the right edge while they overflow, so every-/// panel can be scrolled without a wheel or touchpad (#271).+/// offset to draw them at (moved by the mouse wheel or a finger drag over `area`),+/// [`PanelScroll::end`] takes the rows' height and draws a [`scroll_bar`] on the right edge while+/// they overflow, so every panel can be scrolled without a wheel or touchpad (#271, #1). pub struct PanelScroll {     id: egui::Id,     area: Rect,@@ -464,6 +470,20 @@ impl PanelScroll {         if ui.rect_contains_pointer(area) {             offset -= ui.input(|i| i.smooth_scroll_delta.y);         }+        // A drag over the panel moves the offset the same way, so a finger has a way in where+        // there is neither a wheel nor a trackpad (#1). Registered before the rows are laid out,+        // so whatever the panel draws on top — fields, buttons, rows that drag to apply — wins+        // the pointer as it always did, and this only takes the drags that start on the panel's+        // own empty space.+        // Inset a few points from the panel's edges: the dock's resize gutters reach into the+        // content rect (their hit is the gap expanded by 3), and they register before the panel+        // does — a finger aiming at a divider must never land on the scroll surface instead.+        let drag = ui.interact(area.shrink(4.0), id.with("panel-scroll-drag"), Sense::drag());+        if drag.dragged() {+            // With the finger: the content follows it, so down moves the offset towards zero.+            offset -= drag.drag_delta().y;+            ui.ctx().request_repaint();+        }         Self { id, area, offset: offset.clamp(0.0, max.max(0.0)) }     } @@ -664,7 +684,7 @@ pub fn any_menu_open(ctx: &egui::Context) -> bool { /// and the scroll bar are how a normally opened menu scrolls. pub fn menu_scroll<R>(ui: &mut Ui, browsing: bool, add: impl FnOnce(&mut Ui) -> R) -> R {     let max_h = ui.ctx().content_rect().height() - 40.0;-    let out = egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, add);+    let out = menu_scroll_area(max_h).show(ui, add);     edge_scroll(ui, &out, browsing);     out.inner }@@ -731,6 +751,80 @@ fn edge_scroll<R>(ui: &Ui, out: &egui::containers::scroll_area::ScrollAreaOutput     } } +/// The scroll area every menu body runs in: `max_h` points tall, and draggable to scroll with any+/// pointer. See [`menu_drag`] for why the drag is forced on.+fn menu_scroll_area(max_h: f32) -> egui::ScrollArea {+    egui::ScrollArea::vertical()+        .scroll_source(egui::containers::scroll_area::ScrollSource { drag: menu_drag(), ..egui::containers::scroll_area::ScrollSource::ALL })+        .max_height(max_h)+        .min_scrolled_height(max_h)+}++/// How a menu's contents respond to a drag: always, with the scroll bar and the wheel too.+///+/// egui's own default (`DragScroll::OnTouch`) gates drag-to-scroll behind+/// `InputState::has_touch_screen`, and measured on a phone in landscape (2311DRK48G, 1220x2712)+/// that gate is closed for menus: an 850 pt finger drag inside the Edit menu left it byte-for-byte+/// unchanged, so the rows below the fold (Select All through Preferences, the last of 34) could not+/// be reached at all (#269). Forcing the drag on opened it — the same drag now walks the menu from+/// "Can't Undo" down to "Preferences" and back again.+///+/// The same drag did scroll the file browser's plain `ScrollArea`, which keeps egui's default, so+/// touch input does reach the app and the two areas differ in more than this one setting:+/// `ScrollSource::ALL` enables several sources of which the drag is only one, and the list may have+/// scrolled by a fling rather than by dragging. That is left open on purpose. What is measured is+/// the before/after on the menu itself, and a menu must never depend on that detection: if it is+/// taller than the window it has to scroll, whatever pointer the user is holding.+fn menu_drag() -> egui::containers::scroll_area::DragScroll {+    egui::containers::scroll_area::DragScroll::Always+}++/// A fingertip cannot aim at a 6-point scroll bar: on a touch screen, widen the bar's whole+/// `bar_width` (which is also the width inside which egui hit-tests the bar) to+/// [`TOUCH_BAR_TARGET`] points.+///+/// The themes build `ScrollStyle::solid` with a 6-point `bar_width`; the app's own+/// [`scroll_bar`] widens only its drawn hit. Every panel that lays out its own rows draws that+/// one, but every `ScrollArea` (menus, the file browser, Settings' page list) hit-tests through+/// this style value, and a finger misses the 6-point band — so on a touch screen it is widened.+/// Because the styles are replaced outright whenever a theme is applied (`theme::apply_visuals`),+/// widening is re-checked every frame rather than remembered once.+///+/// `bar_width` is also the width a *solid* bar draws, so widening it alone drew 24-point bars+/// everywhere a stock `ScrollArea` scrolled. The bar therefore takes egui's floating shape: it+/// rest-draws `floating_width` as thick as the theme drew it, reserves the sliver the solid bar+/// reserved (the layout does not reflow), and expands to `bar_width` only while the finger works+/// it — egui senses the full `bar_width` either way, at rest and drawn thin alike.+pub fn touch_scroll_target(ctx: &egui::Context) {+    if !(cfg!(target_os = "android") || ctx.input(|i| i.has_touch_screen())) {+        return;+    }+    let narrower = |s: &egui::Style| s.spacing.scroll.bar_width < TOUCH_BAR_TARGET;+    let narrow = narrower(&ctx.global_style()) || [egui::Theme::Dark, egui::Theme::Light].iter().any(|t| narrower(&ctx.style_of(*t)));+    if !narrow {+        return;+    }+    ctx.all_styles_mut(|s| {+        let sc = &mut s.spacing.scroll;+        let resting = sc.bar_width;+        sc.bar_width = sc.bar_width.max(TOUCH_BAR_TARGET);+        sc.floating = true;+        sc.floating_width = sc.floating_width.max(resting);+        sc.floating_allocated_width = sc.floating_allocated_width.max(sc.bar_inner_margin + resting + sc.bar_outer_margin);+        // Thin like the solid bar was while the finger is elsewhere:+        sc.dormant_background_opacity = 1.0;+        sc.dormant_handle_opacity = 1.0;+        sc.active_background_opacity = 1.0;+        sc.active_handle_opacity = 1.0;+        // Translucent when the finger expands it over the content it scrolls.+        sc.interact_background_opacity = 0.6;+        sc.interact_handle_opacity = 0.6;+    });+}++/// The scroll bar's interaction target on a touch screen, in points.+const TOUCH_BAR_TARGET: f32 = 24.0;+ /// egui's menu style with no gap between the rows: the gap the app's item spacing left between /// two entries highlighted neither and swallowed a click there. The rows grow by that gap, so a /// menu keeps its size.@@ -853,4 +947,212 @@ mod tests {             assert!((r.center().y - field.center().y).abs() < 0.5 && r.height() >= h - 2.5, "{h}: {r:?} in {field:?}");         }     }++    /// #269: a menu taller than its window has to be scrollable, and the Edit menu on a phone in+    /// landscape is exactly that — 34 rows against the 21 that fit, with Select All, Label, Purge,+    /// Templates, Keyboard Shortcuts and Preferences below the fold.+    #[test]+    fn a_menu_taller_than_the_window_scrolls_by_drag() {+        // Measured on the phone (2311DRK48G, landscape): with egui's default `DragScroll::OnTouch`+        // an 850 pt finger drag left the Edit menu byte-for-byte unchanged, while the same drag+        // scrolled the file browser's plain ScrollArea. Forcing the drag on is what opened the menu.+        assert_eq!(menu_drag(), egui::containers::scroll_area::DragScroll::Always, "menus must drag-scroll regardless of egui's touch-screen detection");++        // And the layout really does overflow: in a 300 pt window (a phone in landscape is barely+        // taller) the last of 42 rows starts below the bottom edge, so without scrolling the menu+        // simply runs off the screen.+        let ctx = egui::Context::default();+        let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 300.0));+        let mut last_y = f32::MAX;+        let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };+        let mut out = ctx.run_ui(input, |ui| {+            menu_scroll(ui, false, |ui| {+                for i in 0..42 {+                    let r = ui.label(format!("row {i}"));+                    if i == 41 {+                        last_y = r.rect.min.y;+                    }+                }+            });+        });+        out.textures_delta.clear();+        assert!(last_y > screen.height(), "the test menu must overflow its window: last row at {last_y} pt of {}", screen.height());+    }++    /// #1: the panels have neither a wheel nor a trackpad, so a drag over a panel's own empty+    /// space has to move it. With only `smooth_scroll_delta` the offset ignored a finger+    /// completely — the right sidebar and every `PanelScroll` panel was frozen past the fold.+    #[test]+    fn a_panel_scrolls_when_a_pointer_drags_its_empty_space() {+        let ctx = egui::Context::default();+        let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(300.0, 400.0));+        let area = screen;+        let id = egui::Id::new("drag-scroll-test");+        let offset = std::cell::Cell::new(f32::NAN);+        let run = |events: Vec<egui::Event>| {+            // 500 points of rows in a 400 pt panel: 100 points of scroll.+            let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| {+                let scroll = PanelScroll::begin(ui, id, area);+                offset.set(scroll.offset);+                scroll.end(ui, &mut Registry::default(), "drag-scroll-test", 500.0, &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark));+            });+            out.textures_delta.clear();+        };+        let p = |y: f32| egui::Event::PointerMoved(pos2(150.0, y));+        let press = |y: f32, down: bool| egui::Event::PointerButton {+            pos: pos2(150.0, y),+            button: egui::PointerButton::Primary,+            pressed: down,+            modifiers: egui::Modifiers::NONE,+        };+        let at = |want: f32, what: &str| {+            let got = offset.get();+            assert!((got - want).abs() < 0.5, "{what}: offset {got}, wanted {want}");+        };+        run(vec![p(300.0)]);+        run(vec![press(300.0, true)]);+        // The content follows the finger: up goes to later rows, down back towards the first.+        run(vec![p(260.0)]);+        at(40.0, "the drag moved the offset with the finger");+        run(vec![p(220.0)]);+        at(80.0, "and keeps following it");+        run(vec![p(240.0)]);+        at(60.0, "down too");+        // Clamped at both ends, never past the rows.+        run(vec![p(40.0)]);+        at(100.0, "clamped at the end of the rows");+        run(vec![press(40.0, false)]);+        run(vec![p(250.0)]);+        run(vec![press(250.0, true)]);+        run(vec![p(390.0)]);+        at(0.0, "and clamped back at the first row");+        run(vec![press(390.0, false)]);+    }++    /// #1: the scroll bar's track is 4 points wide — a mouse always landed on its 10-point hit, a+    /// finger could not. The drag it registers now reaches 8 points past each side, so a press+    /// well beside the track scrolls the content (it landed on the panel underneath before).+    #[test]+    fn the_scroll_bar_reaches_past_its_track_for_a_finger() {+        let ctx = egui::Context::default();+        let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(200.0, 400.0));+        // A horizontal bar: the widened hit runs past the track's top and bottom edges, which is+        // where a press beside a vertical bar's track lands too (the same expansion, other axis).+        let track = Rect::from_min_max(pos2(10.0, 300.0), pos2(190.0, 304.0));+        // ~12 points past the track and past the thumb, so the press is a track press that+        // centres the thumb there. The old 3-point expansion (with egui's own hit slop) reached+        // only about 315, so before the widening this press landed on the panel and did nothing.+        let at = pos2(170.0, 316.0);+        let scrolled = std::cell::Cell::new(f32::NAN);+        let run = |events: Vec<egui::Event>| {+            let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| {+                scrolled.set(scroll_bar(+                    ui,+                    &mut Registry::default(),+                    "bar-reach-test",+                    track,+                    0.0,+                    100.0,+                    &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark),+                ));+            });+            out.textures_delta.clear();+        };+        run(vec![egui::Event::PointerMoved(at)]);+        run(vec![egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }]);+        assert!(scrolled.get() > 0.0, "a press 12 points past the track centres the thumb there: {}", scrolled.get());+    }++    /// A touch screen's scroll bars: `ScrollArea` hit-tests through the style's `bar_width`+    /// (6 points here), which a finger cannot land on, so [`touch_scroll_target`] widens it —+    /// and keeps it widened after a theme change rebuilds the styles.+    #[test]+    fn a_touch_screen_widens_the_scroll_bar_target() {+        let bar_width = |events: Vec<egui::Event>| {+            let ctx = egui::Context::default();+            let idle = ctx.style_of(egui::Theme::Dark).spacing.scroll.bar_width;+            ctx.begin_pass(egui::RawInput { events, ..Default::default() });+            super::touch_scroll_target(&ctx);+            let width = ctx.style_of(egui::Theme::Dark).spacing.scroll.bar_width;+            ctx.end_pass().drop_without_applying_deltas();+            (idle, width)+        };+        let (idle, plain) = bar_width(Vec::new());+        assert_eq!(plain, idle, "a window with no touch screen keeps the theme's bar");+        let touch = egui::Event::Touch {+            device_id: egui::TouchDeviceId(3),+            id: egui::TouchId(1),+            phase: egui::TouchPhase::Start,+            pos: egui::pos2(10.0, 10.0),+            force: None,+        };+        let (_, wide) = bar_width(vec![touch]);+        assert!(wide >= super::TOUCH_BAR_TARGET, "a finger has to be able to land on the bar: {wide}");+        assert!(wide > plain, "the target only ever widens: {plain} -> {wide}");++        // A theme switch replaces each style outright; the wider target has to come back.+        let ctx = egui::Context::default();+        let touch = egui::Event::Touch {+            device_id: egui::TouchDeviceId(3),+            id: egui::TouchId(1),+            phase: egui::TouchPhase::Start,+            pos: egui::pos2(10.0, 10.0),+            force: None,+        };+        ctx.begin_pass(egui::RawInput { events: vec![touch.clone()], ..Default::default() });+        super::touch_scroll_target(&ctx);+        assert!(ctx.style_of(egui::Theme::Dark).spacing.scroll.bar_width >= super::TOUCH_BAR_TARGET);+        ctx.end_pass().drop_without_applying_deltas();+        crate::theme::apply_visuals(&ctx, &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark));+        assert!(ctx.style_of(egui::Theme::Dark).spacing.scroll.bar_width < super::TOUCH_BAR_TARGET, "the theme rebuild starts from the thin bar again");+        ctx.begin_pass(egui::RawInput { events: vec![touch], ..Default::default() });+        super::touch_scroll_target(&ctx);+        let after_theme = ctx.style_of(egui::Theme::Dark).spacing.scroll.bar_width;+        ctx.end_pass().drop_without_applying_deltas();+        assert!(after_theme >= super::TOUCH_BAR_TARGET, "a theme switch must not take the touch target away: {after_theme}");+    }++    /// The widened target must not widen the drawn bar: egui uses `bar_width` both for the hit+    /// test and (on a solid bar) for drawing, so the solid widening drew 24-point bars+    /// everywhere a stock `ScrollArea` scrolled (menus, the file browser, Settings…). On a+    /// touch screen the bar therefore takes egui's floating shape: it rest-draws+    /// `floating_width` as thick as the theme drew it, reserves the sliver the solid bar+    /// reserved, and expands to `bar_width` only while the finger works it (a hover too).+    #[test]+    fn a_touch_screen_draws_the_scroll_bar_thin_between_touches() {+        let touch = egui::Event::Touch {+            device_id: egui::TouchDeviceId(3),+            id: egui::TouchId(1),+            phase: egui::TouchPhase::Start,+            pos: egui::pos2(10.0, 10.0),+            force: None,+        };+        let ctx = egui::Context::default();+        let before = ctx.style_of(egui::Theme::Dark).spacing.scroll;+        ctx.begin_pass(egui::RawInput { events: vec![touch], ..Default::default() });+        super::touch_scroll_target(&ctx);+        let after = ctx.style_of(egui::Theme::Dark).spacing.scroll;+        ctx.end_pass().drop_without_applying_deltas();++        assert!(after.floating, "the bar takes egui's floating (rest-thin) shape");+        assert_eq!(after.floating_width, before.bar_width, "the bar rests as thick as the theme drew it: {}", before.bar_width);+        assert_eq!(+            after.floating_allocated_width,+            before.bar_inner_margin + before.bar_width + before.bar_outer_margin,+            "the layout keeps the sliver the solid bar reserved, so content does not reflow"+        );+        assert_eq!(after.dormant_background_opacity, 1.0, "the resting bar is as visible as the solid one was");+        assert_eq!(after.dormant_handle_opacity, 1.0);+        assert!(after.interact_handle_opacity < 1.0, "the expanded bar is translucent over the content while the finger drags");++        // No touch screen: every field untouched (the floating branch is touch-only).+        let ctx = egui::Context::default();+        let before = ctx.style_of(egui::Theme::Dark).spacing.scroll;+        ctx.begin_pass(Default::default());+        super::touch_scroll_target(&ctx);+        let after = ctx.style_of(egui::Theme::Dark).spacing.scroll;+        ctx.end_pass().drop_without_applying_deltas();+        assert_eq!(after.floating, before.floating);+        assert_eq!(after.bar_width, before.bar_width);+    } }diff --git a/crates/ui-egui/tests/it/shared_docking_ui.rs b/crates/ui-egui/tests/it/shared_docking_ui.rs--- a/crates/ui-egui/tests/it/shared_docking_ui.rs+++ b/crates/ui-egui/tests/it/shared_docking_ui.rs@@ -374,6 +374,9 @@ fn accordion_resize_routes_pointer_and_keyboard_without_changing_closed_entries(     }     let DockNode::Stack { entries } = &h.state().ui.dock else { panic!("stack lost") };     assert_eq!(entries[0].height, Some(108.0));+    // The layout lands a frame after the state does: read the gutter where it now is, not where+    // it sat before the keyboard resize (the registry still holds that frame's rect).+    h.run_steps(2);     let from = rect(&h, "dock.gutter.s0").center();     drag(&mut h, from, from + vec2(0.0, 16.0), Modifiers::NONE);     let DockNode::Stack { entries } = &h.state().ui.dock else { panic!("stack lost") };diff --git a/crates/ui-egui/tests/it/ui_panels.rs b/crates/ui-egui/tests/it/ui_panels.rs--- a/crates/ui-egui/tests/it/ui_panels.rs+++ b/crates/ui-egui/tests/it/ui_panels.rs@@ -325,8 +325,8 @@ fn ui_scale_setting_sizes_the_interface() {     h.run_steps(2);     assert_eq!(scale(&h), (100, 1.0), "Cancel put it back from 200 %");     open_settings(&mut h);-    type_scale(&mut h, "50");-    assert_eq!(scale(&h), (75, 0.75), "at least 75");+    type_scale(&mut h, "10");+    assert_eq!(scale(&h), (50, 0.5), "at least 50");     type_scale(&mut h, "90");     click(&mut h, "settings.ok");     h.run_steps(2);diff --git a/docs/architecture.md b/docs/architecture.md--- a/docs/architecture.md+++ b/docs/architecture.md@@ -66,6 +66,30 @@ focused control into view. Active composition breadcrumbs use the selected-row t The `viewer_geometry` tests inspect actual painted contrast, scroll geometry and pointer/keyboard reachability across the three themes. +Two layers make a physical keyboard and mouse usable on Android. winit 0.30.13 (the last 0.30+release, pinned by eframe 0.36) reports no modifier state there — it ignores the meta state the OS+carries on every key event and never emits the `ModifiersChanged` egui-winit learns `Ctrl`/`Shift`/+`Alt` from — and drops every motion action that is not a touch, so a mouse has no hover, no wheel+and no right or middle button. The workspace therefore vends winit with that mouse path filled in+([third_party/winit](../third_party/winit/README.md): one Android-only file, pinned by+`[patch.crates-io]`; every other target compiles the published crate byte-for-byte) and stamps the+modifier keys onto each frame's key events itself (`stamp_android_modifiers` in `ui-egui`, driven+from `raw_input_hook` after the frame's events are gathered, before egui builds its input state).+The stamping also keeps a chord from degrading into its plain-key binding: without it a chord's+letter arrives unmodified and fires whatever that plain key is bound to — the Zoom tool for `Z` —+so the chord itself never runs. The vendored mouse path also matches Android's scroll direction on+the horizontal axis (the OS's `Hscroll` runs the other way round from winit's positive `x`) and+reports a held button once, deduplicating the events of a device that repeats `Down` while held+and also sends `ButtonPress` for the same press. Touch and touch-dragged stylus input keeps the+untouched `Touch` path there. The app side makes the chrome reachable under a finger as well:+panel content drags like a wheel does, the drawn scroll bar's hit area widens beyond its 6-point+line, and the dock's resize gutters stop eating the panel scroll drag; and `touch_scroll_target`+re-applies egui's scroll-bar width at 24 points every frame on a touch screen (or on Android), so+a theme change that rebuilds the styles can't drop the widening+(`a_touch_screen_widens_the_scroll_bar_target`). The same two layers were validated first on+FilmCraft's Android port (filmcraft `47f202cb`, `ecea29d6`, `685c9cc3`), and the scroll-bar work+on PhotoCraft's (PhotoCraft #6931).+ ## 2. Time  All time is an integer `Tick`, 254 016 000 000 per second, the least common multiple of everydiff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -1,6 +1,6 @@ # Where EffectCraft falls short -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (path-editing layer bounds, #1220; issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89)+> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-25 · **Change:** minor (G2: the Android arm64 port's touch pass — panels and scroll bars move under a finger, a 24-point scroll-bar hit re-applied every frame on touch screens, an overwriting in-app save asks Replace? first, thumbnails re-decode after a file changes — PR #1508; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89)  An honest assessment of how far EffectCraft is from being a real replacement for After Effects, and the work that closes the gap. This document is meant for contributors and agents choosing@@ -217,6 +217,17 @@ The most important missing piece: it turns every other estimate here into a meas   `shape_tool_options_paint_new_shapes`, `masks_of_every_shape_tool_kind`,   `tool_creates_mask_draws_a_mask_on_the_selected_shape_layer` and   `fill_options_paint_the_next_shape` (Tool Creates Shape / Mask, Fill and Stroke Options).+- Android evidence for [#1508](https://github.com/storytold/effectcraft/pull/1508), checked+  headlessly with egui's kittest: panels and scroll bars move under a finger, not only a wheel —+  panel content drags, the drawn scroll bar's hit area widens, dock resize gutters stop eating+  the panel scroll drag — and `a_touch_screen_widens_the_scroll_bar_target` re-applies a+  24-point scroll-bar hit every frame on a touch screen, so a theme change that rebuilds the+  styles cannot drop it. An in-app save that would overwrite an existing file asks+  "{name} already exists. Replace it?" first (translated in all seven input catalogs), and+  thumbnails re-decode when a file's size or modification time changes, so a re-saved image is+  not shown stale. The vendored winit's Android mouse-path fixes (hover, wheel direction,+  button press/release, dedup of a held button) are described in+  [architecture.md](architecture.md) §1 and [third_party/winit/README.md](../third_party/winit/README.md). - Done when: no open bug blocks a basic workflow (import, arrange, animate, preview, render) on   any of the three desktop platforms, and every bug users report gets triaged within a day. @@ -335,6 +346,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | G2: the Android arm64 port's touch pass (PR #1508) — panel scroll drag and widened scroll-bar hit (`touch_scroll_target`, re-applied per frame on touch screens), Replace? before an overwriting in-app save, thumbnails keyed by size+mtime, vendored winit Android mouse-path delta; `upstream/main` merged in | | 2026-10-16 | minor | Gap 8: #932 closed (Convert To Bezier Path converts a shape layer's parametric path, not only mask RotoBezier) | | 2026-10-13 | patch | G2: layer bounds no longer compete with editable shape/mask path points (#1220) | | 2026-10-12 | minor | Audited against `upstream/main` 470ea7a3: open-issue count and crash list, closed issues removed from rows 1, 3, 5, 6, 8–12, 15–17, 22–24, G8 and G9 (#759 `#include`), localization catalogs recounted (≈ 12% to ≈ 18%); rows 7, 14 and 21 left to open PRs #1102, #792 and #1098 |diff --git a/docs/preferences.md b/docs/preferences.md--- a/docs/preferences.md+++ b/docs/preferences.md@@ -62,7 +62,7 @@ Commands (CLI, MCP, control channel): - `appearance.theme`: the single theme from before appearance modes, kept for older settings files and automation. A saved file with only `theme` loads as a fixed Dark or Light mode showing it; setting it (`prefs.set`, `ui.set {"theme": …}`, `view.theme.dark|darker|light`) selects that theme and fixes the mode to its family. While the mode is Dark or Light it mirrors the theme in use. - `appearance.brightness`: Brightness - `appearance.uiScale`: UI Scale, the size of the whole interface (any whole percentage from-  75 to 200, on top of the display's own scaling), dragged or typed; it applies as soon as it is+  50 to 200, on top of the display's own scaling), dragged or typed; it applies as soon as it is   dragged or entered - `appearance.useLabelColorForHandles`: Use Label Color for Layer Handles and Paths - `grids.gridColor`: Color