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) + } }