diff options
Diffstat (limited to 'menubar/panel.swift')
| -rw-r--r-- | menubar/panel.swift | 435 |
1 files changed, 312 insertions, 123 deletions
diff --git a/menubar/panel.swift b/menubar/panel.swift index ad5ee99..357ff91 100644 --- a/menubar/panel.swift +++ b/menubar/panel.swift @@ -2,10 +2,12 @@ // // - 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. +// - Modes: off, on ("Always"), auto ("When agents are waiting"). The global +// shortcut shows or hides it on top of the mode. +// - Layout: compact (waiting agents in full, working and idle as one summary +// line); in "Always" mode a toggle switches to the full list. +// - Always dark: herdr's mocha palette with fixed colors on a dark tint, so +// text reads the same over any desktop. import AppKit @@ -54,99 +56,79 @@ struct PanelVisibility: Equatable { } } -/// 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 +/// The full list is offered only in "Always" mode; otherwise compact. +func panelShowsFull(mode: PanelMode, prefersFull: Bool) -> Bool { + mode == .on && prefersFull } -// MARK: compact content (pure, printed by --print-panel) +// MARK: content (pure, printed by --print-panel) struct PanelContent { - static let maxRows = 8 + static let maxCompactRows = 8 - let waiting: [Row] + /// Rows shown individually, in herdr's priority order. + let rows: [Row] + /// Index of the first row that is not waiting, when both kinds are shown: + /// a separator goes before it. + let separatorBefore: Int? 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 + let full: Bool + /// Whether the panel offers the compact/full toggle ("Always" mode). + let canToggle: Bool + + init(rows all: [Row], full: Bool = false, canToggle: Bool = false) { + let waiting = all.filter(\.waiting) + if full { + rows = all + overflow = 0 + separatorBefore = (!waiting.isEmpty && waiting.count < all.count) ? waiting.count : nil + } else { + rows = Array(waiting.prefix(Self.maxCompactRows)) + overflow = max(0, waiting.count - Self.maxCompactRows) + separatorBefore = nil + } + working = all.filter { $0.status == .working }.count + idle = all.filter { $0.status == .idle }.count + self.full = full + self.canToggle = canToggle } - /// "◐ 2 working · ○ 15 idle", or "No agents". + private var hasWaiting: Bool { rows.contains(where: \.waiting) } + + /// "◐ 2 working · ○ 15 idle", with "Nothing waiting" first when no + /// agent waits. var summary: NSAttributedString { let font = NSFont.systemFont(ofSize: 11) let text = NSMutableAttributedString() + let separator = NSAttributedString( + string: " · ", attributes: [.font: font, .foregroundColor: mochaOverlay]) + if !hasWaiting { + text.append(NSAttributedString( + string: working + idle > 0 ? "Nothing waiting" : "No agents", + attributes: [.font: font, .foregroundColor: mochaSubtext])) + } 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])) - } + if text.length > 0 { text.append(separator) } 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) + string: "\(count) \(status.rawValue)", attributes: [.font: font, .foregroundColor: mochaSubtext])) } return text } + var toggleLabel: String { full ? "Show less ▴" : "Show all ▾" } + var plainText: String { - var lines = waiting.map { "\($0.status.symbol) \($0.status.rawValue)\t\($0.label)\t\($0.subtitle)" } + var lines: [String] = [] + for (index, row) in rows.enumerated() { + if index == separatorBefore { lines.append("---") } + lines.append("\(row.status.symbol) \(row.status.rawValue)\t\(row.label)\t\(row.subtitle)") + } if overflow > 0 { lines.append("+\(overflow) more waiting") } - lines.append(summary.string) + lines.append(summary.string + (canToggle ? "\t[\(toggleLabel)]" : "")) return lines.joined(separator: "\n") } } @@ -155,24 +137,73 @@ struct PanelContent { private let panelWidth: CGFloat = 440 private let inset: CGFloat = 8 +/// herdr mocha base, laid over the blur so the panel stays dark on light +/// desktops. +private let panelTint = mocha(30, 30, 46, 0.72) -/// 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 +/// A click target that does not swallow drags: a drag moves the panel. +private class ClickView: NSView { + var onClick: () -> Void = {} private var dragged = false - private var hovered = false { didSet { needsDisplay = true } } + var hovered = false { didSet { needsDisplay = true; hoverChanged() } } + + func hoverChanged() {} + + 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 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() } + } +} + +/// One line, cut with "…" at the panel's edge instead of wrapping. +private func singleLine(_ label: NSTextField) -> NSTextField { + label.maximumNumberOfLines = 1 + label.usesSingleLineMode = true + label.cell?.wraps = false + label.cell?.truncatesLastVisibleLine = true + label.lineBreakMode = .byTruncatingTail + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + // An attributed string's own paragraph style wins over lineBreakMode. + let text = NSMutableAttributedString(attributedString: label.attributedStringValue) + if text.length > 0 { + let style = NSMutableParagraphStyle() + style.lineBreakMode = .byTruncatingTail + text.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: text.length)) + label.attributedStringValue = text + } + return label +} + +/// One agent row: colored symbol and name, then a dim subtitle. +private final class PanelRowView: ClickView { 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 + self.onClick = { onClick(row.paneId) } + let title = singleLine(NSTextField( + labelWithAttributedString: rowTitle(row, primary: mochaText, secondary: mochaSubtext))) + let subtitleLabel = NSTextField(labelWithString: row.subtitle) + subtitleLabel.font = .systemFont(ofSize: 11) + subtitleLabel.textColor = mochaSubtext + let subtitle = singleLine(subtitleLabel) let stack = NSStackView(views: [title, subtitle]) stack.orientation = .vertical stack.alignment = .leading @@ -181,54 +212,59 @@ private final class PanelRowView: NSView { addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), - stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8), + stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), stack.topAnchor.constraint(equalTo: topAnchor, constant: 5), stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5), + title.widthAnchor.constraint(lessThanOrEqualTo: stack.widthAnchor), + subtitle.widthAnchor.constraint(lessThanOrEqualTo: stack.widthAnchor), ]) 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 } +/// "Show all ▾" / "Show less ▴". +private final class ToggleView: ClickView { + private let label: NSTextField - override func mouseDragged(with event: NSEvent) { - if !dragged { - dragged = true - window?.performDrag(with: event) - } + init(text: String, onClick: @escaping () -> Void) { + label = NSTextField(labelWithString: text) + super.init(frame: .zero) + self.onClick = onClick + label.font = .systemFont(ofSize: 11) + label.textColor = mochaSubtext + label.translatesAutoresizingMaskIntoConstraints = false + addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor), + label.trailingAnchor.constraint(equalTo: trailingAnchor), + label.topAnchor.constraint(equalTo: topAnchor), + label.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) } - override func mouseUp(with event: NSEvent) { - if !dragged { onClick(paneId) } - } + required init?(coder: NSCoder) { fatalError("not used") } + + override func hoverChanged() { label.textColor = hovered ? mochaText : mochaSubtext } } final class PanelController { private let panel: NSPanel private let stack = NSStackView() private let onClick: (String) -> Void + private let onToggleFull: () -> Void + private var rendered: String? - init(onClick: @escaping (String) -> Void) { + init(persistFrame: Bool = true, onClick: @escaping (String) -> Void, onToggleFull: @escaping () -> Void) { self.onClick = onClick + self.onToggleFull = onToggleFull panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: 60), styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false) @@ -240,7 +276,7 @@ final class PanelController { panel.backgroundColor = .clear panel.isOpaque = false panel.hasShadow = true - panel.appearance = NSAppearance(named: .vibrantDark) + panel.appearance = NSAppearance(named: .darkAqua) let effect = NSVisualEffectView() effect.material = .hudWindow @@ -251,41 +287,59 @@ final class PanelController { effect.layer?.masksToBounds = true panel.contentView = effect + let tint = NSView() + tint.wantsLayer = true + tint.layer?.backgroundColor = panelTint.cgColor + tint.translatesAutoresizingMaskIntoConstraints = false + effect.addSubview(tint) + stack.orientation = .vertical stack.alignment = .leading stack.spacing = 0 stack.translatesAutoresizingMaskIntoConstraints = false effect.addSubview(stack) NSLayoutConstraint.activate([ + tint.leadingAnchor.constraint(equalTo: effect.leadingAnchor), + tint.trailingAnchor.constraint(equalTo: effect.trailingAnchor), + tint.topAnchor.constraint(equalTo: effect.topAnchor), + tint.bottomAnchor.constraint(equalTo: effect.bottomAnchor), 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 { + if !persistFrame || !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") + if persistFrame { 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 + /// Rebuild the panel only when what it shows changes. Returns whether it + /// rebuilt. + @discardableResult + func render(_ content: PanelContent) -> Bool { + let text = content.plainText + guard text != rendered else { return false } + rendered = text + + stack.arrangedSubviews.forEach { view in + stack.removeArrangedSubview(view) + view.removeFromSuperview() + } + for (index, row) in content.rows.enumerated() { + if index == content.separatorBefore { add(separatorLine()) } + add(PanelRowView(row: row, onClick: onClick), fullWidth: true) } if content.overflow > 0 { - stack.addArrangedSubview(caption(NSAttributedString( + add(caption(NSAttributedString( string: "+\(content.overflow) more waiting, see the menu bar", - attributes: [.font: NSFont.systemFont(ofSize: 11), - .foregroundColor: NSColor.secondaryLabelColor]))) + attributes: [.font: NSFont.systemFont(ofSize: 11), .foregroundColor: mochaSubtext]))) } - stack.addArrangedSubview(caption(content.summary)) + add(summaryLine(content), fullWidth: true) // Grow or shrink downward from the top edge. stack.layoutSubtreeIfNeeded() @@ -293,6 +347,30 @@ final class PanelController { let top = panel.frame.maxY panel.setFrame(NSRect(x: panel.frame.minX, y: top - height, width: panelWidth, height: height), display: true) + return true + } + + private func add(_ view: NSView, fullWidth: Bool = false) { + stack.addArrangedSubview(view) + if fullWidth { view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } + } + + private func separatorLine() -> NSView { + let line = NSView() + line.wantsLayer = true + line.layer?.backgroundColor = mochaOverlay.withAlphaComponent(0.4).cgColor + let box = NSView() + line.translatesAutoresizingMaskIntoConstraints = false + box.addSubview(line) + NSLayoutConstraint.activate([ + line.heightAnchor.constraint(equalToConstant: 1), + line.leadingAnchor.constraint(equalTo: box.leadingAnchor, constant: 8), + line.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -8), + line.topAnchor.constraint(equalTo: box.topAnchor, constant: 4), + line.bottomAnchor.constraint(equalTo: box.bottomAnchor, constant: -4), + box.widthAnchor.constraint(equalToConstant: panelWidth - inset * 2), + ]) + return box } private func caption(_ text: NSAttributedString) -> NSView { @@ -309,6 +387,117 @@ final class PanelController { return box } + /// Summary on the left, the compact/full toggle on the right. + private func summaryLine(_ content: PanelContent) -> NSView { + let box = caption(content.summary) + guard content.canToggle else { return box } + let toggle = ToggleView(text: content.toggleLabel, onClick: onToggleFull) + toggle.translatesAutoresizingMaskIntoConstraints = false + box.addSubview(toggle) + NSLayoutConstraint.activate([ + toggle.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -8), + toggle.centerYAnchor.constraint(equalTo: box.centerYAnchor, constant: 1), + ]) + return box + } + func show() { panel.orderFrontRegardless() } func hide() { panel.orderOut(nil) } + + /// Debug: draw the panel offscreen over a white and a black backdrop, + /// side by side, as a PNG. + func snapshot(to path: String) -> Bool { + guard let view = panel.contentView else { return false } + view.layoutSubtreeIfNeeded() + guard let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return false } + view.cacheDisplay(in: view.bounds, to: rep) + let size = view.bounds.size + let image = NSImage(size: NSSize(width: size.width * 2 + 20, height: size.height)) + image.lockFocus() + NSColor.white.setFill() + NSRect(x: 0, y: 0, width: size.width + 10, height: size.height).fill() + NSColor.black.setFill() + NSRect(x: size.width + 10, y: 0, width: size.width + 10, height: size.height).fill() + // the blur does not render offscreen: approximate it with a mid gray + for x in [CGFloat(0), size.width + 20] { + NSColor(white: 0.5, alpha: 0.5).setFill() + NSBezierPath(roundedRect: NSRect(x: x, y: 0, width: size.width, height: size.height), + xRadius: 12, yRadius: 12).fill() + rep.draw(in: NSRect(x: x, y: 0, width: size.width, height: size.height)) + } + image.unlockFocus() + guard let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff), + let png = bitmap.representation(using: .png, properties: [:]) else { return false } + return (try? png.write(to: URL(fileURLWithPath: path))) != nil + } +} + +// MARK: selftest + +/// Run the panel 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") + + check(!panelShowsFull(mode: .auto, prefersFull: true), "layout: compact when agents are waiting") + check(!panelShowsFull(mode: .off, prefersFull: true), "layout: compact when shown by the shortcut in off") + check(panelShowsFull(mode: .on, prefersFull: true), "layout: full in always when chosen") + check(!panelShowsFull(mode: .on, prefersFull: false), "layout: compact in always by default") + + func row(_ id: String, _ status: Status) -> Row { + Row(paneId: id, agent: "pi", name: id, status: status, session: "s", window: id, since: 1) + } + let many = (0..<10).map { row("w\($0)", .done) } + [row("x", .working), row("y", .idle)] + let compact = PanelContent(rows: many) + check(compact.rows.count == 8 && compact.overflow == 2, "compact: first 8 waiting, the rest counted") + check(compact.working == 1 && compact.idle == 1, "compact: working and idle summarized") + let full = PanelContent(rows: many, full: true, canToggle: true) + check(full.rows.count == 12 && full.overflow == 0, "full: every agent listed") + check(full.separatorBefore == 10, "full: separator between waiting and the rest") + check(PanelContent(rows: [row("x", .working)], full: true).separatorBefore == nil, + "full: no separator without waiting agents") + check(full.plainText.hasSuffix("[Show less ▴]") && !compact.plainText.contains("Show"), + "toggle offered only when allowed, labelled by layout") + return failures } |
