Skip to content

Commit 8cd06bf

Browse files
committed
UX: Center child windows on the main window
- Settings and Debug open centered on the main window. Reopening within the session lands where you left it (in-memory rect cache in `child_window_state.rs`, reset every app launch). - File viewers cascade from main's top-left (+24 px per opened viewer, wrap at 8) so successive opens don't pile on top of each other. - Saved rects that no longer fit any monitor (display disconnected, resolution changed) are clamped to the nearest monitor before use. - `tauri-plugin-window-state` narrowed via `with_filter(|l| l == "main")` so stale positions from past sessions never restore on children. Main still persists across launches as before. - New Tauri commands `get_child_window_rect` / `set_child_window_rect` back the in-session cache; `settings.json` and `debug.json` gain the three position-getter permissions used by the move/resize listeners. - Pure geometry lives in `window-positioning-utils.ts` (21 vitest cases, 100% covered); `window-positioning.ts` holds the thin Tauri wrappers.
1 parent 967bee2 commit 8cd06bf

20 files changed

Lines changed: 565 additions & 11 deletions

apps/desktop/coverage-allowlist.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@
125125
"settings/sections/ViewerSection.svelte": { "reason": "UI section, simple rendering" },
126126
"settings/settings-store.ts": { "reason": "Depends on Tauri store APIs" },
127127
"settings/settings-window.ts": { "reason": "Depends on Tauri window APIs" },
128+
"window-positioning.ts": {
129+
"reason": "Thin wrappers around `getCurrentWindow().outerPosition/outerSize/scaleFactor` + `availableMonitors`; pure math lives in window-positioning-utils.ts and is fully covered there"
130+
},
131+
"debug/debug-window.ts": { "reason": "Depends on Tauri WebviewWindow API; dev-only" },
128132
"shortcuts/mcp-shortcuts-listener.ts": { "reason": "MCP listener, depends on Tauri events" },
129133
"shortcuts/keyboard-handler.ts": { "reason": "DOM event handling, tested via integration" },
130134
"shortcuts/shortcuts-store.ts": { "reason": "Depends on Tauri store APIs" },

apps/desktop/src-tauri/capabilities/debug.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
"core:window:allow-set-focus",
1111
"core:window:allow-set-effects",
1212
"core:window:allow-start-dragging",
13+
"core:window:allow-outer-position",
14+
"core:window:allow-outer-size",
15+
"core:window:allow-scale-factor",
1316
"core:event:default",
1417
"core:app:allow-set-app-theme",
1518
"core:webview:allow-internal-toggle-devtools",

