146 lines
5.2 KiB
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)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|