diff options
Diffstat (limited to 'menubar/panel.swift')
| -rw-r--r-- | menubar/panel.swift | 314 |
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) } +} |
