aboutsummaryrefslogtreecommitdiff
path: root/menubar/panel.swift
diff options
context:
space:
mode:
Diffstat (limited to 'menubar/panel.swift')
-rw-r--r--menubar/panel.swift314
1 files changed, 314 insertions, 0 deletions
diff --git a/menubar/panel.swift b/menubar/panel.swift
new file mode 100644
index 0000000..ad5ee99
--- /dev/null
+++ b/menubar/panel.swift
@@ -0,0 +1,314 @@
+// The floating panel: an always-on-top HUD with the agents waiting for you.
+//
+// - Never takes focus: a non-activating panel, so typing stays in the
+// terminal. Visible on every Space and over full-screen apps.
+// - Modes: off, on, auto (shown while something is blocked or done).
+// The global shortcut shows or hides it on top of the mode.
+// - Compact: blocked and done agents in full; working and idle as one
+// summary line. The menu keeps the full list.
+
+import AppKit
+
+// MARK: visibility (pure, tested by --selftest)
+
+enum PanelMode: String, CaseIterable {
+ case off, on, auto
+
+ var label: String {
+ switch self {
+ case .off: return "Off"
+ case .on: return "Always"
+ case .auto: return "When agents are waiting"
+ }
+ }
+}
+
+struct PanelVisibility: Equatable {
+ var mode: PanelMode = .auto
+ /// Set by the shortcut: true = shown, false = hidden, nil = follow mode.
+ var forced: Bool?
+ /// Pane ids of agents currently blocked or done.
+ var waiting: Set<String> = []
+
+ var visible: Bool {
+ forced ?? (mode == .on || (mode == .auto && !waiting.isEmpty))
+ }
+
+ /// The shortcut: flip what is on screen right now.
+ mutating func toggle() {
+ forced = !visible
+ }
+
+ mutating func setMode(_ newMode: PanelMode) {
+ mode = newMode
+ forced = nil
+ }
+
+ /// A newly waiting agent cancels a manual hide (it needs you), except in
+ /// mode off, where nothing shows unless asked for.
+ mutating func update(waiting newWaiting: Set<String>) {
+ if forced == false, !newWaiting.subtracting(waiting).isEmpty {
+ forced = nil
+ }
+ waiting = newWaiting
+ }
+}
+
+/// Run the visibility rules against expectations; returns failure messages.
+func panelSelftest() -> [String] {
+ var failures: [String] = []
+ func check(_ condition: Bool, _ message: String) {
+ if !condition { failures.append(message) }
+ }
+
+ var auto = PanelVisibility(mode: .auto)
+ check(!auto.visible, "auto: hidden with nothing waiting")
+ auto.update(waiting: ["a"])
+ check(auto.visible, "auto: shown when an agent waits")
+ auto.toggle()
+ check(!auto.visible, "auto: shortcut hides")
+ auto.update(waiting: ["a"])
+ check(!auto.visible, "auto: stays hidden for the same waiting agents")
+ auto.update(waiting: ["a", "b"])
+ check(auto.visible, "auto: a newly waiting agent shows it again")
+ auto.update(waiting: [])
+ check(!auto.visible, "auto: hides when nothing waits")
+ auto.toggle()
+ check(auto.visible, "auto: shortcut shows it with nothing waiting")
+ auto.update(waiting: [])
+ check(auto.visible, "auto: a manual show stays")
+ auto.toggle()
+ check(!auto.visible, "auto: shortcut hides again")
+
+ var off = PanelVisibility(mode: .off)
+ off.update(waiting: ["a"])
+ check(!off.visible, "off: hidden even when an agent waits")
+ off.toggle()
+ check(off.visible, "off: shortcut shows")
+ off.toggle()
+ off.update(waiting: ["a", "b"])
+ check(!off.visible, "off: a new waiting agent does not show it")
+
+ var on = PanelVisibility(mode: .on)
+ check(on.visible, "on: shown with nothing waiting")
+ on.toggle()
+ check(!on.visible, "on: shortcut hides")
+ on.update(waiting: ["a"])
+ check(on.visible, "on: a newly waiting agent shows it again")
+
+ var changed = PanelVisibility(mode: .auto, forced: false, waiting: ["a"])
+ changed.setMode(.on)
+ check(changed.visible && changed.forced == nil, "mode change clears the shortcut override")
+ return failures
+}
+
+// MARK: compact content (pure, printed by --print-panel)
+
+struct PanelContent {
+ static let maxRows = 8
+
+ let waiting: [Row]
+ let overflow: Int
+ let working: Int
+ let idle: Int
+
+ init(rows: [Row]) {
+ let waitingRows = rows.filter(\.waiting)
+ waiting = Array(waitingRows.prefix(Self.maxRows))
+ overflow = max(0, waitingRows.count - Self.maxRows)
+ working = rows.filter { $0.status == .working }.count
+ idle = rows.filter { $0.status == .idle }.count
+ }
+
+ /// "◐ 2 working · ○ 15 idle", or "No agents".
+ var summary: NSAttributedString {
+ let font = NSFont.systemFont(ofSize: 11)
+ let text = NSMutableAttributedString()
+ for (status, count) in [(Status.working, working), (.idle, idle)] where count > 0 {
+ if text.length > 0 {
+ text.append(NSAttributedString(
+ string: " · ", attributes: [.font: font, .foregroundColor: NSColor.tertiaryLabelColor]))
+ }
+ text.append(NSAttributedString(
+ string: "\(status.symbol) ", attributes: [.font: font, .foregroundColor: status.color]))
+ text.append(NSAttributedString(
+ string: "\(count) \(status.rawValue)",
+ attributes: [.font: font, .foregroundColor: NSColor.secondaryLabelColor]))
+ }
+ if waiting.isEmpty {
+ let lead = text.length > 0 ? "Nothing waiting · " : "No agents waiting"
+ text.insert(NSAttributedString(
+ string: lead, attributes: [.font: font, .foregroundColor: NSColor.secondaryLabelColor]), at: 0)
+ }
+ return text
+ }
+
+ var plainText: String {
+ var lines = waiting.map { "\($0.status.symbol) \($0.status.rawValue)\t\($0.label)\t\($0.subtitle)" }
+ if overflow > 0 { lines.append("+\(overflow) more waiting") }
+ lines.append(summary.string)
+ return lines.joined(separator: "\n")
+ }
+}
+
+// MARK: window
+
+private let panelWidth: CGFloat = 440
+private let inset: CGFloat = 8
+
+/// One clickable agent row; a drag anywhere moves the panel instead.
+private final class PanelRowView: NSView {
+ private let paneId: String
+ private let onClick: (String) -> Void
+ private var dragged = false
+ private var hovered = false { didSet { needsDisplay = true } }
+
+ init(row: Row, onClick: @escaping (String) -> Void) {
+ paneId = row.paneId
+ self.onClick = onClick
+ super.init(frame: .zero)
+ let title = NSTextField(labelWithAttributedString: rowTitle(row))
+ title.lineBreakMode = .byTruncatingTail
+ let subtitle = NSTextField(labelWithString: row.subtitle)
+ subtitle.font = .systemFont(ofSize: 11)
+ subtitle.textColor = .secondaryLabelColor
+ subtitle.lineBreakMode = .byTruncatingTail
+ let stack = NSStackView(views: [title, subtitle])
+ stack.orientation = .vertical
+ stack.alignment = .leading
+ stack.spacing = 1
+ stack.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(stack)
+ NSLayoutConstraint.activate([
+ stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
+ stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8),
+ stack.topAnchor.constraint(equalTo: topAnchor, constant: 5),
+ stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5),
+ ])
+ toolTip = row.name.isEmpty ? nil : row.name
+ }
+
+ required init?(coder: NSCoder) { fatalError("not used") }
+
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
+
+ override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ trackingAreas.forEach(removeTrackingArea)
+ addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeAlways],
+ owner: self))
+ }
+
+ override func mouseEntered(with event: NSEvent) { hovered = true }
+ override func mouseExited(with event: NSEvent) { hovered = false }
+
+ override func draw(_ dirtyRect: NSRect) {
+ guard hovered else { return }
+ NSColor.white.withAlphaComponent(0.08).setFill()
+ NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6).fill()
+ }
+
+ override func mouseDown(with event: NSEvent) { dragged = false }
+
+ override func mouseDragged(with event: NSEvent) {
+ if !dragged {
+ dragged = true
+ window?.performDrag(with: event)
+ }
+ }
+
+ override func mouseUp(with event: NSEvent) {
+ if !dragged { onClick(paneId) }
+ }
+}
+
+final class PanelController {
+ private let panel: NSPanel
+ private let stack = NSStackView()
+ private let onClick: (String) -> Void
+
+ init(onClick: @escaping (String) -> Void) {
+ self.onClick = onClick
+ panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: 60),
+ styleMask: [.nonactivatingPanel, .borderless],
+ backing: .buffered, defer: false)
+ panel.isFloatingPanel = true
+ panel.level = .floating
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
+ panel.hidesOnDeactivate = false
+ panel.isMovableByWindowBackground = true
+ panel.backgroundColor = .clear
+ panel.isOpaque = false
+ panel.hasShadow = true
+ panel.appearance = NSAppearance(named: .vibrantDark)
+
+ let effect = NSVisualEffectView()
+ effect.material = .hudWindow
+ effect.blendingMode = .behindWindow
+ effect.state = .active
+ effect.wantsLayer = true
+ effect.layer?.cornerRadius = 12
+ effect.layer?.masksToBounds = true
+ panel.contentView = effect
+
+ stack.orientation = .vertical
+ stack.alignment = .leading
+ stack.spacing = 0
+ stack.translatesAutoresizingMaskIntoConstraints = false
+ effect.addSubview(stack)
+ NSLayoutConstraint.activate([
+ stack.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: inset),
+ stack.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -inset),
+ stack.topAnchor.constraint(equalTo: effect.topAnchor, constant: inset),
+ ])
+
+ // Remember where the user put it; first time, top right of the screen.
+ if !panel.setFrameUsingName("dingger.panel"), let screen = NSScreen.main {
+ let area = screen.visibleFrame
+ panel.setFrameTopLeftPoint(NSPoint(x: area.maxX - panelWidth - 16, y: area.maxY - 16))
+ }
+ panel.setFrameAutosaveName("dingger.panel")
+ }
+
+ var isVisible: Bool { panel.isVisible }
+
+ func render(_ content: PanelContent) {
+ stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
+ for row in content.waiting {
+ let view = PanelRowView(row: row, onClick: onClick)
+ stack.addArrangedSubview(view)
+ view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+ }
+ if content.overflow > 0 {
+ stack.addArrangedSubview(caption(NSAttributedString(
+ string: "+\(content.overflow) more waiting, see the menu bar",
+ attributes: [.font: NSFont.systemFont(ofSize: 11),
+ .foregroundColor: NSColor.secondaryLabelColor])))
+ }
+ stack.addArrangedSubview(caption(content.summary))
+
+ // Grow or shrink downward from the top edge.
+ stack.layoutSubtreeIfNeeded()
+ let height = stack.fittingSize.height + inset * 2
+ let top = panel.frame.maxY
+ panel.setFrame(NSRect(x: panel.frame.minX, y: top - height, width: panelWidth, height: height),
+ display: true)
+ }
+
+ private func caption(_ text: NSAttributedString) -> NSView {
+ let label = NSTextField(labelWithAttributedString: text)
+ let box = NSView()
+ label.translatesAutoresizingMaskIntoConstraints = false
+ box.addSubview(label)
+ NSLayoutConstraint.activate([
+ label.leadingAnchor.constraint(equalTo: box.leadingAnchor, constant: 8),
+ label.trailingAnchor.constraint(lessThanOrEqualTo: box.trailingAnchor, constant: -8),
+ label.topAnchor.constraint(equalTo: box.topAnchor, constant: 6),
+ label.bottomAnchor.constraint(equalTo: box.bottomAnchor, constant: -4),
+ ])
+ return box
+ }
+
+ func show() { panel.orderFrontRegardless() }
+ func hide() { panel.orderOut(nil) }
+}