apps/desktop/src-tauri/capabilities/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
"core:window:allow-set-max-size",
1313
"core:window:allow-set-effects",
1414
"core:window:allow-start-dragging",
15+
"core:window:allow-outer-position",
16+
"core:window:allow-outer-size",
17+
"core:window:allow-scale-factor",
1518
"core:event:default",
1619
"core:app:allow-set-app-theme",
1720
"core:webview:allow-internal-toggle-devtools",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//! In-session position+size cache for child windows (Settings, Debug).
2+
//!
3+
//! The window-state plugin persists only the main window across launches
4+
//! (`with_filter(|label| label == "main")` in `lib.rs`). Child windows
5+
//! intentionally start fresh each app launch — they're modal-feeling and
6+
//! should reappear centered on the main window, not in a stale spot from
7+
//! days ago.
8+
//!
9+
//! Within a single session, though, reopening Settings after closing it
10+
//! should land back where the user last had it. That's what this cache is
11+
//! for. It lives in `app.manage(...)` so it's wiped automatically when the
12+
//! process exits; no disk involvement.
13+
14+
use std::collections::HashMap;
15+
use std::sync::Mutex;
16+
17+
/// Logical-pixel rectangle. `f64` mirrors what Tauri's `LogicalPosition` /
18+
/// `LogicalSize` use on the wire.
19+
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
20+
pub struct ChildWindowRect {
21+
pub x: f64,
22+
pub y: f64,
23+
pub width: f64,
24+
pub height: f64,
25+
}
26+
27+
/// Mutex-guarded map keyed by window label.
28+
#[derive(Default)]
29+
pub struct ChildWindowRectStore(Mutex<HashMap<String, ChildWindowRect>>);
30+
31+
impl ChildWindowRectStore {
32+
pub fn new() -> Self {
33+
Self::default()
34+
}
35+
36+
pub fn get(&self, label: &str) -> Option<ChildWindowRect> {
37+
self.0.lock().ok()?.get(label).copied()
38+
}
39+
40+
pub fn set(&self, label: String, rect: ChildWindowRect) {
41+
if let Ok(mut map) = self.0.lock() {
42+
map.insert(label, rect);
43+
}
44+
}
45+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
//! Tauri commands for the in-session child-window position cache. See
2+
//! `crate::child_window_state` for the design.
3+
4+
use tauri::State;
5+
6+
use crate::child_window_state::{ChildWindowRect, ChildWindowRectStore};
7+
8+
/// Returns the saved rect for a child window label, or `None` if no entry
9+
/// exists (first open in this session, or never opened).
10+
#[tauri::command]
11+
#[specta::specta]
12+
pub fn get_child_window_rect(label: String, store: State<'_, ChildWindowRectStore>) -> Option<ChildWindowRect> {
13+
store.get(&label)
14+
}
15+
16+
/// Saves the rect for a child window label. Called from the frontend's
17+
/// move/resize listeners on Settings and Debug.
18+
#[tauri::command]
19+
#[specta::specta]
20+
pub fn set_child_window_rect(label: String, rect: ChildWindowRect, store: State<'_, ChildWindowRectStore>) {
21+
store.set(label, rect);
22+
}

apps/desktop/src-tauri/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! Tauri commands module.
22
3+
pub mod child_window_state;
34
pub mod clipboard;
45
pub mod crash_reporter;
56
pub mod e2e;

apps/desktop/src-tauri/src/ipc.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ pub fn builder() -> Builder<tauri::Wry> {
119119
crate::commands::rename::rename_file,
120120
crate::commands::rename::move_to_trash,
121121
crate::commands::restricted_paths::get_restricted_paths,
122+
crate::commands::child_window_state::get_child_window_rect,
123+
crate::commands::child_window_state::set_child_window_rect,
122124
crate::commands::file_viewer::viewer_open,
123125
crate::commands::file_viewer::viewer_get_lines,
124126
crate::commands::file_viewer::viewer_get_status,

apps/desktop/src-tauri/src/ipc_collectors.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ pub(crate) fn collect_cross_platform_types(types: &mut Types) -> Vec<Function> {
6767
crate::commands::rename::rename_file,
6868
crate::commands::rename::move_to_trash,
6969
crate::commands::restricted_paths::get_restricted_paths,
70+
crate::commands::child_window_state::get_child_window_rect,
71+
crate::commands::child_window_state::set_child_window_rect,
7072
crate::commands::file_viewer::viewer_open,
7173
crate::commands::file_viewer::viewer_get_lines,
7274
crate::commands::file_viewer::viewer_get_status,

apps/desktop/src-tauri/src/lib.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ mod accent_color;
7979
mod accent_color_linux;
8080
mod ai;
8181
pub mod benchmark;
82+
mod child_window_state;
8283
mod clipboard;
8384
mod commands;
8485
pub mod config;
@@ -201,9 +202,16 @@ pub fn run() {
201202
let specta_builder = ipc::builder();
202203
let builder = tauri::Builder::default();
203204

204-
// Window state plugin is only available on desktop platforms
205+
// Window state plugin is only available on desktop platforms. The filter
206+
// restricts persistence to the main window: Settings, Debug, and viewer
207+
// windows are deliberately reset on every launch. Within a session they
208+
// remember position via `child_window_state` (in-memory only).
205209
#[cfg(not(any(target_os = "android", target_os = "ios")))]
206-
let builder = builder.plugin(tauri_plugin_window_state::Builder::new().build());
210+
let builder = builder.plugin(
211+
tauri_plugin_window_state::Builder::new()
212+
.with_filter(|label| label == "main")
213+
.build(),
214+
);
207215

208216
// MCP Bridge plugin is only available in debug builds for security
209217
#[cfg(debug_assertions)]
@@ -578,6 +586,10 @@ pub fn run() {
578586
// everywhere.
579587
app.manage(quick_look::init_state());
580588

589+
// In-session position cache for Settings + Debug windows. See
590+
// `child_window_state.rs` for the why.
591+
app.manage(child_window_state::ChildWindowRectStore::new());
592+
581593
// Initialize pane state store for MCP context tools
582594
app.manage(mcp::PaneStateStore::new());
583595

apps/desktop/src/lib/debug/debug-window.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { LogicalPosition } from '@tauri-apps/api/dpi'
1919
import { Effect, EffectState } from '@tauri-apps/api/window'
2020
import { getAppLogger } from '$lib/logging/logger'
2121
import { decorateChildWindowTitle } from '$lib/app-mode'
22+
import { readMainRect, readMonitors, readSavedRect, resolveChildPosition } from '$lib/window-positioning'
2223

2324
const log = getAppLogger('debug')
2425

@@ -42,14 +43,19 @@ export async function openDebugWindow(): Promise<void> {
4243

4344
log.debug('Creating new debug window')
4445

46+
const [main, monitors, saved] = await Promise.all([readMainRect(), readMonitors(), readSavedRect('debug')])
47+
const rect = main
48+
? resolveChildPosition({ size: { width: DEBUG_WIDTH, height: DEBUG_HEIGHT }, main, monitors, saved })
49+
: null
50+
4551
const win = new WebviewWindow('debug', {
4652
url: '/debug',
4753
title: decorateChildWindowTitle('Debug'),
4854
width: DEBUG_WIDTH,
4955
height: DEBUG_HEIGHT,
5056
minWidth: DEBUG_MIN_WIDTH,
5157
minHeight: DEBUG_MIN_HEIGHT,
52-
center: true,
58+
...(rect ? { x: rect.x, y: rect.y } : { center: true }),
5359
resizable: true,
5460
decorations: true,
5561
focus: true,

0 commit comments

Comments
 (0)