Skip to content

feat(window): multi-window support with a native Window menu - #22

Merged
nprudhomme merged 4 commits into
ekino:mainfrom
lampietti:feat/multi-window
Aug 13, 2026
Merged

nprudhomme merged 4 commits into
ekino:mainfrom
lampietti:feat/multi-window

Conversation

@lampietti

Copy link
Copy Markdown
Member

Description

What

Adds multi-window support: any folder can be opened in its own window, with a native Window menu to list, switch between and arrange those windows.

Why

The app only ever had one window, so reading two sets of docs side by side — or a guide next to its reference — meant closing one folder and reopening the other. Several windows plus a standard Window menu is what a macOS document viewer is expected to offer.

Changes

Multiple windows (99f0331)

  • File › New Window (⌘N) opens an empty window; File › Open Folder in New Window… (⇧⌘N) opens a picked folder in its own window, leaving the current one untouched
  • New open_new_window command: spawned windows are labelled viewer-N and cascade off the frontmost one
  • The pending-open buffer becomes a map keyed by window label, so a window only drains the folder meant for it (it was a single global slot, which two windows starting at once would fight over)
  • New PendingOpen::Empty marker keeps a ⌘N window on the welcome screen rather than restoring lastFolder, which would make it a clone of the window it came from
  • viewer-* added to the capability's window list — without it, spawned windows come up with no plugin permissions
  • resolveInitialView (src/utils.ts) decides what a window shows at startup

Menu commands now target the frontmost window (40756b1)

  • app_handle.emit broadcasts to every webview. Harmless with one window; with two, a single ⌘F focused both search fields and one "Open Folder…" raised two pickers
  • Every menu command goes through emit_to_focused, and a Finder "Open With" on a running app lands in the frontmost window instead of all of them

Window menu (de45952)

  • One checkable entry per open window, checked on the frontmost; selecting an entry brings that window forward
  • Rebuilt on every event that can change it: window opened, closed, focused, renamed
  • macOS's own set_as_windows_menu_for_nsapp() was tried first and populates nothing here — AppKit only adopts windows created after that call, and never picked up tao's — so the entries are built explicitly
  • Windows are titled after the document they show (or their folder), otherwise the list reads as several identical "Markdown Viewer" rows. Renaming goes through a set_window_title command that updates the title and the menu together so they cannot drift

Window arrangement (b5cd5ad)

  • Fill, Center, Move & Resize (halves, quarters, Return to Previous Size) and Bring All to Front
  • Frames come from NSScreen.visibleFrame rather than Tauri's Monitor::work_area(), which reports the right size but an origin of (0, 0): top-anchored layouts looked fine because macOS clamps them below the menu bar, but bottom halves, bottom quarters and centering all landed one menu-bar-height too high
  • Deliberately no accelerators: macOS already binds ⌃⌥+arrows to its own window tiling
  • Adds a direct objc2-app-kit dependency (already in the tree transitively via objc2-web-kit)

Docs — README feature bullet, docs/guide/features.md section, keyboard-shortcuts table.

Verification

  • npx tsc --noEmit, 311 tests (10 new, covering resolveInitialView and windowTitle), cargo check
  • Each of the 4 commits compiles on its own, so the branch stays bisectable
  • Arrangement geometry was measured in the running app for every layout — e.g. the bottom half lands at y=1368 on a work area of y=78 h=2580, so its bottom edge is exactly the screen's, and left/right halves meet with no gap
  • Per-window folder routing was checked in a running app with three windows (main restoring its folder, one on a given folder, one empty)
  • Menu-bar rendering and window switching were verified interactively

Notes for review: lastFolder stays app-wide, so the next launch restores the folder of the last window that navigated. Preferences are read when a window opens, so changing a theme does not retro-apply to windows already open.

Type of Change

  • Bug fix
  • New feature
  • Refactoring
  • Documentation

Checklist

  • I have tested my changes locally
  • I have updated the documentation if needed
  • My code follows the existing code style

🤖 Generated with Claude Code

lampietti and others added 4 commits August 13, 2026 10:47
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@nprudhomme
nprudhomme merged commit 8078463 into ekino:main Aug 13, 2026
4 checks passed
nprudhomme added a commit that referenced this pull request Aug 13, 2026
PR #22 landed without a changelog entry. Covers the new windows, the
native Window menu, the arrangement commands, and the switch from
broadcast to frontmost-window menu delivery.

Claude-Session: https://claude.ai/code/session_01NcJuB2iR4WEfuQVLpYqWi4
@nprudhomme nprudhomme mentioned this pull request Aug 13, 2026
7 tasks
nprudhomme added a commit that referenced this pull request Aug 13, 2026
* chore(release): v0.11.0

Bump version to 0.11.0 and document the user-facing changes since 0.10.0:
native File/View menus with Open Recent (#19), fast document open with
in-memory cache, cloud-sync UX and a debug HUD (#18), and a search
highlight fix on document switch / 1->0 clear (#17).

* fix(ui): stop transparent modal backdrops from locking the window

A modal backdrop could be left in `display: flex` while fully transparent,
covering the whole window at z-index 2000. It swallowed every click and
scroll while staying invisible, and because the close path had already
detached its listeners, the user had no way to dismiss it — the app was
unusable until restart. Reported on 0.10.0: no clicks on Open Folder, no
file selection in the sidebar, no scrolling, while the native menu bar
still worked.

The backdrops were shown and hidden through deferred callbacks. WebKit
suspends both animation frames and timers while a window is occluded, so
neither the reveal nor the hide is guaranteed to run — leaving the layer
displayed but never marked `visible`.

Make interactivity follow opacity in CSS: an overlay without `.visible`
is now click-through and hidden from the tab order and the accessibility
tree. That is the load-bearing guarantee — it holds whatever the reason
the layer got stuck, including a keyboard user reaching a destructive
button inside an invisible dialog.

Defence in depth on top of it:
- reveal synchronously (forced reflow, then class) instead of from a
  frame callback, so a dialog opened on an occluded window is still
  visible and therefore dismissable;
- track an open generation so a pending hide never acts on a backdrop a
  newer open has claimed, in both confirm-dialog and Preferences;
- focus the trap immediately, since callers now reveal before trapping.

Tests cover the two suspension modes that produce the field state:
frames never delivered, and timers never delivered.

Claude-Session: https://claude.ai/code/session_01NcJuB2iR4WEfuQVLpYqWi4

* docs(changelog): record multi-window support in 0.11.0

PR #22 landed without a changelog entry. Covers the new windows, the
native Window menu, the arrangement commands, and the switch from
broadcast to frontmost-window menu delivery.

Claude-Session: https://claude.ai/code/session_01NcJuB2iR4WEfuQVLpYqWi4

* docs(changelog): set the 0.11.0 release date

The heading still carried the date the release branch was prepared.

Claude-Session: https://claude.ai/code/session_01NcJuB2iR4WEfuQVLpYqWi4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants