diff --git a/CLAUDE.md b/CLAUDE.md index b69d446..48047bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,6 +97,7 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/ - **Creating a note writes nothing.** `createNote()` opens a **local draft** (`DRAFT_ID`), persisted only on the first change worth keeping (`isWorthSaving`: title, content, source, tag, pin or deadline) — a blank row per opening would turn the canvas into a pile of things to tidy up. `draftMaterialisation` is load-bearing, and it holds the **promise** rather than the id: closing the editor commits the title, the source **then** the content back to back with no change detection and no `await` between them, so the second starts while the first is still writing the row. Holding the id — which only exists once the write returns — left that window answering "still a draft", and one close created two notes. Installed before the write leaves, it makes the later commits wait and land as an update of the row the first one created. Anything needing a real row calls `materialiseDraft()` first. - **The undo banner and the undo record are two different things.** `undoBanner()` is what the 8 s timer clears; `lastDeletion()` is what `Ctrl+Z` reads and it survives — hiding a suggestion is not withdrawing it. Only `dismissUndo()` gives up for good. - **The native side asks for an action, and the action set is generated.** One topic, `GLOBAL_ACTION_EVENT`, carrying a `GlobalAction` (`desktop.rs`, built with `closed_enum!`). Both cross into `bindings.ts` through `.typ::<…>()` and `.constant(…)` in `lib.rs` — neither needs a command to hang off — so the page's `switch` over the action is exhaustive and a variant added in Rust stops the front compiling. There used to be three topic strings spelled on both sides, where a typo made a subscription silently inert. Deliberately **not** `collect_events![…]`: that generates a `listen` per event and would leave the `EVENT_SUBSCRIBER` token every spec substitutes with nothing to stand in front of. +- **The window's geometry is remembered by a plugin, with one flag deliberately left out.** `tauri-plugin-window-state` saves size, position and maximized state; `WINDOW_STATE_FLAGS` (`lib.rs`) is **not** its default `all()`, because that carries `VISIBLE` — and quitting from the tray saves a hidden window, which the next launch would restore hidden, with nothing on screen. A unit test holds the flag out. The size in `tauri.conf.json` is only a first launch — and the window is declared `"visible": false` there and shown from `setup`, because the plugin restores the geometry _after_ the window would otherwise already be on screen: created visible, it showed the config size for ~190 ms and then jumped. - **A global shortcut is first-come, first-served across the machine, and the loser gets no error.** The palette is on `Ctrl+Alt+P` because `Ctrl+Alt+Space` is taken by widely installed applications. `desktop::init` records what it could not take, and `unavailable_shortcuts` lets the front say so at startup — a log line is not an interface. `init` is also where every piece of native state is managed, before any command can run. - **Every library operation reports, including when it changed nothing.** Exporting then re-importing at once imports zero notes (every id is already there) and that is correct; without `file.importedNothing` it is indistinguishable from a failure. Reports go to `StatusNotifier` (`core/services/notifications/`) and render under the titlebar — not inside the menu, which closes on the click and which a native dialog covers. - **The File menu owns its entries.** `FileMenuComponent` declares them as an array and injects what they need (`LibraryStore`, `NoteSelectionStore`, `SpacesStore`, `ClockService`); the array's order is the order on screen. There were three contribution registries here — menu, settings pages, shortcut groups — so `layout/` could stay ignorant of a feature; with one feature they protected nothing and hid what a button did behind a registration, so they are gone. Same for the preferences panel, which lists its two pages, and the shortcuts sheet, which builds the global group and imports the notes ones from `notes/ui/notes-shortcuts.ts` — the canvas group is derived from the key table that binds it, so a key cannot be documented without being bound. The trash and tag management deliberately stay **out** of the menu — they are views on the notes, so they sit next to the quick filters and at the end of the tag rail respectively. "Préférences…" and "Quitter", which act on the application rather than on a tool, are static entries in the menu itself. diff --git a/docs/architecture.md b/docs/architecture.md index f33e181..06b4e0e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1458,6 +1458,37 @@ would keep answering. lands a tick later; a component destroyed in between would otherwise stay subscribed for the whole session. +### Window geometry + +`tauri-plugin-window-state` remembers the size, the position and whether the window was +maximized, in `.window-state.json` beside the database. `tauri.conf.json` still declares a +size, and it is only a **first** launch: 1100×720, with a 640×480 floor so a window cannot be +restored — or dragged — to something nothing fits in. + +⚠️ The flags are explicit (`WINDOW_STATE_FLAGS`, `lib.rs`) and that is the whole subtlety. +The plugin's default is `all()`, which includes `VISIBLE`; quitting from the tray saves a +window that is **hidden**, and the next launch would restore it hidden — an application that +starts with nothing on screen and only a tray icon to be found by. A unit test holds the flag +out. `DECORATIONS` and `FULLSCREEN` are left out for the opposite reason: nothing here +changes either, so saving them stores noise. + +A minimized window needs no guard of ours — the plugin's `Moved` and `Resized` handlers +both skip one, which on Windows reports itself at -32000. And the file is written on +`RunEvent::Exit`, not on every move: the tray's "Quitter" is `app.exit(0)`, so it goes +through, while a force-kill saves nothing and leaves the previous geometry standing. + +⚠️ **The window is declared `"visible": false` and shown from `setup`.** The plugin restores +the geometry from `on_webview_ready`, which runs _after_ the window is on screen: created +visible, the window appeared at the config's size and then jumped to the remembered one. +Measured by polling the window rectangle through startup — 1116×759 at +172 ms, 900×600 at ++359 ms, so nearly 200 ms of the wrong window. Created hidden, there is one rectangle and no +jump. `setup` is also the right place rather than the front end: a front end that fails to +boot would otherwise leave a process with no window at all. + +`backgroundColor` is the dark `--bg-0`, for the same reason the dark palette is the base +one — the WebView paints white before the first frame, and the theme preference cannot be +read before Angular boots. + ### System tray DevBox stays resident in the notification area, and **the window's close button only hides it** diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d039829..cb868d7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -931,6 +931,7 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-wdio", "tauri-plugin-wdio-webdriver", + "tauri-plugin-window-state", "tauri-specta", "thiserror 2.0.20", "toml 1.1.3+spec-1.1.0", @@ -4766,6 +4767,21 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.13.1", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", +] + [[package]] name = "tauri-runtime" version = "2.11.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index aed86c9..02bcb6b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -47,6 +47,7 @@ tauri-plugin-dialog = "2" tauri-plugin-opener = "2" tauri-plugin-store = "2" tauri-plugin-clipboard-manager = "2" +tauri-plugin-window-state = "2.4.1" tauri-plugin-log = "2" log = "0.4" thiserror = "2.0.20" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 693638e..233a9b3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ pub(crate) mod closed_enum; pub(crate) mod count; use tauri::Manager; +use tauri_plugin_window_state::StateFlags; use tauri_specta::{Builder, collect_commands}; use attachments::{ @@ -103,6 +104,18 @@ fn ipc_builder() -> Builder { .constant("APP_METADATA", app_info::METADATA) } +/// ⚠️ **Not** the plugin's default, which is `all()` — and `all()` carries `VISIBLE`. +/// Quitting from the tray saves a window that is hidden, and the next launch would +/// restore it hidden: an application that starts with nothing on screen and only a tray +/// icon to be found by. `DECORATIONS` and `FULLSCREEN` are left out for the opposite +/// reason — nothing here changes either, so saving them stores noise. +/// +/// A minimized window is the plugin's own problem and it handles it: its `Moved` and +/// `Resized` handlers both skip one, which on Windows reports itself at -32000. +const WINDOW_STATE_FLAGS: StateFlags = StateFlags::SIZE + .union(StateFlags::POSITION) + .union(StateFlags::MAXIMIZED); + /// Order matters here, and only here: `single_instance` has to come before every /// other plugin, and `log` before the plugins that already log during their own /// initialisation. @@ -131,7 +144,12 @@ fn with_plugins(builder: tauri::Builder) -> tauri::Builder) -> tauri::Builder Result<(), Box> { // `tauri.conf.json` carries the product name, which is the crate's and is // lowercase; the window wears the name the user is shown everywhere else. + // + // ⚠️ The window is declared `"visible": false` and is shown **here**, because + // `tauri-plugin-window-state` restores the geometry from `on_webview_ready` — which + // has already run by the time `setup` does. Created visible, the window appeared at + // the config's size for ~190 ms and then jumped to the remembered one, measured. + // Showing it first, and here rather than from the front end, is also what keeps a + // front end that fails to boot from leaving a process with no window at all. if let Some(window) = app.get_webview_window("main") { window.set_title(app_info::METADATA.name)?; + window.show()?; } app.handle() @@ -236,3 +262,16 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while launching the Tauri application"); } + +#[cfg(test)] +mod tests { + use super::{StateFlags, WINDOW_STATE_FLAGS}; + + /// The one flag that turns "remembers its geometry" into "does not come back". + #[test] + fn the_window_state_never_remembers_that_it_was_hidden() { + assert!(!WINDOW_STATE_FLAGS.contains(StateFlags::VISIBLE)); + assert!(WINDOW_STATE_FLAGS.contains(StateFlags::SIZE)); + assert!(WINDOW_STATE_FLAGS.contains(StateFlags::POSITION)); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 817a0d1..59ab12b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -12,8 +12,12 @@ "windows": [ { "title": "devbox", - "width": 800, - "height": 600 + "width": 1100, + "height": 720, + "minWidth": 640, + "minHeight": 480, + "visible": false, + "backgroundColor": "#15171c" } ], "security": {