mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
feat(window): persist window size + position across app launches
SwiftUI's WindowGroup exposes `.defaultSize` and `.windowResizability` but no built-in autosave for window frame across launches. The documented escape hatch is AppKit's `NSWindow.setFrameAutosaveName(_:)`, which writes the frame to UserDefaults on resize/move and restores it on next open. Add a small `WindowFrameAutosave` NSViewRepresentable that finds its hosting NSWindow on first appear and stamps the autosave name. Apply it to `ContextBoundRoot` keyed off `context.id` so each open server window remembers its own geometry. New servers fall back to the WindowGroup's `.defaultSize(1100, 700)` until the user resizes once. A previous WIP attempt (dd4a61f) tried to use a fictional `.windowFrameAutosaveName(...)` SwiftUI modifier that doesn't exist — which is why it was never merged. This works because we go through AppKit directly. Also picks up Xcode's auto-extracted cron-related Localizable.xcstrings entries that had been pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Persist a SwiftUI `WindowGroup` window's frame (size + position) across
|
||||
/// app launches by hooking into AppKit's `NSWindow.setFrameAutosaveName`.
|
||||
///
|
||||
/// **Why this exists.** SwiftUI's `WindowGroup` exposes `.defaultSize`,
|
||||
/// `.windowResizability`, and (on macOS Sonoma+) various scene modifiers
|
||||
/// — but not a "remember this window's size between launches" affordance.
|
||||
/// Apple's documented escape hatch is AppKit's `setFrameAutosaveName(_:)`,
|
||||
/// which writes the window's frame to UserDefaults on resize/move and
|
||||
/// reads it back on next `makeKey`. We bridge into it from SwiftUI via an
|
||||
/// invisible `NSViewRepresentable` that finds the hosting `NSWindow`
|
||||
/// and stamps the autosave name once it appears.
|
||||
///
|
||||
/// **Usage.**
|
||||
/// ContentView()
|
||||
/// .windowFrameAutosave("Scarf.\(context.id)")
|
||||
///
|
||||
/// Pass a stable identifier per logical window. Different identifiers per
|
||||
/// window are required by AppKit ("no two windows can be associated with
|
||||
/// the same name simultaneously" — `NSWindow.setFrameAutosaveName(_:)`
|
||||
/// docs). For Scarf's multi-window-per-server model, keying off
|
||||
/// `ServerID` gives each server window its own remembered frame.
|
||||
///
|
||||
/// **First-launch behaviour.** No saved frame exists → AppKit leaves the
|
||||
/// window at whatever frame SwiftUI's `.defaultSize` produced. After the
|
||||
/// first user resize, AppKit autosaves and subsequent opens restore the
|
||||
/// new frame.
|
||||
///
|
||||
/// **What it doesn't do.** Doesn't capture/restore fullscreen state
|
||||
/// (AppKit handles that separately and reasonably). Doesn't try to
|
||||
/// override window state restoration when the user has the system-level
|
||||
/// "Close windows when quitting an application" setting OFF — that
|
||||
/// pathway runs first and we just ride alongside.
|
||||
struct WindowFrameAutosave: NSViewRepresentable {
|
||||
let name: String
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView(frame: .zero)
|
||||
// The hosting NSWindow isn't attached to this view yet at
|
||||
// makeNSView time — SwiftUI mounts the AppKit view hierarchy
|
||||
// before the window assignment propagates. Defer one runloop
|
||||
// iteration so `view.window` is non-nil when we stamp.
|
||||
DispatchQueue.main.async { [weak view] in
|
||||
view?.window?.setFrameAutosaveName(name)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
// SwiftUI may swap the host window in rare cases (window
|
||||
// restoration after a relaunch, scene reuse). Re-stamp on
|
||||
// update so we don't lose the autosave binding silently.
|
||||
// setFrameAutosaveName is idempotent for the same name on
|
||||
// the same window; assigning the same name twice is a no-op.
|
||||
DispatchQueue.main.async { [weak nsView] in
|
||||
guard let window = nsView?.window else { return }
|
||||
if window.frameAutosaveName != name {
|
||||
window.setFrameAutosaveName(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Persist this view's hosting window's frame (size + position)
|
||||
/// across launches under `name`. See `WindowFrameAutosave` for
|
||||
/// details.
|
||||
func windowFrameAutosave(_ name: String) -> some View {
|
||||
background(WindowFrameAutosave(name: name))
|
||||
}
|
||||
}
|
||||
@@ -11385,6 +11385,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LAST RUN OUTPUT" : {
|
||||
"comment" : "A label displayed above the last run output.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Last run: %@" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@@ -13909,6 +13913,10 @@
|
||||
"comment" : "A message that appears when the user is not logged in to Nous Portal.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No output yet — this job hasn't run, or its output file is gone." : {
|
||||
"comment" : "A message displayed when a cron job's output",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No output yet." : {
|
||||
"comment" : "A message displayed when a tool call has not yet produced output.",
|
||||
"isCommentAutoGenerated" : true
|
||||
|
||||
@@ -252,6 +252,12 @@ private struct ContextBoundRoot: View {
|
||||
// title gives macOS Mission Control / ⌘` cycling a meaningful
|
||||
// label so users can pick the right window without focusing it.
|
||||
.navigationTitle("Scarf — \(context.displayName)")
|
||||
// Persist this window's frame (size + position) across
|
||||
// launches via AppKit's NSWindow.frameAutosaveName. The
|
||||
// autosave name is per-server so each open server window
|
||||
// remembers its own geometry; new servers fall back to
|
||||
// WindowGroup's `.defaultSize` until first resize.
|
||||
.windowFrameAutosave("Scarf.Window.\(context.id)")
|
||||
.onAppear { fileWatcher.startWatching() }
|
||||
.onDisappear { fileWatcher.stopWatching() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user