From 99f0331528f5b1170dd67c961a415993ac6657f0 Mon Sep 17 00:00:00 2001 From: Lucas Lampietti Date: Thu, 13 Aug 2026 10:47:02 +0200 Subject: [PATCH 1/4] feat(window): open folders in separate windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add File › New Window (⌘N) for an empty window and File › Open Folder in New Window… (⇧⌘N) to open a picked folder in its own window. Each window keeps its own document, outline, search and scroll position. The pending-open buffer becomes a map keyed by window label so a window only ever drains the folder meant for it; a new PendingOpen::Empty marker keeps a ⌘N window on the welcome screen instead of restoring lastFolder, which would just clone the window it was spawned from. Spawned windows are labelled viewer-N and allowlisted in the capability, without which they would come up with no plugin permissions at all. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 3 +- docs/guide/features.md | 16 +++ docs/reference/keyboard-shortcuts.md | 9 +- src-tauri/capabilities/default.json | 2 +- src-tauri/src/lib.rs | 149 ++++++++++++++++++++++++--- src/main.ts | 47 +++++---- src/utils.test.ts | 41 ++++++++ src/utils.ts | 34 ++++++ 8 files changed, 265 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index eb056ec..b0d8a1a 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,11 @@ A native macOS Markdown viewer built with [Tauri v2](https://v2.tauri.app/). Bro - **Relative link navigation** — Click `.md` links to navigate between documents - **Dark mode** — Follows macOS system appearance, toggleable manually - **Session persistence** — Remembers your last opened folder across launches +- **Multiple windows** — Open a second folder side by side in its own window (Cmd+N for an empty window, Shift+Cmd+N to pick a folder). Each window keeps its own document, outline and search - **Open single files** — Open `.md` files directly via CLI, Finder "Open With", or drag & drop - **PDF export** — Export the current document as PDF with native rendering - **CLI support** — Open a folder or file directly: `mdv ~/docs` or `mdv ~/docs/README.md` -- **Native menu** — Cmd+O to open a folder, standard macOS app menu +- **Native menu** — Cmd+O to open a file, Shift+Cmd+O for a folder, standard macOS app menu ### Markdown Rendering diff --git a/docs/guide/features.md b/docs/guide/features.md index 991ff0f..4c1fa05 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -8,6 +8,22 @@ Open any folder and navigate its directory tree via the sidebar. The sidebar sho A breadcrumb bar at the top shows your current location and lets you navigate back to parent directories. +## Multiple Windows + +Two folders can be read side by side, each in its own window: + +- **File › New Window** (`Cmd+N`) opens an empty window on the welcome screen. +- **File › Open Folder in New Window…** (`Shift+Cmd+N`) picks a folder and opens + it in a new window, leaving the current window untouched. + +Windows are fully independent — document, outline, search state and scroll +position are per window. + +The recent-files list is shared (the native "Open Recent" submenu is app-wide). +Preferences (theme, fonts, language) are stored once for the app and applied +when a window opens, so changing them updates the window you changed them in and +every window opened afterwards. + ## Markdown Rendering Full GitHub-Flavored Markdown (GFM) support including: diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 54fe425..fd5957f 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -4,9 +4,16 @@ | Shortcut | Action | |---|---| -| **Cmd+O** | Open folder | +| **Cmd+O** | Open file | +| **Shift+Cmd+O** | Open folder | +| **Cmd+N** | New window (welcome screen) | +| **Shift+Cmd+N** | Open a folder in a new window | +| **Cmd+W** | Close the current window | | **Cmd+Q** | Quit application | +Windows are independent: each one keeps its own document, outline, search and +scroll position. + ## Overlays | Shortcut | Action | diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index ad4c54e..81dd7b5 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default permissions for the app", - "windows": ["main"], + "windows": ["main", "viewer-*"], "permissions": [ "core:default", "core:window:allow-start-dragging", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a4cc871..48b1027 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,13 +1,19 @@ +use std::collections::HashMap; use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Mutex, OnceLock}; use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, Submenu, SubmenuBuilder}; -use tauri::{Emitter, Manager, Wry}; +use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder, Wry}; #[derive(Clone, serde::Serialize)] #[serde(tag = "kind", rename_all = "lowercase")] enum PendingOpen { File { path: String }, Folder { path: String }, + /// A freshly spawned window with nothing to open: it must show the welcome + /// screen instead of restoring the last folder (which would make "New + /// Window" a duplicate of the window it was spawned from). + Empty, } #[derive(serde::Deserialize)] @@ -81,18 +87,107 @@ fn update_recent_menu(app: tauri::AppHandle, items: Vec) -> Result<( Ok(()) } -// Global slot — available from process start, so `RunEvent::Opened` can write +// Global map — available from process start, so `RunEvent::Opened` can write // safely even if it fires before `setup` finishes (which can happen on macOS // cold-start via Apple Events). -static PENDING_OPEN: OnceLock>> = OnceLock::new(); +// +// Keyed by window label: each window drains only its own entry, so spawning a +// second window with its own folder can never steal the main window's pending +// open (or vice-versa). +static PENDING_OPEN: OnceLock>> = OnceLock::new(); + +const MAIN_WINDOW_LABEL: &str = "main"; + +fn pending_map() -> &'static Mutex> { + PENDING_OPEN.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn set_pending(label: &str, pending: PendingOpen) { + if let Ok(mut map) = pending_map().lock() { + map.insert(label.to_string(), pending); + } +} + +#[tauri::command] +fn get_pending_open(window: tauri::Window) -> Option { + pending_map() + .lock() + .ok() + .and_then(|mut map| map.remove(window.label())) +} -fn pending_slot() -> &'static Mutex> { - PENDING_OPEN.get_or_init(|| Mutex::new(None)) +// Labels for spawned windows. `viewer-*` is allowlisted in +// capabilities/default.json — a new prefix here needs a matching entry there or +// the window comes up without any plugin permissions. +static WINDOW_SEQ: AtomicUsize = AtomicUsize::new(1); + +fn next_window_label(app: &tauri::AppHandle) -> String { + loop { + let n = WINDOW_SEQ.fetch_add(1, Ordering::Relaxed); + let label = format!("viewer-{n}"); + if app.get_webview_window(&label).is_none() { + return label; + } + } } +/// Open a new app window, optionally on `path` (a folder or a single file). +/// +/// Sync on purpose: Tauri runs non-async commands on the main thread, which is +/// where macOS window creation has to happen. #[tauri::command] -fn get_pending_open() -> Option { - pending_slot().lock().ok().and_then(|mut g| g.take()) +fn open_new_window(app: tauri::AppHandle, path: Option) -> Result { + let label = next_window_label(&app); + + // Buffer *before* building the window: the frontend pulls its pending open + // during init, which can start as soon as the webview exists. + let pending = match path.as_deref() { + Some(p) if Path::new(p).is_file() => PendingOpen::File { path: p.to_string() }, + Some(p) if Path::new(p).is_dir() => PendingOpen::Folder { path: p.to_string() }, + _ => PendingOpen::Empty, + }; + set_pending(&label, pending); + + let mut builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::default()) + .title("Markdown Viewer") + .inner_size(1200.0, 800.0) + .min_inner_size(800.0, 500.0) + .resizable(true); + + #[cfg(target_os = "macos")] + { + // Mirror the main window's chrome (see app.windows in tauri.conf.json): + // the frontend draws its own title bar over the native traffic lights. + builder = builder + .title_bar_style(tauri::TitleBarStyle::Overlay) + .hidden_title(true); + } + + // Cascade off the frontmost window so a new window never lands exactly on + // top of the one it was spawned from. + if let Some(parent) = focused_window(&app) { + if let (Ok(pos), Ok(scale)) = (parent.outer_position(), parent.scale_factor()) { + let logical = pos.to_logical::(scale); + builder = builder.position(logical.x + 28.0, logical.y + 28.0); + } + } + + builder.build().map_err(|e| e.to_string())?; + Ok(label) +} + +/// The frontmost window, falling back to any window (macOS can dispatch a menu +/// command while no window holds focus, e.g. right after the last one closed). +/// +/// `Manager::get_focused_window` would be the direct route, but it sits behind +/// tauri's `unstable` feature — this walks the stable webview-window map. +fn focused_window(app: &tauri::AppHandle) -> Option { + let windows = app.webview_windows(); + windows + .values() + .find(|w| w.is_focused().unwrap_or(false)) + .or_else(|| windows.values().next()) + .cloned() } #[tauri::command] @@ -340,6 +435,7 @@ pub fn run(path_arg: Option) { ping, export_pdf, get_pending_open, + open_new_window, list_system_fonts, themes_dir, list_disk_themes, @@ -362,6 +458,17 @@ pub fn run(path_arg: Option) { .accelerator("CmdOrCtrl+Shift+O") .build(app)?; + let new_window = MenuItemBuilder::with_id("new_window", "New Window") + .accelerator("CmdOrCtrl+N") + .build(app)?; + + let open_folder_new_window = MenuItemBuilder::with_id( + "open_folder_new_window", + "Open Folder in New Window…", + ) + .accelerator("CmdOrCtrl+Shift+N") + .build(app)?; + let recent_submenu = SubmenuBuilder::new(app, "Open Recent") .item( &MenuItemBuilder::with_id("recent_none", "No Recent Files") @@ -410,7 +517,9 @@ pub fn run(path_arg: Option) { .quit() .build()?, &SubmenuBuilder::new(app, "File") - .items(&[&open_file, &open_folder]) + .items(&[&new_window]) + .separator() + .items(&[&open_file, &open_folder, &open_folder_new_window]) .item(&recent_submenu) .separator() .items(&[&print_item, &export_pdf_item]) @@ -440,6 +549,19 @@ pub fn run(path_arg: Option) { app.on_menu_event(move |_app, event| { let id = event.id().0.as_str(); match id { + // Handled entirely in Rust: no window needs to be involved, + // and the new window may be the first one (nothing focused). + "new_window" => { + if let Err(e) = open_new_window(app_handle.clone(), None) { + eprintln!("[menu] new window failed: {e}"); + } + } + // The folder picker lives in the frontend (dialog plugin), so + // the window prompts and then asks Rust for a window on the + // chosen folder. + "open_folder_new_window" => { + let _ = app_handle.emit("menu-open-folder-new-window", ()); + } "open_file" => { let _ = app_handle.emit("menu-open-file", ()); } @@ -493,9 +615,7 @@ pub fn run(path_arg: Option) { None }; if let Some(p) = pending { - if let Ok(mut slot) = pending_slot().lock() { - *slot = Some(p); - } + set_pending(MAIN_WINDOW_LABEL, p); } } @@ -527,10 +647,13 @@ pub fn run(path_arg: Option) { let _ = win.unminimize(); let _ = win.show(); let _ = win.set_focus(); - } else if let Ok(mut slot) = pending_slot().lock() { + } else { // Cold-start: Apple Events fired before setup completed. // Buffer so the frontend can pull it on init. - *slot = Some(PendingOpen::File { path: path_str }); + set_pending( + MAIN_WINDOW_LABEL, + PendingOpen::File { path: path_str }, + ); } } } diff --git a/src/main.ts b/src/main.ts index a5c3d34..613f7d7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -73,7 +73,7 @@ import { toHexForPicker, } from "./theme-editor"; import { createSearchController, type SearchController } from "./search"; -import type { Entry, RecentEntry } from "./utils"; +import type { Entry, PendingOpen, RecentEntry } from "./utils"; import { classifyLink, extractRootName, @@ -82,6 +82,7 @@ import { getFullPath, mergeRecent, parseMarkdownHref, + resolveInitialView, resolvePath, } from "./utils"; @@ -508,10 +509,6 @@ async function openExamples(): Promise { await setRootPath(examplesPath); } -type PendingOpen = - | { kind: "file"; path: string } - | { kind: "folder"; path: string }; - async function openFileFromPath(filePath: string): Promise { const lastSep = filePath.lastIndexOf("/"); const parentDir = filePath.substring(0, lastSep); @@ -1483,6 +1480,9 @@ async function init(): Promise { appWindow.listen("menu-open-file", () => { openFile(); }); + appWindow.listen("menu-open-folder-new-window", () => { + openFolderInNewWindow(); + }); appWindow.listen("menu-print", () => { printDocument(); }); @@ -1512,23 +1512,17 @@ async function init(): Promise { // Populate the native "Open Recent" submenu from the persisted list. await syncRecentMenu(await loadRecents()); - // Cold-start: pull anything the backend buffered (CLI arg or RunEvent::Opened - // that fired before our listener was registered). A pending open wins over - // the saved folder so the user doesn't see a flash of the previous folder. + // Pull anything the backend buffered for *this* window: a CLI arg, a + // RunEvent::Opened that fired before our listener was registered, or the + // folder handed to a window spawned from the File menu. const pending = await invoke("get_pending_open"); - if (pending?.kind === "file") { - await openFileFromPath(pending.path); - return; - } - if (pending?.kind === "folder") { - await setRootPath(pending.path); - return; - } - - const saved = await loadRootPath(); - if (saved) { - await setRootPath(saved); + const view = resolveInitialView(pending, await loadRootPath()); + if (view.kind === "file") { + await openFileFromPath(view.path); + } else if (view.kind === "folder") { + await setRootPath(view.path); } + // "welcome": nothing to do, the empty state is what index.html starts on. } async function setRootPath(path: string, fileToOpen?: string): Promise { @@ -1567,6 +1561,19 @@ async function openFile(): Promise { } } +// The picker has to run here (dialog plugin is frontend-side); Rust then spawns +// the window and hands it the folder through its own pending-open slot, so this +// window's state is left untouched. +async function openFolderInNewWindow(): Promise { + const selected = await open({ directory: true, multiple: false }); + if (typeof selected !== "string") return; + try { + await invoke("open_new_window", { path: selected }); + } catch (e) { + console.error("Failed to open a new window:", e); + } +} + // --- Filesystem --- async function listEntries(dirPath: string): Promise { diff --git a/src/utils.test.ts b/src/utils.test.ts index 0688e87..e6da376 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -8,6 +8,7 @@ import { parseMarkdownHref, findReadme, mergeRecent, + resolveInitialView, } from "./utils"; import type { RecentEntry } from "./utils"; @@ -269,3 +270,43 @@ describe("mergeRecent", () => { expect(list).toEqual([f("/a")]); }); }); + +describe("resolveInitialView", () => { + it("opens a pending file", () => { + expect(resolveInitialView({ kind: "file", path: "/docs/a.md" }, null)).toEqual({ + kind: "file", + path: "/docs/a.md", + }); + }); + + it("opens a pending folder", () => { + expect(resolveInitialView({ kind: "folder", path: "/docs" }, null)).toEqual({ + kind: "folder", + path: "/docs", + }); + }); + + it("prefers a pending open over the saved folder", () => { + expect(resolveInitialView({ kind: "folder", path: "/docs" }, "/old")).toEqual({ + kind: "folder", + path: "/docs", + }); + }); + + it("shows the welcome screen for a window spawned empty", () => { + expect(resolveInitialView({ kind: "empty" }, "/old")).toEqual({ + kind: "welcome", + }); + }); + + it("restores the saved folder when nothing is pending", () => { + expect(resolveInitialView(null, "/old")).toEqual({ + kind: "folder", + path: "/old", + }); + }); + + it("shows the welcome screen with no pending open and no saved folder", () => { + expect(resolveInitialView(null, null)).toEqual({ kind: "welcome" }); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index f7312d5..b02cc6d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -95,6 +95,40 @@ export function findReadme(entries: Entry[]): Entry | undefined { ); } +/** What the Rust `get_pending_open` command buffered for this window. */ +export type PendingOpen = + | { kind: "file"; path: string } + | { kind: "folder"; path: string } + | { kind: "empty" }; + +/** What a window should display right after init. */ +export type InitialView = + | { kind: "file"; path: string } + | { kind: "folder"; path: string } + | { kind: "welcome" }; + +/** + * Decide what a window shows on startup. + * + * A pending open (CLI arg, Finder "Open With", or a folder handed to a freshly + * spawned window) always wins over the persisted folder, so the user never sees + * a flash of the previous folder. `kind: "empty"` is the marker for a window + * spawned by "New Window": it must land on the welcome screen rather than + * restoring `lastFolder`, otherwise it would just clone the window it came from. + */ +export function resolveInitialView( + pending: PendingOpen | null, + savedFolder: string | null +): InitialView { + if (pending) { + if (pending.kind === "file") return { kind: "file", path: pending.path }; + if (pending.kind === "folder") return { kind: "folder", path: pending.path }; + return { kind: "welcome" }; + } + if (savedFolder) return { kind: "folder", path: savedFolder }; + return { kind: "welcome" }; +} + export type RecentEntry = { path: string; kind: "file" | "folder" }; /** From 40756b19961116fc3e4ecadc2fea289190f65be4 Mon Sep 17 00:00:00 2001 From: Lucas Lampietti Date: Thu, 13 Aug 2026 10:47:41 +0200 Subject: [PATCH 2/4] fix(menu): deliver menu commands to the frontmost window only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app_handle.emit broadcasts to every webview, which was harmless with a single window but means a second window turns one ⌘F into two focused search fields and one "Open Folder…" into two folder pickers. Route every menu command through emit_to_focused instead, and target a Finder "Open With" on a running app at the frontmost window rather than all of them. focused_window walks webview_windows() because Manager::get_focused_window sits behind tauri's unstable feature. Co-Authored-By: Claude Opus 5 (1M context) --- docs/guide/features.md | 3 +- docs/reference/keyboard-shortcuts.md | 3 +- src-tauri/src/lib.rs | 49 ++++++++++++++++++---------- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/docs/guide/features.md b/docs/guide/features.md index 4c1fa05..5671372 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -17,7 +17,8 @@ Two folders can be read side by side, each in its own window: it in a new window, leaving the current window untouched. Windows are fully independent — document, outline, search state and scroll -position are per window. +position are per window. Menu commands and their shortcuts (Find, Print, Export +as PDF, Open Recent, Toggle Dark Mode…) act on the frontmost window only. The recent-files list is shared (the native "Open Recent" submenu is app-wide). Preferences (theme, fonts, language) are stored once for the app and applied diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index fd5957f..798b087 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -12,7 +12,8 @@ | **Cmd+Q** | Quit application | Windows are independent: each one keeps its own document, outline, search and -scroll position. +scroll position. Menu commands (Find, Print, Export as PDF, Open Recent…) always +apply to the frontmost window. ## Overlays diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 48b1027..a172fde 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -190,6 +190,19 @@ fn focused_window(app: &tauri::AppHandle) -> Option { .cloned() } +// Menu commands act on the frontmost window only. A plain `emit` broadcasts to +// every webview, so with two windows open a single ⌘F (or "Open Folder…") would +// fire in both — two folder pickers, two focused search fields. +fn emit_to_focused( + app: &tauri::AppHandle, + event: &str, + payload: S, +) { + if let Some(win) = focused_window(app) { + let _ = app.emit_to(win.label(), event, payload); + } +} + #[tauri::command] fn print_webview(webview: tauri::Webview) -> Result<(), String> { webview.print().map_err(|e| e.to_string()) @@ -560,42 +573,44 @@ pub fn run(path_arg: Option) { // the window prompts and then asks Rust for a window on the // chosen folder. "open_folder_new_window" => { - let _ = app_handle.emit("menu-open-folder-new-window", ()); + emit_to_focused(&app_handle, "menu-open-folder-new-window", ()); } "open_file" => { - let _ = app_handle.emit("menu-open-file", ()); + emit_to_focused(&app_handle, "menu-open-file", ()); } "open_folder" => { - let _ = app_handle.emit("menu-open-folder", ()); + emit_to_focused(&app_handle, "menu-open-folder", ()); } "print" => { - let _ = app_handle.emit("menu-print", ()); + emit_to_focused(&app_handle, "menu-print", ()); } "export_pdf" => { - let _ = app_handle.emit("menu-export-pdf", ()); + emit_to_focused(&app_handle, "menu-export-pdf", ()); } "toggle_theme" => { - let _ = app_handle.emit("menu-toggle-theme", ()); + emit_to_focused(&app_handle, "menu-toggle-theme", ()); } "preferences" => { - let _ = app_handle.emit("menu-open-preferences", ()); + emit_to_focused(&app_handle, "menu-open-preferences", ()); } "find" => { - let _ = app_handle.emit("menu-find", ()); + emit_to_focused(&app_handle, "menu-find", ()); } "recent_clear" => { - let _ = app_handle.emit("menu-clear-recent", ()); + emit_to_focused(&app_handle, "menu-clear-recent", ()); } _ if id.starts_with(RECENT_FILE_PREFIX) => { let path = id[RECENT_FILE_PREFIX.len()..].to_string(); - let _ = app_handle.emit( + emit_to_focused( + &app_handle, "menu-open-recent", RecentOpen { kind: "file".into(), path }, ); } _ if id.starts_with(RECENT_FOLDER_PREFIX) => { let path = id[RECENT_FOLDER_PREFIX.len()..].to_string(); - let _ = app_handle.emit( + emit_to_focused( + &app_handle, "menu-open-recent", RecentOpen { kind: "folder".into(), path }, ); @@ -638,12 +653,12 @@ pub fn run(path_arg: Option) { if let Ok(path) = url.to_file_path() { if path.is_file() { let path_str = path.to_string_lossy().to_string(); - let windows = app_handle.webview_windows(); - if let Some(win) = windows.values().next() { - // Hot-start: a window already exists, the JS listener - // is registered. Emit directly; the frontend's cold-start - // pull-from-buffer has already drained any prior value. - let _ = app_handle.emit("open-file", path_str); + // Hot-start: a window already exists, the JS listener is + // registered. Emit to the frontmost one (falling back to + // any window) so the document replaces that window's + // content and not every window's. + if let Some(win) = focused_window(app_handle) { + let _ = app_handle.emit_to(win.label(), "open-file", path_str); let _ = win.unminimize(); let _ = win.show(); let _ = win.set_focus(); From de45952189827af9b5b74451b46d8d68791f2b2f Mon Sep 17 00:00:00 2001 From: Lucas Lampietti Date: Thu, 13 Aug 2026 10:49:15 +0200 Subject: [PATCH 3/4] feat(window): list open windows in the Window menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Window submenu holding one checkable entry per open window, checked on the frontmost one, that brings the picked window forward. The list is rebuilt from every event that can change it: window opened, closed, focused, or renamed. macOS can populate a Window menu itself via setWindowsMenu:, but AppKit only adopts windows created after that call — our main window already exists when the menu is built, and tao's windows were never picked up at all — so the entries are built here instead. Windows are also named after the document they show (or their folder), or the list would read as several identical "Markdown Viewer" entries. Renaming goes through a set_window_title command that updates the title and the menu together, so the two cannot drift apart. Co-Authored-By: Claude Opus 5 (1M context) --- docs/guide/features.md | 4 + docs/reference/keyboard-shortcuts.md | 4 +- src-tauri/src/lib.rs | 140 ++++++++++++++++++++++++++- src/main.ts | 16 ++- src/utils.test.ts | 19 ++++ src/utils.ts | 19 ++++ 6 files changed, 199 insertions(+), 3 deletions(-) diff --git a/docs/guide/features.md b/docs/guide/features.md index 5671372..b71de8b 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -16,6 +16,10 @@ Two folders can be read side by side, each in its own window: - **File › Open Folder in New Window…** (`Shift+Cmd+N`) picks a folder and opens it in a new window, leaving the current window untouched. +The **Window** menu lists every open window and switches to the one you pick +(the active one is checked). Each window is named after the document it shows — +or its folder when no document is open yet — so the list stays readable. + Windows are fully independent — document, outline, search state and scroll position are per window. Menu commands and their shortcuts (Find, Print, Export as PDF, Open Recent, Toggle Dark Mode…) act on the frontmost window only. diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 798b087..23fb92a 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -8,12 +8,14 @@ | **Shift+Cmd+O** | Open folder | | **Cmd+N** | New window (welcome screen) | | **Shift+Cmd+N** | Open a folder in a new window | +| **Cmd+M** | Minimize the current window | | **Cmd+W** | Close the current window | | **Cmd+Q** | Quit application | Windows are independent: each one keeps its own document, outline, search and scroll position. Menu commands (Find, Print, Export as PDF, Open Recent…) always -apply to the frontmost window. +apply to the frontmost window. The **Window** menu lists the open windows and +switches to the one you select. ## Overlays diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a172fde..797bd75 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,7 +2,9 @@ use std::collections::HashMap; use std::path::Path; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Mutex, OnceLock}; -use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, Submenu, SubmenuBuilder}; +use tauri::menu::{ + CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder, PredefinedMenuItem, Submenu, SubmenuBuilder, +}; use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder, Wry}; #[derive(Clone, serde::Serialize)] @@ -87,6 +89,103 @@ fn update_recent_menu(app: tauri::AppHandle, items: Vec) -> Result<( Ok(()) } +// Handle to the "Window" submenu, whose tail is one entry per open window. +// +// macOS can populate a Window menu on its own (`setWindowsMenu:`), but AppKit +// only adopts windows it sees created after that call — our main window already +// exists by then, and tao's windows never got added at all. So the list is built +// here and refreshed on every event that can change it: window opened, closed, +// focused, or renamed by the frontend. +static WINDOW_SUBMENU: OnceLock>>> = OnceLock::new(); + +const WINDOW_ITEM_PREFIX: &str = "window:"; + +fn window_slot() -> &'static Mutex>> { + WINDOW_SUBMENU.get_or_init(|| Mutex::new(None)) +} + +/// Order windows the way the user created them: the original window first, then +/// the spawned ones by number (so `viewer-10` sorts after `viewer-9`). +fn window_sort_key(label: &str) -> (u8, u32, String) { + if label == MAIN_WINDOW_LABEL { + return (0, 0, String::new()); + } + let n = label + .rsplit('-') + .next() + .and_then(|s| s.parse::().ok()) + .unwrap_or(u32::MAX); + (1, n, label.to_string()) +} + +fn rebuild_window_menu(app: &tauri::AppHandle) -> Result<(), String> { + let guard = window_slot().lock().map_err(|e| e.to_string())?; + let Some(submenu) = guard.as_ref() else { + return Ok(()); // menu not built yet — setup will do the first pass + }; + + let count = submenu.items().map_err(|e| e.to_string())?.len(); + for _ in 0..count { + submenu.remove_at(0).map_err(|e| e.to_string())?; + } + + let minimize = PredefinedMenuItem::minimize(app, None).map_err(|e| e.to_string())?; + let zoom = PredefinedMenuItem::maximize(app, None).map_err(|e| e.to_string())?; + let fullscreen = PredefinedMenuItem::fullscreen(app, None).map_err(|e| e.to_string())?; + let close = PredefinedMenuItem::close_window(app, None).map_err(|e| e.to_string())?; + let sep1 = PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?; + let sep2 = PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?; + for item in [ + &minimize as &dyn tauri::menu::IsMenuItem, + &zoom, + &sep1, + &fullscreen, + &close, + &sep2, + ] { + submenu.append(item).map_err(|e| e.to_string())?; + } + + let mut windows: Vec<_> = app + .webview_windows() + .into_iter() + .map(|(label, win)| { + let title = win.title().unwrap_or_default(); + let focused = win.is_focused().unwrap_or(false); + (label, title, focused) + }) + .collect(); + windows.sort_by(|a, b| window_sort_key(&a.0).cmp(&window_sort_key(&b.0))); + + for (label, title, focused) in windows { + let text = if title.is_empty() { + "Markdown Viewer".to_string() + } else { + title + }; + let item = CheckMenuItemBuilder::with_id(format!("{WINDOW_ITEM_PREFIX}{label}"), text) + .checked(focused) + .build(app) + .map_err(|e| e.to_string())?; + submenu.append(&item).map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// Rename this window and refresh its entry in the Window menu. +/// +/// Goes through an app command rather than the frontend calling `setTitle` +/// directly, so the menu can never drift from the actual titles. +#[tauri::command] +fn set_window_title( + app: tauri::AppHandle, + window: tauri::Window, + title: String, +) -> Result<(), String> { + window.set_title(&title).map_err(|e| e.to_string())?; + rebuild_window_menu(&app) +} + // Global map — available from process start, so `RunEvent::Opened` can write // safely even if it fires before `setup` finishes (which can happen on macOS // cold-start via Apple Events). @@ -173,6 +272,9 @@ fn open_new_window(app: tauri::AppHandle, path: Option) -> Result) { export_pdf, get_pending_open, open_new_window, + set_window_title, list_system_fonts, themes_dir, list_disk_themes, @@ -512,6 +615,13 @@ pub fn run(path_arg: Option) { .accelerator("CmdOrCtrl+F") .build(app)?; + // Contents are filled in by rebuild_window_menu once the handle is + // stored below; it appends the fixed items plus one entry per window. + let window_menu = SubmenuBuilder::new(app, "Window").build()?; + if let Ok(mut slot) = window_slot().lock() { + *slot = Some(window_menu.clone()); + } + let app_name = app.package_info().name.clone(); let menu = MenuBuilder::new(app) @@ -553,11 +663,16 @@ pub fn run(path_arg: Option) { &SubmenuBuilder::new(app, "View") .items(&[&toggle_theme]) .build()?, + &window_menu, ]) .build()?; app.set_menu(menu)?; + if let Err(e) = rebuild_window_menu(app.handle()) { + eprintln!("[menu] initial window list failed: {e}"); + } + let app_handle = app.handle().clone(); app.on_menu_event(move |_app, event| { let id = event.id().0.as_str(); @@ -599,6 +714,19 @@ pub fn run(path_arg: Option) { "recent_clear" => { emit_to_focused(&app_handle, "menu-clear-recent", ()); } + // One entry per open window: bring the picked one forward. + _ if id.starts_with(WINDOW_ITEM_PREFIX) => { + let label = &id[WINDOW_ITEM_PREFIX.len()..]; + if let Some(win) = app_handle.get_webview_window(label) { + let _ = win.unminimize(); + let _ = win.show(); + let _ = win.set_focus(); + } + // Focus moved, so the checkmark has to move with it. The + // Focused event does this too, but rebuilding here keeps + // the menu right even if focus is refused. + let _ = rebuild_window_menu(&app_handle); + } _ if id.starts_with(RECENT_FILE_PREFIX) => { let path = id[RECENT_FILE_PREFIX.len()..].to_string(); emit_to_focused( @@ -647,6 +775,16 @@ pub fn run(path_arg: Option) { .expect("error while building tauri application"); app.run(|app_handle, event| { + // Keep the Window menu's list and checkmark in step with reality. + if let tauri::RunEvent::WindowEvent { ref event, .. } = event { + if matches!( + event, + tauri::WindowEvent::Destroyed | tauri::WindowEvent::Focused(true) + ) { + let _ = rebuild_window_menu(app_handle); + } + } + #[cfg(target_os = "macos")] if let tauri::RunEvent::Opened { ref urls } = event { for url in urls { diff --git a/src/main.ts b/src/main.ts index 613f7d7..f01ca52 100644 --- a/src/main.ts +++ b/src/main.ts @@ -84,6 +84,7 @@ import { parseMarkdownHref, resolveInitialView, resolvePath, + windowTitle, } from "./utils"; // --- Theme & preferences management --- @@ -441,6 +442,18 @@ const searchOptWholeWord = document.getElementById( "search-opt-wholeword" ) as HTMLInputElement; +const appWindow = getCurrentWindow(); + +// Keeps the native window title in sync with what the window shows. The title is +// hidden in our custom title bar; it exists so this window is identifiable in the +// native Window menu, which is why renaming goes through the Rust command that +// also refreshes that menu. +function syncWindowTitle(): void { + void invoke("set_window_title", { + title: windowTitle(activeFile, rootName), + }).catch((e) => console.warn("Failed to set the window title:", e)); +} + let rootPath: string | null = null; let rootName = ""; let currentPath: string[] = []; @@ -1465,7 +1478,6 @@ async function init(): Promise { await initTheme(); initSearch(); initPreferences(); - const appWindow = getCurrentWindow(); // Runtime opens (hot-start file association, "Open With", CLI events) appWindow.listen("open-folder", (event) => { @@ -1594,6 +1606,7 @@ async function renderSidebar(): Promise { emptyState.style.display = "block"; contentEl.classList.add("empty"); titlebarFilename.textContent = ""; + syncWindowTitle(); setSearchEnabled(false); } @@ -1810,6 +1823,7 @@ async function loadFile(filePath: string): Promise { const t6 = performance.now(); titlebarFilename.textContent = filePath.split("/").pop() ?? ""; + syncWindowTitle(); setSearchEnabled(true); searchController?.reset(); diff --git a/src/utils.test.ts b/src/utils.test.ts index e6da376..097af1a 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -9,6 +9,7 @@ import { findReadme, mergeRecent, resolveInitialView, + windowTitle, } from "./utils"; import type { RecentEntry } from "./utils"; @@ -310,3 +311,21 @@ describe("resolveInitialView", () => { expect(resolveInitialView(null, null)).toEqual({ kind: "welcome" }); }); }); + +describe("windowTitle", () => { + it("uses the open document's file name", () => { + expect(windowTitle("guide/api.md", "docs")).toBe("api.md"); + }); + + it("uses the file name for a root-level document", () => { + expect(windowTitle("README.md", "docs")).toBe("README.md"); + }); + + it("falls back to the folder name when no document is open", () => { + expect(windowTitle(null, "docs")).toBe("docs"); + }); + + it("falls back to the app name on the welcome screen", () => { + expect(windowTitle(null, "")).toBe("Markdown Viewer"); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index b02cc6d..4578f02 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -129,6 +129,25 @@ export function resolveInitialView( return { kind: "welcome" }; } +export const DEFAULT_WINDOW_TITLE = "Markdown Viewer"; + +/** + * Native title for a window, used by the macOS Window menu to label the entry + * it adds per window (the title itself stays hidden in the custom title bar). + * + * Most specific thing first: the open document, else the folder being browsed, + * else the app name for a window still on the welcome screen. + */ +export function windowTitle( + activeFile: string | null, + rootName: string +): string { + const fileName = activeFile?.split("/").pop(); + if (fileName) return fileName; + if (rootName) return rootName; + return DEFAULT_WINDOW_TITLE; +} + export type RecentEntry = { path: string; kind: "file" | "folder" }; /** From b5cd5adf332c275f6b7e175c061911cee58ada19 Mon Sep 17 00:00:00 2001 From: Lucas Lampietti Date: Thu, 13 Aug 2026 10:49:52 +0200 Subject: [PATCH 4/4] feat(window): add Fill, Center and Move & Resize commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round out the Window menu with the macOS window-arrangement commands: Fill, Center, halves and quarters under Move & Resize, Return to Previous Size, and Bring All to Front. They act on the frontmost window, using the screen it currently sits on. Frames come from NSScreen.visibleFrame rather than Tauri's Monitor::work_area(), which reports the right size but an origin of (0,0) — it ignores the menu bar. Top-anchored arrangements hid that because macOS clamps them below the menu bar, but bottom halves, bottom quarters and centering all landed one menu-bar-height too high. No accelerators on purpose: macOS already binds ⌃⌥+arrows to its own window tiling, and shadowing those would be a coin flip over which wins. Co-Authored-By: Claude Opus 5 (1M context) --- docs/guide/features.md | 14 ++- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 235 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 241 insertions(+), 10 deletions(-) diff --git a/docs/guide/features.md b/docs/guide/features.md index b71de8b..b291b15 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -20,10 +20,22 @@ The **Window** menu lists every open window and switches to the one you pick (the active one is checked). Each window is named after the document it shows — or its folder when no document is open yet — so the list stays readable. +It also carries the usual macOS window commands: + +- **Minimize**, **Zoom**, **Enter Full Screen** +- **Fill** — the window takes the whole screen minus menu bar and Dock +- **Center** — keeps the size, centers the window on its screen +- **Move & Resize** — halves (Left, Right, Top, Bottom), quarters, and **Return + to Previous Size**, which undoes a run of arrangements in one step +- **Bring All to Front** — raises every window, keeping the active one in front + +These act on the frontmost window and use the screen it currently sits on. They +have no keyboard shortcuts on purpose: macOS already binds Control-Option-arrows +to its own window tiling, which keeps working alongside them. + Windows are fully independent — document, outline, search state and scroll position are per window. Menu commands and their shortcuts (Find, Print, Export as PDF, Open Recent, Toggle Dark Mode…) act on the frontmost window only. - The recent-files list is shared (the native "Open Recent" submenu is app-wide). Preferences (theme, fonts, language) are stored once for the app and applied when a window opens, so changing them updates the window you changed them in and diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ba95901..a41d0f9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2215,6 +2215,7 @@ dependencies = [ "font-kit", "log", "objc2", + "objc2-app-kit", "objc2-foundation", "objc2-web-kit", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 15ce47c..487f92c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,6 +32,7 @@ font-kit = "0.14" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSScreen"] } objc2-foundation = { version = "0.3", features = ["NSData", "NSError", "NSString"] } objc2-web-kit = { version = "0.3", features = ["WKWebView", "WKPDFConfiguration", "block2", "objc2-app-kit"] } block2 = "0.6" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 797bd75..89a8960 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -118,6 +118,172 @@ fn window_sort_key(label: &str) -> (u8, u32, String) { (1, n, label.to_string()) } +const ARRANGE_PREFIX: &str = "window-arrange:"; +const BRING_ALL_TO_FRONT_ID: &str = "window-bring-all-front"; + +// Frame each window had before it was first tiled, so "Return to Previous Size" +// can undo a run of Fill/half/quarter moves in one step (macOS behaviour). +// Dropped once restored, and when the window goes away. +type Frame = (tauri::PhysicalPosition, tauri::PhysicalSize); +static PRE_ARRANGE: OnceLock>> = OnceLock::new(); + +fn pre_arrange() -> &'static Mutex> { + PRE_ARRANGE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// The area a window may occupy on its screen — the screen minus menu bar and +/// Dock — as (x, y, width, height) in physical pixels, top-left origin. +/// +/// `Monitor::work_area()` is not usable for this: on macOS it reports the right +/// *size* but an origin of (0, 0), i.e. it ignores the menu bar. Anchoring to +/// the top still lands correctly because macOS refuses to put a window under the +/// menu bar, but anything anchored to the bottom (bottom half, bottom quarters, +/// centering) ends up one menu-bar-height too high. So on macOS we ask AppKit +/// for the same `visibleFrame` its own Window menu uses, and convert it from +/// Cocoa's bottom-left origin to the top-left origin tao expects. +#[cfg(target_os = "macos")] +fn usable_area(win: &tauri::WebviewWindow) -> Option<(i32, i32, i32, i32)> { + use objc2_app_kit::NSScreen; + use objc2_foundation::MainThreadMarker; + + let mtm = MainThreadMarker::new()?; + let screens = NSScreen::screens(mtm); + // screens[0] is the screen holding the menu bar; every Cocoa y is relative + // to its bottom edge. + let primary_height = screens.iter().next()?.frame().size.height; + let to_top_left = |origin_y: f64, height: f64| primary_height - (origin_y + height); + + let scale = win.scale_factor().ok()?; + let pos = win.outer_position().ok()?; + let (wx, wy) = (pos.x as f64 / scale, pos.y as f64 / scale); + + let screen = screens + .iter() + .find(|s| { + let f = s.frame(); + let top = to_top_left(f.origin.y, f.size.height); + wx >= f.origin.x + && wx < f.origin.x + f.size.width + && wy >= top + && wy < top + f.size.height + }) + .or_else(|| screens.iter().next())?; + + let v = screen.visibleFrame(); + let top = to_top_left(v.origin.y, v.size.height); + let s = screen.backingScaleFactor(); + Some(( + (v.origin.x * s).round() as i32, + (top * s).round() as i32, + (v.size.width * s).round() as i32, + (v.size.height * s).round() as i32, + )) +} + +#[cfg(not(target_os = "macos"))] +fn usable_area(win: &tauri::WebviewWindow) -> Option<(i32, i32, i32, i32)> { + let monitor = win.current_monitor().ok()??; + let area = monitor.work_area(); + Some(( + area.position.x, + area.position.y, + area.size.width as i32, + area.size.height as i32, + )) +} + +/// Move/resize the frontmost window inside its screen's work area (the screen +/// minus menu bar and Dock), the way the macOS Window menu does it. +fn arrange_focused_window(app: &tauri::AppHandle, action: &str) -> Result<(), String> { + let Some(win) = focused_window(app) else { + return Ok(()); + }; + let label = win.label().to_string(); + + if action == "restore" { + let saved = pre_arrange() + .lock() + .map_err(|e| e.to_string())? + .remove(&label); + if let Some((pos, size)) = saved { + win.set_size(size).map_err(|e| e.to_string())?; + win.set_position(pos).map_err(|e| e.to_string())?; + } + return Ok(()); + } + + let (ax, ay, aw, ah) = + usable_area(&win).ok_or_else(|| "no usable screen area".to_string())?; + // Halves are computed as "the rest of the area" for the right/bottom side so + // an odd number of pixels never leaves a one-pixel gap down the middle. + let (lw, th) = (aw / 2, ah / 2); + let (rw, bh) = (aw - lw, ah - th); + + let size = win.outer_size().map_err(|e| e.to_string())?; + let (x, y, w, h) = match action { + "fill" => (ax, ay, aw, ah), + "center" => ( + ax + (aw - size.width as i32) / 2, + ay + (ah - size.height as i32) / 2, + size.width as i32, + size.height as i32, + ), + "left" => (ax, ay, lw, ah), + "right" => (ax + lw, ay, rw, ah), + "top" => (ax, ay, aw, th), + "bottom" => (ax, ay + th, aw, bh), + "top-left" => (ax, ay, lw, th), + "top-right" => (ax + lw, ay, rw, th), + "bottom-left" => (ax, ay + th, lw, bh), + "bottom-right" => (ax + lw, ay + th, rw, bh), + _ => return Ok(()), + }; + + // Remember the pre-tiling frame once, so chained arrangements still restore + // to where the window was before the user started rearranging it. + { + let mut saved = pre_arrange().lock().map_err(|e| e.to_string())?; + if !saved.contains_key(&label) { + let pos = win.outer_position().map_err(|e| e.to_string())?; + saved.insert(label, (pos, size)); + } + } + + win.set_size(tauri::PhysicalSize::new(w.max(1) as u32, h.max(1) as u32)) + .map_err(|e| e.to_string())?; + win.set_position(tauri::PhysicalPosition::new(x, y)) + .map_err(|e| e.to_string())?; + Ok(()) +} + +/// Raise every window, leaving the frontmost one still frontmost. +fn bring_all_to_front(app: &tauri::AppHandle) { + let focused = focused_window(app).map(|w| w.label().to_string()); + for (label, win) in app.webview_windows() { + if Some(&label) == focused.as_ref() { + continue; + } + let _ = win.unminimize(); + let _ = win.show(); + let _ = win.set_focus(); + } + if let Some(label) = focused { + if let Some(win) = app.get_webview_window(&label) { + let _ = win.set_focus(); + } + } +} + +fn arrange_item( + app: &tauri::AppHandle, + action: &str, + text: &str, +) -> Result, String> { + MenuItemBuilder::with_id(format!("{ARRANGE_PREFIX}{action}"), text) + .build(app) + .map_err(|e| e.to_string()) +} + fn rebuild_window_menu(app: &tauri::AppHandle) -> Result<(), String> { let guard = window_slot().lock().map_err(|e| e.to_string())?; let Some(submenu) = guard.as_ref() else { @@ -132,16 +298,48 @@ fn rebuild_window_menu(app: &tauri::AppHandle) -> Result<(), String> { let minimize = PredefinedMenuItem::minimize(app, None).map_err(|e| e.to_string())?; let zoom = PredefinedMenuItem::maximize(app, None).map_err(|e| e.to_string())?; let fullscreen = PredefinedMenuItem::fullscreen(app, None).map_err(|e| e.to_string())?; - let close = PredefinedMenuItem::close_window(app, None).map_err(|e| e.to_string())?; + let fill = arrange_item(app, "fill", "Fill")?; + let center = arrange_item(app, "center", "Center")?; + + // Deliberately without accelerators: macOS already owns ⌃⌥+arrows for its + // own window tiling, and shadowing those here would be a coin flip over + // which one wins. + let move_resize = SubmenuBuilder::new(app, "Move & Resize") + .items(&[ + &arrange_item(app, "left", "Left")?, + &arrange_item(app, "right", "Right")?, + &arrange_item(app, "top", "Top")?, + &arrange_item(app, "bottom", "Bottom")?, + ]) + .separator() + .items(&[ + &arrange_item(app, "top-left", "Top Left")?, + &arrange_item(app, "top-right", "Top Right")?, + &arrange_item(app, "bottom-left", "Bottom Left")?, + &arrange_item(app, "bottom-right", "Bottom Right")?, + ]) + .separator() + .items(&[&arrange_item(app, "restore", "Return to Previous Size")?]) + .build() + .map_err(|e| e.to_string())?; + + let bring_all = MenuItemBuilder::with_id(BRING_ALL_TO_FRONT_ID, "Bring All to Front") + .build(app) + .map_err(|e| e.to_string())?; let sep1 = PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?; let sep2 = PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?; + let sep3 = PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?; for item in [ &minimize as &dyn tauri::menu::IsMenuItem, &zoom, + &fill, + ¢er, + &move_resize, &sep1, &fullscreen, - &close, &sep2, + &bring_all, + &sep3, ] { submenu.append(item).map_err(|e| e.to_string())?; } @@ -684,18 +882,18 @@ pub fn run(path_arg: Option) { eprintln!("[menu] new window failed: {e}"); } } - // The folder picker lives in the frontend (dialog plugin), so - // the window prompts and then asks Rust for a window on the - // chosen folder. - "open_folder_new_window" => { - emit_to_focused(&app_handle, "menu-open-folder-new-window", ()); - } "open_file" => { emit_to_focused(&app_handle, "menu-open-file", ()); } "open_folder" => { emit_to_focused(&app_handle, "menu-open-folder", ()); } + // The folder picker lives in the frontend (dialog plugin), + // so the frontmost window prompts and then asks Rust for a + // window on the chosen folder. + "open_folder_new_window" => { + emit_to_focused(&app_handle, "menu-open-folder-new-window", ()); + } "print" => { emit_to_focused(&app_handle, "menu-print", ()); } @@ -714,6 +912,15 @@ pub fn run(path_arg: Option) { "recent_clear" => { emit_to_focused(&app_handle, "menu-clear-recent", ()); } + BRING_ALL_TO_FRONT_ID => { + bring_all_to_front(&app_handle); + } + _ if id.starts_with(ARRANGE_PREFIX) => { + let action = &id[ARRANGE_PREFIX.len()..]; + if let Err(e) = arrange_focused_window(&app_handle, action) { + eprintln!("[menu] arrange '{action}' failed: {e}"); + } + } // One entry per open window: bring the picked one forward. _ if id.starts_with(WINDOW_ITEM_PREFIX) => { let label = &id[WINDOW_ITEM_PREFIX.len()..]; @@ -776,11 +983,21 @@ pub fn run(path_arg: Option) { app.run(|app_handle, event| { // Keep the Window menu's list and checkmark in step with reality. - if let tauri::RunEvent::WindowEvent { ref event, .. } = event { + if let tauri::RunEvent::WindowEvent { + ref label, + ref event, + .. + } = event + { if matches!( event, tauri::WindowEvent::Destroyed | tauri::WindowEvent::Focused(true) ) { + if matches!(event, tauri::WindowEvent::Destroyed) { + if let Ok(mut saved) = pre_arrange().lock() { + saved.remove(label); + } + } let _ = rebuild_window_menu(app_handle); } }