From ff86a3c641871354ee3e9787afd9bfa2373aaa3f Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Wed, 16 Sep 2026 23:19:44 +0530 Subject: [PATCH] feat: keep notes on their display across unplug and relaunch Frames are global coordinates, as Stickies stores them, but every window move was persisted, including the ones macOS makes when it shoves windows off a vanished display and the launch rescue that recentres an off-screen note. So a monitor unplugged at home overwrote the note's office spot, and plugging it back or relaunching there left the note on the laptop. - Only user drags and resizes persist a frame; the manager keeps each open note's saved frame in memory and never writes it from its own placements - One placement rule, at open and on every display change: the saved frame when any connected display shows it, else parked at the nearest edge of the main display at the same size (a tall note keeps its header on screen) - Observe didChangeScreenParametersNotification: cancel pending saves, ignore saves for two seconds, re-place every open note, so unplugging parks notes and plugging back returns them exactly, with or without a relaunch between - Attach the panel delegate only after the initial placement and roll-up No schema change; existing saved positions load as they are. --- CLAUDE.md | 6 +- Sources/Tic/Windows/NoteWindowManager.swift | 71 ++++++++++++++++----- Tests/TicTests/NoteWindowManagerTests.swift | 21 ++++++ 3 files changed, 80 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 699b1a6..2e13666 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +69,11 @@ panels**, and a few responsibilities are deliberately split across the AppKit/Sw `restoreAll` (launch: only notes with `isOpen` — the header X clears it, quitting doesn't, so relaunch shows what was on screen), `openNote`, close, debounced frame persistence, roll-up resize, and applying float-on-top / show-on-all-Spaces. Frames are global coordinates spanning - all displays; `ensureOnScreen` recentres only when *no* screen contains the note. + all displays, exactly as Stickies stores them. **Only user drags/resizes persist a frame**: `place` + puts a note at its saved frame when any connected display shows it, else parks it at the nearest + edge of the main display, and re-runs on `didChangeScreenParametersNotification` with saves + suppressed (`placing` + a 2s blackout) — so unplugging a monitor never overwrites the note's spot + on it, and plugging it back returns the note exactly, with or without a relaunch in between. - **`NoteController`** (`@MainActor @Observable`) — one per open note. Holds the `Note`, streams its tasks live via GRDB `ValueObservation`, and turns user actions into DB writes. It stays **AppKit-free**: window side effects go through closures the manager sets on it diff --git a/Sources/Tic/Windows/NoteWindowManager.swift b/Sources/Tic/Windows/NoteWindowManager.swift index 5dd7d3a..483b934 100644 --- a/Sources/Tic/Windows/NoteWindowManager.swift +++ b/Sources/Tic/Windows/NoteWindowManager.swift @@ -11,6 +11,15 @@ final class NoteWindowManager: NSObject, NSWindowDelegate { private var pendingFrameSaves: [UUID: Task] = [:] /// Height a note had before it was rolled up, so it restores to the right size on expand. private var expandedHeights: [UUID: CGFloat] = [:] + /// Each open note's frame as saved in the DB — the last spot the user chose. Only user drags and + /// resizes update it; a placement we or macOS make (display unplugged, launch rescue) never does, + /// so a note parked on the laptop at home still returns to its monitor at the office. + private var savedFrames: [UUID: CGRect] = [:] + /// True while `place` moves a panel, so its own `windowDidMove` isn't persisted. + private var placing = false + /// Frame saves are dropped until this instant after a display change: macOS moves windows off a + /// vanished display at about the same time as the notification, in either order. + private var suppressSavesUntil = Date.distantPast /// The one image window, reused for whichever image was opened last. private var imageWindow: NSWindow? /// The note whose image the image window is showing, so closing that note closes it too. @@ -19,6 +28,10 @@ final class NoteWindowManager: NSObject, NSWindowDelegate { init(appDatabase: AppDatabase) { self.appDatabase = appDatabase super.init() + NotificationCenter.default.addObserver( + self, selector: #selector(screensDidChange), + name: NSApplication.didChangeScreenParametersNotification, object: nil + ) } // MARK: - Opening notes @@ -51,8 +64,8 @@ final class NoteWindowManager: NSObject, NSWindowDelegate { controller.start() let panel = NotePanel(note: note, content: AnyView(NoteView(controller: controller))) - panel.delegate = self panels[note.id] = panel + savedFrames[note.id] = CGRect(x: note.frameX, y: note.frameY, width: note.frameW, height: note.frameH) // Live-window side effects. Weak captures so the closures never keep the panel alive // past close (windowWillClose nils the controller, releasing them). @@ -79,7 +92,7 @@ final class NoteWindowManager: NSObject, NSWindowDelegate { controller?.stageImage(data) } - ensureOnScreen(panel) + place(panel) if makeKey { panel.makeKeyAndOrderFront(nil) // become key → the menu bar popover resigns/dismisses } else { @@ -90,6 +103,7 @@ final class NoteWindowManager: NSObject, NSWindowDelegate { if note.isCollapsed { setCollapsed(panel, collapsed: true, animate: false) } + panel.delegate = self // only now: the placement and roll-up above aren't user moves to persist return panel } @@ -159,21 +173,40 @@ final class NoteWindowManager: NSObject, NSWindowDelegate { panel.setFrame(newFrame, display: true, animate: animate) } - /// If a restored frame leaves the note essentially off every display (e.g. the one it was on - /// was disconnected since it was last saved), recenter it on the main screen so it's never lost. - /// Frames are global coordinates spanning all displays, so a note on a second monitor restores - /// there as long as some screen still contains it. - private func ensureOnScreen(_ panel: NotePanel) { - guard let screen = NSScreen.main else { return } - let screens = NSScreen.screens.map(\.visibleFrame) - if !Self.isVisible(panel.frame, onAnyOf: screens) { - let visible = screen.visibleFrame - let origin = NSPoint( - x: visible.midX - panel.frame.width / 2, - y: visible.midY - panel.frame.height / 2 - ) - panel.setFrameOrigin(origin) - } + /// Puts a panel where its saved frame says, as long as some connected display shows it; otherwise + /// parks it on the main display (nearest edge, same size). Runs at open and on every display + /// change, so unplugging a monitor parks its notes and plugging it back returns them to the exact + /// spot. Frames are global coordinates spanning all displays (as Stickies stores them), and this + /// never persists: the saved frame stays the user's. + private func place(_ panel: NotePanel) { + guard let saved = savedFrames[panel.noteID], let main = NSScreen.main else { return } + let target = Self.placement(for: saved, screens: NSScreen.screens.map(\.visibleFrame), main: main.visibleFrame) + // Keep the live size (a rolled-up note sits at its collapsed height); align the top-left corner. + placing = true + panel.setFrameOrigin(NSPoint(x: target.minX, y: target.maxY - panel.frame.height)) + placing = false + } + + /// `saved` itself when at least an 80×80 corner lies on one of `screens`; else `saved` shifted the + /// shortest distance that fits it on `main` (a note taller than the screen keeps its top edge on). + nonisolated static func placement(for saved: CGRect, screens: [CGRect], main: CGRect) -> CGRect { + if isVisible(saved, onAnyOf: screens) { return saved } + var frame = saved + frame.origin.x = min(max(saved.minX, main.minX), main.maxX - saved.width) + frame.origin.y = min(max(saved.minY, main.minY), main.maxY - saved.height) + return frame + } + + /// A display was plugged in, unplugged, or rearranged. macOS shoves windows off a vanished display + /// onto a remaining one, which must not be saved as the user's choice: drop the moves it caused + /// (already pending or still to come) and re-place every note from its saved frame. + @objc private func screensDidChange() { + // ponytail: a 2s blanket, since macOS moves windows within the same reconfiguration; a user drag + // inside it is dropped — make it precise if that ever shows. + suppressSavesUntil = Date().addingTimeInterval(2) + for task in pendingFrameSaves.values { task.cancel() } + pendingFrameSaves.removeAll() + for panel in panels.values { place(panel) } } /// True when at least an 80×80 corner of `frame` lies on one of `screens` (enough to grab). @@ -216,6 +249,7 @@ final class NoteWindowManager: NSObject, NSWindowDelegate { pendingFrameSaves[panel.noteID]?.cancel() pendingFrameSaves[panel.noteID] = nil expandedHeights[panel.noteID] = nil + savedFrames[panel.noteID] = nil controllers[panel.noteID]?.stop() controllers[panel.noteID] = nil panels[panel.noteID] = nil @@ -225,6 +259,8 @@ final class NoteWindowManager: NSObject, NSWindowDelegate { /// gesture settles (300ms idle). private func scheduleFrameSave(_ notification: Notification) { guard let panel = notification.object as? NotePanel else { return } + // Not a user move: our own placement, or macOS relocating windows around a display change. + if placing || Date() < suppressSavesUntil { return } let id = panel.noteID let live = panel.frame @@ -236,6 +272,7 @@ final class NoteWindowManager: NSObject, NSWindowDelegate { let x = live.origin.x let y = collapsed ? (live.maxY - height) : live.origin.y let width = live.width + savedFrames[id] = CGRect(x: x, y: y, width: width, height: height) pendingFrameSaves[id]?.cancel() pendingFrameSaves[id] = Task { [appDatabase] in diff --git a/Tests/TicTests/NoteWindowManagerTests.swift b/Tests/TicTests/NoteWindowManagerTests.swift index e56c317..b8e8b73 100644 --- a/Tests/TicTests/NoteWindowManagerTests.swift +++ b/Tests/TicTests/NoteWindowManagerTests.swift @@ -22,4 +22,25 @@ struct NoteWindowManagerTests { let corner = CGRect(x: -200, y: -280, width: 280, height: 360) // exactly 80×80 showing #expect(NoteWindowManager.isVisible(corner, onAnyOf: [main, second])) } + + @Test("office → home → office: parked on the main display while its monitor is gone, back exactly after") + func displayRoundTrip() { + let onSecond = CGRect(x: 2000, y: 300, width: 280, height: 360) + #expect(NoteWindowManager.placement(for: onSecond, screens: [main, second], main: main) == onSecond) + + let parked = NoteWindowManager.placement(for: onSecond, screens: [main], main: main) // monitor unplugged + #expect(NoteWindowManager.isVisible(parked, onAnyOf: [main])) + #expect(parked.size == onSecond.size) + #expect(parked.maxX == main.maxX && parked.minY == 300) // nearest edge, not a pile in the centre + + // The saved frame is never rewritten by parking, so the same input places it back on the monitor. + #expect(NoteWindowManager.placement(for: onSecond, screens: [main, second], main: main) == onSecond) + } + + @Test("a note taller than the display keeps its header on screen") + func tallNote() { + let tall = CGRect(x: 5000, y: 5000, width: 280, height: 1200) + let parked = NoteWindowManager.placement(for: tall, screens: [main], main: main) + #expect(parked.maxY == main.maxY && parked.maxX == main.maxX) + } }