| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395 |
- import AppKit
- import Combine
- import SwiftUI
- final class DesktopWidgetWindowManager: ObservableObject {
- static let shared = DesktopWidgetWindowManager()
- private let storageKey = "widgetInstancesData"
- private var windowsByInstanceID: [UUID: NSPanel] = [:]
- /// Bumps when persisted widget instances are added or removed so SwiftUI can refresh pickers.
- @Published private(set) var instancesChangeToken: Int = 0
- private init() {}
- private func notifyInstancesChanged() {
- instancesChangeToken += 1
- }
- func show(instance: WidgetInstance, appProvider: (UUID) -> LauncherApp?) {
- guard let app = appProvider(instance.appID) else { return }
- let resolvedVariant = WidgetTemplates.variant(for: app, variantID: instance.variantID)
- var resolvedInstance = instance
- if resolvedInstance.origin == nil {
- resolvedInstance.origin = nextAutoPlacement(for: resolvedVariant, appProvider: appProvider)
- }
- let panel = makePanel(for: resolvedInstance, app: app, variant: resolvedVariant)
- windowsByInstanceID[instance.id] = panel
- persistUpsert(instance: resolvedInstance)
- if let origin = resolvedInstance.origin {
- panel.setFrameOrigin(NSPoint(x: origin.x, y: origin.y))
- // Persist immediately so future auto-placement has accurate collision data.
- persistOrigin(instanceID: instance.id, origin: panel.frame.origin)
- } else {
- panel.center()
- // Persist immediately so future auto-placement has accurate collision data.
- persistOrigin(instanceID: instance.id, origin: panel.frame.origin)
- }
- // Ensure the newly added widget is visually on top.
- panel.orderFront(nil)
- panel.orderFrontRegardless()
- notifyInstancesChanged()
- }
- /// Returns `true` if there is already a persisted widget instance for the given `appID + variantID`.
- /// This is used by the widget picker UI to toggle `+`/`-` for each widget card.
- func hasInstance(appID: UUID, variantID: String) -> Bool {
- return loadInstances().contains { $0.appID == appID && $0.variantID == variantID }
- }
- /// Removes any widget instances matching the given `appID + variantID`.
- /// - Returns: number of instances removed.
- func removeInstances(appID: UUID, variantID: String) -> Int {
- let instances = loadInstances()
- let removed = instances.filter { $0.appID == appID && $0.variantID == variantID }
- let remaining = instances.filter { !($0.appID == appID && $0.variantID == variantID) }
- for instance in removed {
- if let win = windowsByInstanceID.removeValue(forKey: instance.id) {
- win.close()
- }
- }
- saveInstances(remaining)
- notifyInstancesChanged()
- return removed.count
- }
- func restore(appProvider: (UUID) -> LauncherApp?) {
- let instances = loadInstances()
- for instance in instances {
- guard windowsByInstanceID[instance.id] == nil else { continue }
- show(instance: instance, appProvider: appProvider)
- }
- }
- func removeAll(forAppID appID: UUID) {
- let instances = loadInstances()
- let remaining = instances.filter { $0.appID != appID }
- let removed = instances.filter { $0.appID == appID }
- for instance in removed {
- if let win = windowsByInstanceID.removeValue(forKey: instance.id) {
- win.close()
- }
- }
- saveInstances(remaining)
- notifyInstancesChanged()
- }
- // MARK: - Panel construction
- private func makePanel(for instance: WidgetInstance, app: LauncherApp, variant: WidgetVariant) -> NSPanel {
- let size = panelSize(for: variant)
- let panel: NSPanel
- if variant.layoutMode.needsKeyCapablePanel {
- // Key-capable so embedded web views and search fields receive clicks and typing reliably.
- panel = InteractiveMapsWidgetPanel(
- contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height),
- styleMask: [.nonactivatingPanel, .borderless],
- backing: .buffered,
- defer: false
- )
- panel.becomesKeyOnlyIfNeeded = true
- } else {
- panel = DesktopWidgetPanel(
- contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height),
- styleMask: [.nonactivatingPanel, .borderless],
- backing: .buffered,
- defer: false
- )
- panel.becomesKeyOnlyIfNeeded = false
- }
- panel.isReleasedWhenClosed = false
- panel.isFloatingPanel = false
- panel.isMovable = true
- // One level above desktop icons so Finder desktop clicks do not leave our widget behind the desktop layer.
- let desktopIconLevel = Int(CGWindowLevelForKey(.desktopIconWindow))
- panel.level = NSWindow.Level(rawValue: desktopIconLevel + 1)
- // System window shadow is drawn as a rectangular halo; with transparent, rounded content it reads as ugly black lines.
- panel.hasShadow = false
- panel.isOpaque = false
- panel.backgroundColor = .clear
- panel.isMovableByWindowBackground = true
- panel.hidesOnDeactivate = false
- panel.ignoresMouseEvents = false
- panel.collectionBehavior = [
- .canJoinAllSpaces,
- .stationary,
- .ignoresCycle,
- ]
- let root = DesktopWidgetHostView(
- app: app,
- variant: variant,
- onRemove: { self.close(instanceID: instance.id) }
- )
- let hosting = NSHostingView(rootView: root)
- hosting.translatesAutoresizingMaskIntoConstraints = false
- let container = NSView()
- container.translatesAutoresizingMaskIntoConstraints = false
- container.wantsLayer = true
- container.layer?.backgroundColor = NSColor.clear.cgColor
- container.layer?.borderWidth = 0
- container.layer?.cornerRadius = 22
- container.layer?.masksToBounds = true
- container.addSubview(hosting)
- hosting.wantsLayer = true
- hosting.layer?.borderWidth = 0
- hosting.layer?.backgroundColor = NSColor.clear.cgColor
- NSLayoutConstraint.activate([
- hosting.leadingAnchor.constraint(equalTo: container.leadingAnchor),
- hosting.trailingAnchor.constraint(equalTo: container.trailingAnchor),
- hosting.topAnchor.constraint(equalTo: container.topAnchor),
- hosting.bottomAnchor.constraint(equalTo: container.bottomAnchor),
- ])
- panel.contentView = container
- NotificationCenter.default.addObserver(
- forName: NSWindow.didMoveNotification,
- object: panel,
- queue: .main
- ) { [weak self, weak panel] _ in
- guard let self, let panel else { return }
- self.persistOrigin(instanceID: instance.id, origin: panel.frame.origin)
- }
- return panel
- }
- private func panelSize(for variant: WidgetVariant) -> CGSize {
- if variant.layoutMode == .interactiveMap {
- switch variant.size {
- case .small:
- return CGSize(width: 340, height: 420)
- case .medium:
- return CGSize(width: 460, height: 400)
- case .large:
- return CGSize(width: 520, height: 560)
- }
- }
- if let override = variant.layoutMode.desktopPanelSizeOverride() {
- return override
- }
- switch variant.size {
- case .small:
- return CGSize(width: 220, height: 210)
- case .medium:
- return CGSize(width: 390, height: 210)
- case .large:
- return CGSize(width: 390, height: 400)
- }
- }
- /// Region where macOS system desktop widgets (Calendar, Weather, Photos, etc.) usually sit.
- /// Those windows are composited above normal third-party desktop windows, so our panels cannot
- /// appear “on top” of them—auto-placement must avoid this area so the user sees our widget beside them.
- private func reservedSystemDesktopWidgetZone(in visibleFrame: NSRect) -> NSRect {
- let margin: CGFloat = 18
- let w = min(560, visibleFrame.width * 0.48)
- let h = min(420, visibleFrame.height * 0.55)
- return NSRect(
- x: visibleFrame.minX + margin,
- y: visibleFrame.maxY - margin - h,
- width: w,
- height: h
- )
- }
- private func nextAutoPlacement(for variant: WidgetVariant, appProvider: (UUID) -> LauncherApp?) -> WidgetInstance.Origin {
- let panelSizeForNew = panelSize(for: variant)
- let frame = NSScreen.main?.visibleFrame ?? NSRect(x: 100, y: 100, width: 1200, height: 800)
- let systemWidgetZone = reservedSystemDesktopWidgetZone(in: frame)
- let margin: CGFloat = 18
- let gapX: CGFloat = 26
- let gapY: CGFloat = 20
- let cellW = panelSizeForNew.width + gapX
- let cellH = panelSizeForNew.height + gapY
- // Existing widgets:
- // 1) Live panels already on screen (most reliable; avoids missing persisted origins).
- // 2) Persisted widget origins (fallback for already-closed widgets).
- let liveRects: [NSRect] = windowsByInstanceID.values.map { $0.frame }
- let persistedRects: [NSRect] = loadInstances().compactMap { instance in
- guard let origin = instance.origin else { return nil }
- guard let app = appProvider(instance.appID) else { return nil }
- let v = WidgetTemplates.variant(for: app, variantID: instance.variantID)
- let s = panelSize(for: v)
- return NSRect(x: origin.x, y: origin.y, width: s.width, height: s.height)
- }
- let existingRects = liveRects + persistedRects
- let keepOut: CGFloat = 8
- func overlapsExisting(_ candidate: NSRect) -> Bool {
- let inflated = candidate.insetBy(dx: -keepOut, dy: -keepOut)
- return existingRects.contains(where: { $0.intersects(inflated) })
- }
- func overlapsSystemWidgetZone(_ candidate: NSRect) -> Bool {
- candidate.intersects(systemWidgetZone)
- }
- let cols = max(1, Int((frame.width - margin * 2) / max(cellW, 1)))
- let maxSlots = 200
- // Scan row-major for the first slot that avoids our widgets and Apple's desktop widget zone.
- for slot in 0..<maxSlots {
- let row = slot / cols
- let col = slot % cols
- let x = frame.minX + margin + CGFloat(col) * cellW
- let y = frame.maxY - margin - panelSizeForNew.height - CGFloat(row) * cellH
- let candidate = NSRect(x: x, y: y, width: panelSizeForNew.width, height: panelSizeForNew.height)
- // Keep inside visible bounds with margin.
- guard candidate.minX >= frame.minX + margin,
- candidate.maxX <= frame.maxX - margin,
- candidate.minY >= frame.minY + margin,
- candidate.maxY <= frame.maxY - margin else {
- continue
- }
- if !overlapsExisting(candidate), !overlapsSystemWidgetZone(candidate) {
- return WidgetInstance.Origin(x: candidate.minX, y: candidate.minY)
- }
- }
- // Fallback: first column to the right of the system widget zone, then continue the grid.
- let safeMinX = max(systemWidgetZone.maxX + gapX, frame.minX + margin)
- for slot in 0..<maxSlots {
- let row = slot / cols
- let col = slot % cols
- let x = safeMinX + CGFloat(col) * cellW
- let y = frame.maxY - margin - panelSizeForNew.height - CGFloat(row) * cellH
- let candidate = NSRect(x: x, y: y, width: panelSizeForNew.width, height: panelSizeForNew.height)
- guard candidate.minX >= frame.minX + margin,
- candidate.maxX <= frame.maxX - margin,
- candidate.minY >= frame.minY + margin,
- candidate.maxY <= frame.maxY - margin else {
- continue
- }
- if !overlapsExisting(candidate) {
- return WidgetInstance.Origin(x: candidate.minX, y: candidate.minY)
- }
- }
- // Last resort: center of screen (usually clear of the top-left widget stack).
- let cx = frame.midX - panelSizeForNew.width / 2
- let cy = frame.midY - panelSizeForNew.height / 2
- let x = min(max(cx, frame.minX + margin), frame.maxX - margin - panelSizeForNew.width)
- let y = min(max(cy, frame.minY + margin), frame.maxY - margin - panelSizeForNew.height)
- return WidgetInstance.Origin(x: x, y: y)
- }
- private func close(instanceID: UUID) {
- if let win = windowsByInstanceID.removeValue(forKey: instanceID) {
- win.close()
- }
- let instances = loadInstances().filter { $0.id != instanceID }
- saveInstances(instances)
- notifyInstancesChanged()
- }
- // MARK: - Persistence
- private func loadInstances() -> [WidgetInstance] {
- guard let data = UserDefaults.standard.data(forKey: storageKey) else { return [] }
- do {
- return try JSONDecoder().decode([WidgetInstance].self, from: data)
- } catch {
- return []
- }
- }
- private func saveInstances(_ instances: [WidgetInstance]) {
- do {
- let data = try JSONEncoder().encode(instances)
- UserDefaults.standard.set(data, forKey: storageKey)
- } catch {
- // Ignore and keep UI responsive.
- }
- }
- private func persistUpsert(instance: WidgetInstance) {
- var instances = loadInstances()
- if let idx = instances.firstIndex(where: { $0.id == instance.id }) {
- instances[idx] = instance
- } else {
- instances.append(instance)
- }
- saveInstances(instances)
- }
- private func persistOrigin(instanceID: UUID, origin: NSPoint) {
- var instances = loadInstances()
- guard let idx = instances.firstIndex(where: { $0.id == instanceID }) else { return }
- instances[idx].origin = WidgetInstance.Origin(x: origin.x, y: origin.y)
- saveInstances(instances)
- }
- }
- private final class DesktopWidgetPanel: NSPanel {
- override var canBecomeKey: Bool { false }
- override var canBecomeMain: Bool { false }
- override func mouseDown(with event: NSEvent) {
- orderFront(nil)
- super.mouseDown(with: event)
- }
- }
- /// Used for the interactive Google Maps widget so `TextField` and toolbar buttons can become first responder.
- private final class InteractiveMapsWidgetPanel: NSPanel {
- override var canBecomeKey: Bool { true }
- override var canBecomeMain: Bool { false }
- override func mouseDown(with event: NSEvent) {
- orderFront(nil)
- super.mouseDown(with: event)
- }
- }
- private struct DesktopWidgetHostView: View {
- let app: LauncherApp
- let variant: WidgetVariant
- let onRemove: () -> Void
- var body: some View {
- DesktopWidgetView(app: app, variant: variant, isPreview: false)
- .contextMenu {
- Button("Remove Widget", role: .destructive) {
- onRemove()
- }
- }
- }
- }
|