Skip to content

Commit 99d257d

Browse files
authored
fix: enforce main window sizing contract (#322)
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
1 parent 1123d6c commit 99d257d

5 files changed

Lines changed: 131 additions & 15 deletions

File tree

Sources/Kaset/AppDelegate.swift

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -118,16 +118,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
118118
private func setupWindowDelegate() {
119119
DiagnosticsLogger.app.info("AppDelegate: setupWindowDelegate starting")
120120
for window in NSApplication.shared.windows where window.canBecomeMain {
121-
// Skip auxiliary player windows; only the regular app window should be hidden-on-close.
122-
if self.isAuxiliaryPlayerWindow(window) {
121+
// Skip auxiliary/player and non-primary scene windows; only the regular app window should be hidden-on-close.
122+
if self.isAuxiliaryPlayerWindow(window) || !MainWindowLayout.isPrimaryWindow(window) {
123123
continue
124124
}
125125
window.delegate = self
126-
// Enable automatic window frame persistence using autosave name
127-
// This ensures window size/position is restored across app launches
128-
if window.frameAutosaveName.isEmpty {
129-
window.setFrameAutosaveName("KasetMainWindow")
130-
}
126+
MainWindowLayout.configure(window)
131127
// Store reference to main window for reliable reopen
132128
self.mainWindow = window
133129
}
@@ -228,15 +224,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
228224
private func showMainWindowIfNeeded() {
229225
DiagnosticsLogger.app.info("AppDelegate: showMainWindowIfNeeded")
230226
// Try stored reference first
231-
if let mainWindow {
227+
if let mainWindow, MainWindowLayout.isPrimaryWindow(mainWindow) {
228+
MainWindowLayout.configure(mainWindow)
232229
if !mainWindow.isVisible {
233230
mainWindow.makeKeyAndOrderFront(nil)
234231
}
235232
return
236233
}
237234

238235
// Fallback: find main window by frameAutosaveName
239-
for window in NSApplication.shared.windows where window.frameAutosaveName == "KasetMainWindow" {
236+
for window in NSApplication.shared.windows where window.frameAutosaveName == MainWindowLayout.autosaveName {
237+
MainWindowLayout.configure(window)
240238
self.mainWindow = window
241239
if !window.isVisible {
242240
window.makeKeyAndOrderFront(nil)
@@ -245,11 +243,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
245243
}
246244

247245
// Last resort: find any main-capable window that's not an auxiliary player window.
246+
// Do not apply the primary-window sizing contract here: a generic fallback
247+
// may match Settings or another regular scene window.
248248
for window in NSApplication.shared.windows where window.canBecomeMain {
249249
if self.isAuxiliaryPlayerWindow(window) {
250250
continue
251251
}
252-
self.mainWindow = window
253252
if !window.isVisible {
254253
window.makeKeyAndOrderFront(nil)
255254
}

Sources/Kaset/KasetApp.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,8 @@ struct KasetApp: App {
264264
}
265265
}
266266
}
267+
.defaultSize(width: MainWindowLayout.defaultWidth, height: MainWindowLayout.defaultHeight)
268+
.windowResizability(.contentMinSize)
267269

268270
Settings {
269271
SettingsView()
@@ -503,19 +505,23 @@ struct KasetApp: App {
503505
@discardableResult
504506
private func focusExistingMainWindow() -> Bool {
505507
// Find and show the main window
506-
for window in NSApplication.shared.windows where window.frameAutosaveName == "KasetMainWindow" {
508+
for window in NSApplication.shared.windows where window.frameAutosaveName == MainWindowLayout.autosaveName {
509+
MainWindowLayout.configure(window)
507510
window.makeKeyAndOrderFront(nil)
508511
NSApplication.shared.activate(ignoringOtherApps: true)
509512
return true
510513
}
511514

512-
for window in NSApplication.shared.windows where window.title == "Kaset" && !Self.isAuxiliaryPlayerWindow(window) {
515+
for window in NSApplication.shared.windows where window.title == MainWindowLayout.windowTitle && !Self.isAuxiliaryPlayerWindow(window) {
516+
MainWindowLayout.configure(window)
513517
window.makeKeyAndOrderFront(nil)
514518
NSApplication.shared.activate(ignoringOtherApps: true)
515519
return true
516520
}
517521

518522
// Fallback: find any main-capable window that's not an auxiliary player window.
523+
// Do not apply the primary-window sizing contract here: a generic fallback
524+
// may match Settings or another regular scene window.
519525
for window in NSApplication.shared.windows where window.canBecomeMain {
520526
if Self.isAuxiliaryPlayerWindow(window) {
521527
continue
@@ -534,7 +540,7 @@ struct KasetApp: App {
534540

535541
/// Hides the main window while keeping playback and auxiliary windows alive.
536542
private func hideMainWindow() {
537-
for window in NSApplication.shared.windows where window.frameAutosaveName == "KasetMainWindow" {
543+
for window in NSApplication.shared.windows where window.frameAutosaveName == MainWindowLayout.autosaveName {
538544
window.orderOut(nil)
539545
return
540546
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import AppKit
2+
3+
// MARK: - MainWindowLayout
4+
5+
/// Shared sizing contract for Kaset's primary app window.
6+
///
7+
/// SwiftUI's `.frame(minWidth:minHeight:)` documents the layout floor for the
8+
/// view hierarchy, while this helper applies the same floor to the underlying
9+
/// `NSWindow` so live resizing and restored autosaved frames cannot shrink the
10+
/// window below the point where the sidebar/player controls remain usable.
11+
enum MainWindowLayout {
12+
static let autosaveName = "KasetMainWindow"
13+
static let windowTitle = "Kaset"
14+
static let minimumWidth: CGFloat = 980
15+
static let minimumHeight: CGFloat = 600
16+
static let defaultWidth: CGFloat = 1100
17+
static let defaultHeight: CGFloat = 760
18+
19+
static var minimumContentSize: NSSize {
20+
NSSize(width: minimumWidth, height: minimumHeight)
21+
}
22+
23+
/// Returns true for windows that are known to be the primary app window.
24+
static func isPrimaryWindowIdentity(title: String, frameAutosaveName: String) -> Bool {
25+
frameAutosaveName == self.autosaveName || title == self.windowTitle
26+
}
27+
28+
@MainActor
29+
static func isPrimaryWindow(_ window: NSWindow) -> Bool {
30+
self.isPrimaryWindowIdentity(title: window.title, frameAutosaveName: window.frameAutosaveName)
31+
}
32+
33+
/// Applies the primary-window sizing contract to an AppKit window.
34+
@MainActor
35+
static func configure(_ window: NSWindow) {
36+
guard self.isPrimaryWindow(window) else { return }
37+
38+
if window.frameAutosaveName.isEmpty {
39+
window.setFrameAutosaveName(self.autosaveName)
40+
}
41+
42+
window.contentMinSize = self.minimumContentSize
43+
self.expandIfNeeded(window)
44+
}
45+
46+
/// Pure clamp used by both AppKit configuration and tests.
47+
static func clampedContentSize(_ contentSize: NSSize) -> NSSize {
48+
NSSize(
49+
width: max(contentSize.width, self.minimumWidth),
50+
height: max(contentSize.height, self.minimumHeight)
51+
)
52+
}
53+
54+
@MainActor
55+
private static func expandIfNeeded(_ window: NSWindow) {
56+
let currentFrame = window.frame
57+
let currentContentSize = window.contentRect(forFrameRect: currentFrame).size
58+
let clampedContentSize = Self.clampedContentSize(currentContentSize)
59+
60+
guard clampedContentSize.width > currentContentSize.width
61+
|| clampedContentSize.height > currentContentSize.height
62+
else { return }
63+
64+
let clampedFrameSize = window.frameRect(
65+
forContentRect: NSRect(origin: .zero, size: clampedContentSize)
66+
).size
67+
var clampedFrame = currentFrame
68+
clampedFrame.size = clampedFrameSize
69+
// Keep the titlebar/top edge anchored when expanding a stale restored
70+
// frame, so the window does not jump downward on launch/reopen.
71+
clampedFrame.origin.y = currentFrame.maxY - clampedFrameSize.height
72+
73+
let constrainedFrame = window.constrainFrameRect(clampedFrame, to: window.screen)
74+
window.setFrame(constrainedFrame, display: true)
75+
}
76+
}

Sources/Kaset/Views/MainWindow.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ struct MainWindow: View {
192192
AccountErrorToast()
193193
.padding(.top, 60)
194194
}
195+
.frame(minWidth: MainWindowLayout.minimumWidth, minHeight: MainWindowLayout.minimumHeight)
195196
.onChange(of: self.showCommandBar.wrappedValue) { _, newValue in
196197
if newValue {
197198
self.presentCommandBarIfAvailable()
@@ -413,7 +414,7 @@ struct MainWindow: View {
413414
}
414415
.animation(.easeInOut(duration: 0.25), value: self.playerService.showLyrics)
415416
.animation(.easeInOut(duration: 0.25), value: self.playerService.showQueue)
416-
.frame(minWidth: 980, minHeight: 600)
417+
.frame(minWidth: MainWindowLayout.minimumWidth, minHeight: MainWindowLayout.minimumHeight)
417418
.toolbar {
418419
if self.supportsCommandBarUI {
419420
ToolbarItem(placement: .primaryAction) {
@@ -598,7 +599,7 @@ struct MainWindow: View {
598599
.controlSize(.regular)
599600
.frame(width: 20, height: 20)
600601
}
601-
.frame(minWidth: 980, minHeight: 600)
602+
.frame(minWidth: MainWindowLayout.minimumWidth, minHeight: MainWindowLayout.minimumHeight)
602603
}
603604

604605
private func handleAuthStateChange(oldState: AuthService.State, newState: AuthService.State) {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import AppKit
2+
import Testing
3+
@testable import Kaset
4+
5+
@Suite("Main window layout", .serialized)
6+
struct MainWindowLayoutTests {
7+
@Test("Clamps undersized restored content frames")
8+
func clampsUndersizedContentFrames() {
9+
let clamped = MainWindowLayout.clampedContentSize(NSSize(width: 640, height: 420))
10+
11+
#expect(clamped.width == MainWindowLayout.minimumWidth)
12+
#expect(clamped.height == MainWindowLayout.minimumHeight)
13+
}
14+
15+
@Test("Leaves larger content frames unchanged")
16+
func leavesLargerContentFramesUnchanged() {
17+
let size = NSSize(width: 1400, height: 900)
18+
19+
#expect(MainWindowLayout.clampedContentSize(size) == size)
20+
}
21+
22+
@Test("Minimum AppKit content size matches SwiftUI content floor")
23+
func minimumContentSizeMatchesSwiftUIFloor() {
24+
#expect(MainWindowLayout.minimumContentSize.width == MainWindowLayout.minimumWidth)
25+
#expect(MainWindowLayout.minimumContentSize.height == MainWindowLayout.minimumHeight)
26+
}
27+
28+
@Test("Primary window identity excludes other regular scene windows")
29+
func primaryWindowIdentityExcludesOtherRegularSceneWindows() {
30+
#expect(MainWindowLayout.isPrimaryWindowIdentity(title: MainWindowLayout.windowTitle, frameAutosaveName: ""))
31+
#expect(MainWindowLayout.isPrimaryWindowIdentity(title: "Settings", frameAutosaveName: MainWindowLayout.autosaveName))
32+
#expect(!MainWindowLayout.isPrimaryWindowIdentity(title: "Settings", frameAutosaveName: ""))
33+
}
34+
}

0 commit comments

Comments
 (0)