bugger/Sources/Views/FloatingWidget/FloatingWidgetWindow.swift

146 lines
5.2 KiB
Swift

import AppKit
import SwiftUI
final class FloatingWidgetWindow: NSPanel {
private let margin: CGFloat = 16
private let bottomOffset: CGFloat = 200
init() {
super.init(
contentRect: NSRect(x: 0, y: 0, width: 1, height: 1),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
isFloatingPanel = true
level = .floating
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
isOpaque = false
backgroundColor = .clear
hasShadow = true
isMovableByWindowBackground = true
hidesOnDeactivate = false
animationBehavior = .none
// Place the 1x1 placeholder in the bottom-right corner so the first
// content measurement anchors to the screen's right edge.
if let screen = NSScreen.main {
let screenFrame = screen.visibleFrame
setFrameOrigin(NSPoint(
x: screenFrame.maxX - 1 - margin,
y: screenFrame.minY + bottomOffset
))
}
let rootView = FloatingWidgetView { [weak self] size in
self?.applyContentSize(size)
}
let hostingView = NSHostingView(rootView: rootView)
contentView = hostingView
// Size to the content's intrinsic size up front to avoid a 1x1 flash
// before the first SwiftUI layout pass reports a measurement.
applyContentSize(hostingView.fittingSize)
}
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
/// Resizes the window to fit its content while keeping the bottom-right
/// corner anchored, so a growing bug count extends leftward instead of
/// spilling off-screen. Clamped to the visible screen frame as a safety
/// net (e.g. when the user has dragged the widget toward an edge).
private func applyContentSize(_ size: CGSize) {
guard size.width > 0, size.height > 0 else { return }
// Skip no-op resizes to avoid a layout feedback loop with SwiftUI.
guard abs(size.width - frame.width) > 0.5
|| abs(size.height - frame.height) > 0.5 else { return }
var newFrame = frame
// Keep the right edge: grow leftward as the count widens.
newFrame.origin.x = newFrame.maxX - size.width
// Keep the bottom edge: grow upward when the priority row appears.
newFrame.origin.y = newFrame.minY
newFrame.size = size
if let screen = NSScreen.main {
let visible = screen.visibleFrame
newFrame.origin.x = max(newFrame.origin.x, visible.minX)
newFrame.origin.y = min(
max(newFrame.origin.y, visible.minY),
visible.maxY - size.height
)
}
setFrame(newFrame, display: true)
}
}
struct FloatingWidgetView: View {
@State private var bugStore = BugStore.shared
let onSizeChange: (CGSize) -> Void
private var priorityCounts: [(BugPriority, Int)] {
let active = bugStore.activeBugs
return BugPriority.allCases
.filter { $0 != .unknown }
.map { priority in
(priority, active.filter { $0.priority == priority }.count)
}
.filter { $0.1 > 0 }
}
var body: some View {
Button {
AppDelegate.shared?.togglePopover()
} label: {
VStack(spacing: 4) {
HStack(spacing: 4) {
Image(systemName: "ant.fill")
.font(.caption)
Text("\(bugStore.activeBugs.count)")
.font(.title3)
.fontWeight(.bold)
.monospacedDigit()
}
if !priorityCounts.isEmpty {
HStack(spacing: 6) {
ForEach(priorityCounts, id: \.0) { priority, count in
HStack(spacing: 2) {
Circle()
.fill(priority.color)
.frame(width: 6, height: 6)
Text("\(count)")
.font(.caption2)
.fontWeight(.medium)
.monospacedDigit()
}
}
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(.ultraThinMaterial)
.shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4)
)
}
.buttonStyle(.plain)
// Let the button take its intrinsic size so the window can fit it
// exactly, regardless of the (initially 1x1) hosting container.
.fixedSize()
.background(
GeometryReader { proxy in
Color.clear
.onAppear { onSizeChange(proxy.size) }
.onChange(of: proxy.size) { _, newSize in
onSizeChange(newSize)
}
}
)
}
}