diff --git a/ROADMAP.md b/ROADMAP.md index 69d3bf73..a89790a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. - **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%. - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index 7ee41ada..d9cd53ea 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -2,8 +2,8 @@ use std::cell::RefCell; use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use figcraft_engine::{Prefs, Session}; use figcraft_ui_egui::{DialogFuture, FigApp, Services}; @@ -20,6 +20,8 @@ pub struct Desktop { language: Option, /// Recovery copies of unsaved files (written at once after a panic). recovery: crate::recovery::Shared, + /// The folder of the last file or folder a dialog picked, where the next one starts. + last_folder: Arc>>, } pub fn config_dir() -> Option { @@ -54,7 +56,15 @@ impl Desktop { let reduce_motion = Arc::new(AtomicBool::new(false)); let flag = reduce_motion.clone(); std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed)); - Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, language: system_language(), recovery } + Self { + clipboard: arboard::Clipboard::new().ok(), + dir, + prefs, + reduce_motion, + language: system_language(), + recovery, + last_folder: Arc::default(), + } } } @@ -262,19 +272,57 @@ fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> { } } +/// The folder to start the next dialog in once `path` was picked: the file's folder, or the folder +/// picked itself. +pub(crate) fn folder_to_remember(path: &Path, is_folder: bool) -> Option { + let folder = if is_folder { Some(path) } else { path.parent() }; + folder.filter(|f| !f.as_os_str().is_empty()).map(Path::to_path_buf) +} + +/// The remembered folder, unless it is gone since: then the system's own choice. +pub(crate) fn start_folder(last: Option) -> Option { + last.filter(|f| f.is_dir()) +} + +impl Desktop { + /// A dialog that starts in the last folder used. The Linux portal does not remember it between + /// dialogs as macOS and Windows do. + fn dialog_in_last_folder(&self, title: &str) -> rfd::AsyncFileDialog { + let dialog = dialog(title); + match start_folder(self.last_folder.lock().ok().and_then(|l| l.clone())) { + Some(folder) => dialog.set_directory(folder), + None => dialog, + } + } +} + +/// `pick`, with the folder of what it picks kept in `last` for the next dialog. +pub(crate) fn remembering(last: Arc>>, pick: DialogFuture, is_folder: bool) -> DialogFuture { + Box::pin(async move { + let path = pick.await?; + if let (Some(folder), Ok(mut last)) = (folder_to_remember(&path, is_folder), last.lock()) { + *last = Some(folder); + } + Some(path) + }) +} + impl Services for Desktop { fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { - picked(dialog(title).add_filter("Supported files", exts).pick_file()) + let pick = picked(self.dialog_in_last_folder(title).add_filter("Supported files", exts).pick_file()); + remembering(self.last_folder.clone(), pick, false) } fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture { - let mut dialog = dialog(title).set_file_name(name); + let mut dialog = self.dialog_in_last_folder(title).set_file_name(name); for (label, extensions) in save_filters(exts) { dialog = dialog.add_filter(label, extensions); } - picked(dialog.save_file()) + let pick = picked(dialog.save_file()); + remembering(self.last_folder.clone(), pick, false) } fn pick_folder(&mut self, title: &str) -> DialogFuture { - picked(dialog(title).pick_folder()) + let pick = picked(self.dialog_in_last_folder(title).pick_folder()); + remembering(self.last_folder.clone(), pick, true) } fn clipboard_set_text(&mut self, s: &str) { if let Some(c) = self.clipboard.as_mut() { diff --git a/apps/figcraft/src/services_tests.rs b/apps/figcraft/src/services_tests.rs index 2abec5bb..970f1729 100644 --- a/apps/figcraft/src/services_tests.rs +++ b/apps/figcraft/src/services_tests.rs @@ -117,3 +117,28 @@ fn quitting_keeps_the_preferences_and_ends_the_live_sessions() { assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept"); let _ = std::fs::remove_dir_all(&dir); } + +/// #1290: the Linux portal opens every dialog where it likes, so the app keeps the folder itself. +#[test] +fn the_next_dialog_starts_in_the_folder_of_the_last_pick() { + use crate::services::{folder_to_remember, remembering, start_folder}; + use std::path::PathBuf; + let dir = root("last-folder"); + let file = dir.join("Poster.figcraft"); + assert_eq!(folder_to_remember(&file, false), Some(dir.clone()), "a file: its folder"); + assert_eq!(folder_to_remember(&dir, true), Some(dir.clone()), "a folder pick: the folder itself"); + assert_eq!(folder_to_remember(std::path::Path::new("Poster.figcraft"), false), None, "a bare name has no folder"); + + let last = Arc::new(Mutex::new(None::)); + let mut pick = remembering(last.clone(), Box::pin(std::future::ready(Some(file.clone()))), false); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + assert_eq!(pick.as_mut().poll(&mut cx), std::task::Poll::Ready(Some(file))); + assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a pick is remembered"); + let mut cancelled = remembering(last.clone(), Box::pin(std::future::ready(None)), false); + assert_eq!(cancelled.as_mut().poll(&mut cx), std::task::Poll::Ready(None)); + assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a cancelled dialog keeps the folder"); + + assert_eq!(start_folder(Some(dir.clone())), Some(dir.clone())); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(start_folder(Some(dir)), None, "a folder deleted since: the system's own"); +}