Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 54 additions & 17 deletions Sources/Tic/Windows/NoteWindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ final class NoteWindowManager: NSObject, NSWindowDelegate {
private var pendingFrameSaves: [UUID: Task<Void, Never>] = [:]
/// 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.
Expand All @@ -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
Expand Down Expand Up @@ -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).
Expand All @@ -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 {
Expand All @@ -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
}

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions Tests/TicTests/NoteWindowManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}