From 508752e56833009d46c9372cdcc01291af40bf90 Mon Sep 17 00:00:00 2001 From: Madeline Onassis Date: Wed, 12 Aug 2026 03:08:49 +0100 Subject: [PATCH] Persist floating window size and position between sessions The floating overlay was always recreated at a centered default frame, so any manual resize or reposition was lost when the session ended. The panel frame is stored in NotchSettings whenever the window is moved or resized, and the panel is constructed with that frame so the content lays out against its final size immediately. The saved size is restored as-is; only the origin is constrained, so the window cannot come back with an edge past the display, and a frame whose center no longer lands on a connected display is discarded in favour of the default. The stored frame is marked @ObservationIgnored: nothing renders from it, and it is written on every drag and resize step, which would otherwise redraw the overlay continuously while the window is being dragged. --- .../Textream/NotchOverlayController.swift | 43 ++++++++++++++++++- Textream/Textream/NotchSettings.swift | 14 ++++++ Textream/Textream/SettingsView.swift | 1 + 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/Textream/Textream/NotchOverlayController.swift b/Textream/Textream/NotchOverlayController.swift index ca95e34..15cfc92 100644 --- a/Textream/Textream/NotchOverlayController.swift +++ b/Textream/Textream/NotchOverlayController.swift @@ -350,6 +350,11 @@ class NotchOverlayController: NSObject { let xPosition = screenFrame.midX - panelWidth / 2 let yPosition = screenFrame.midY - panelHeight / 2 + 100 + let defaultFrame = NSRect(x: xPosition, y: yPosition, width: panelWidth, height: panelHeight) + + // The panel opens at the size and position the window was last left at, so + // the content lays out against its final height right away + let initialFrame = restorableFloatingFrame(settings.floatingWindowFrame) ?? defaultFrame let floatingView = FloatingOverlayView( content: overlayContent, @@ -359,7 +364,7 @@ class NotchOverlayController: NSObject { let contentView = NSHostingView(rootView: floatingView) let panel = NSPanel( - contentRect: NSRect(x: xPosition, y: yPosition, width: panelWidth, height: panelHeight), + contentRect: initialFrame, styleMask: [.borderless, .nonactivatingPanel, .resizable], backing: .buffered, defer: false @@ -379,9 +384,45 @@ class NotchOverlayController: NSObject { panel.orderFrontRegardless() self.panel = panel + // Observed after the panel is on screen so the notifications posted while + // it is first placed are not mistaken for user moves + observeFloatingFrame(panel) + installKeyMonitor() } + /// The saved size is restored as-is, but the origin is nudged back onto the + /// display so no edge ends up past it. The overlay draws above the menu bar, + /// so the full screen frame is used and the window can sit flush with the top + /// of the display. A frame whose center no longer lands on a connected display + /// is discarded, so the window cannot return off-screen after a monitor is + /// disconnected. + private func restorableFloatingFrame(_ frame: NSRect?) -> NSRect? { + guard var frame = frame, frame.width > 0, frame.height > 0 else { return nil } + let center = NSPoint(x: frame.midX, y: frame.midY) + guard let screen = NSScreen.screens.first(where: { NSMouseInRect(center, $0.frame, false) }) else { return nil } + + // Windows larger than the display keep their size and overflow the bottom + // right, so the top edge stays within reach. + let bounds = screen.frame + frame.origin.x = frame.width >= bounds.width + ? bounds.minX + : min(max(frame.minX, bounds.minX), bounds.maxX - frame.width) + frame.origin.y = min(max(frame.minY, bounds.minY), bounds.maxY - frame.height) + return frame + } + + private func observeFloatingFrame(_ panel: NSPanel) { + for name in [NSWindow.didMoveNotification, NSWindow.didResizeNotification] { + NotificationCenter.default.publisher(for: name, object: panel) + .sink { notification in + guard let window = notification.object as? NSWindow else { return } + NotchSettings.shared.floatingWindowFrame = window.frame + } + .store(in: &cancellables) + } + } + func dismiss() { guard !isDismissing else { return } isDismissing = true diff --git a/Textream/Textream/NotchSettings.swift b/Textream/Textream/NotchSettings.swift index b136557..9f4d16e 100644 --- a/Textream/Textream/NotchSettings.swift +++ b/Textream/Textream/NotchSettings.swift @@ -362,6 +362,18 @@ class NotchSettings { didSet { UserDefaults.standard.set(Int(pinnedScreenID), forKey: "pinnedScreenID") } } + /// Not observed: nothing renders from this, and it is written on every drag + /// and resize step, which would otherwise redraw the overlay continuously. + @ObservationIgnored var floatingWindowFrame: NSRect? { + didSet { + if let floatingWindowFrame { + UserDefaults.standard.set(NSStringFromRect(floatingWindowFrame), forKey: "floatingWindowFrame") + } else { + UserDefaults.standard.removeObject(forKey: "floatingWindowFrame") + } + } + } + var floatingGlassEffect: Bool { didSet { UserDefaults.standard.set(floatingGlassEffect, forKey: "floatingGlassEffect") } } @@ -480,6 +492,8 @@ class NotchSettings { self.notchDisplayMode = NotchDisplayMode(rawValue: UserDefaults.standard.string(forKey: "notchDisplayMode") ?? "") ?? .followMouse let savedPinnedScreenID = UserDefaults.standard.integer(forKey: "pinnedScreenID") self.pinnedScreenID = UInt32(savedPinnedScreenID) + let savedFloatingFrame = NSRectFromString(UserDefaults.standard.string(forKey: "floatingWindowFrame") ?? "") + self.floatingWindowFrame = savedFloatingFrame.width > 0 && savedFloatingFrame.height > 0 ? savedFloatingFrame : nil self.floatingGlassEffect = UserDefaults.standard.object(forKey: "floatingGlassEffect") as? Bool ?? false let savedOpacity = UserDefaults.standard.double(forKey: "glassOpacity") self.glassOpacity = savedOpacity > 0 ? savedOpacity : 0.15 diff --git a/Textream/Textream/SettingsView.swift b/Textream/Textream/SettingsView.swift index ad74631..c150961 100644 --- a/Textream/Textream/SettingsView.swift +++ b/Textream/Textream/SettingsView.swift @@ -1407,6 +1407,7 @@ struct SettingsView: View { settings.overlayMode = .pinned settings.notchDisplayMode = .followMouse settings.pinnedScreenID = 0 + settings.floatingWindowFrame = nil settings.floatingGlassEffect = false settings.glassOpacity = 0.15 settings.overlayTransparency = false