// 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. // - On or off (the global shortcut and the menu toggle it; off stays off), // and while on: shown always, or only when agents are waiting. // - Layout: compact (waiting agents in full, working and idle as one summary // line, with a count of forgotten drafts that jumps to the first); when // shown always, 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 // MARK: visibility (pure, tested by --selftest) /// What the panel shows while it is on. enum PanelPolicy: String, CaseIterable { case always, whenWaiting var label: String { switch self { case .always: return "Always" case .whenWaiting: return "Only When Agents Are Waiting" } } } /// Two persistent settings and one fact: /// - enabled: the panel is on (the shortcut and the menu toggle it); /// - policy: while on, show it always or only when agents are waiting; /// - waiting: whether any agent is blocked or done. /// Off stays off: nothing shows the panel until it is turned back on. struct PanelVisibility: Equatable { static let peekSeconds: TimeInterval = 3 var enabled = true var policy: PanelPolicy = .whenWaiting var anyWaiting = false /// tmux stopped and its sessions can be restored. var restoreOffered = false /// Turning the panel on with nothing to show shows it briefly, so the /// shortcut visibly did something. var peekUntil: Date? func visible(at now: Date) -> Bool { guard enabled else { return false } if policy == .always || anyWaiting || restoreOffered { return true } return peekUntil.map { now < $0 } ?? false } mutating func toggle(at now: Date) { enabled.toggle() peekUntil = enabled ? now.addingTimeInterval(Self.peekSeconds) : nil } /// Settings from before the on/off split: a three-way mode. static func migrated(fromMode mode: String) -> PanelVisibility { switch mode { case "off": return PanelVisibility(enabled: false, policy: .whenWaiting) case "on": return PanelVisibility(enabled: true, policy: .always) default: return PanelVisibility(enabled: true, policy: .whenWaiting) } } } /// The full list is offered only when the panel shows always; otherwise /// compact. func panelShowsFull(policy: PanelPolicy, prefersFull: Bool) -> Bool { policy == .always && prefersFull } // MARK: content (pure, printed by --print-panel) struct PanelContent { static let maxCompactRows = 8 /// 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 let full: Bool /// Whether the panel offers the compact/full toggle (shown always). let canToggle: Bool /// Agents with a forgotten draft not shown as rows, in list order. let drafts: [Row] /// Sessions to restore, shown first. let offer: RestoreOffer? /// A restore is running. let restoring: Bool init(rows all: [Row], full: Bool = false, canToggle: Bool = false, offer: RestoreOffer? = nil, restoring: Bool = false) { self.offer = offer self.restoring = restoring 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 let shown = Set(rows.map(\.paneId)) drafts = all.filter { $0.forgottenDraft && !shown.contains($0.paneId) } self.full = full self.canToggle = canToggle } 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(separator) } text.append(NSAttributedString( string: "\(status.symbol) ", attributes: [.font: font, .foregroundColor: status.color])) text.append(NSAttributedString( string: "\(count) \(status.rawValue)", attributes: [.font: font, .foregroundColor: mochaSubtext])) } return text } var toggleLabel: String { full ? "Show less ▴" : "Show all ▾" } /// "✎ 2 drafts", when some agents not shown have one. var draftsLabel: String? { drafts.isEmpty ? nil : "✎ \(drafts.count) \(drafts.count == 1 ? "draft" : "drafts")" } /// The offer row's title: what restore will do, or that it runs. var offerTitle: String? { guard let offer else { return nil } return restoring ? "Restoring \(offer.sessions.count) \(offer.sessions.count == 1 ? "session" : "sessions")…" : offer.title } /// The summary line is noise when tmux is gone and only the offer shows. var showsSummary: Bool { offer == nil || working + idle + rows.count > 0 } var plainText: String { var lines: [String] = [] if let offer, let offerTitle { lines.append("↺ \(offerTitle)\t\(offer.detail)" + (restoring ? "" : "\t[Not now]")) } for (index, row) in rows.enumerated() { if index == separatorBefore { lines.append("---") } lines.append(row.panelText) } if overflow > 0 { lines.append("+\(overflow) more waiting") } if showsSummary { lines.append(summary.string + (draftsLabel.map { "\t[\($0)]" } ?? "") + (canToggle ? "\t[\(toggleLabel)]" : "")) } return lines.joined(separator: "\n") } } // MARK: window 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) /// The panel's background: HUD blur under herdr's mocha base, rounded. func darkHUD() -> NSVisualEffectView { let effect = NSVisualEffectView() effect.material = .hudWindow effect.blendingMode = .behindWindow effect.state = .active effect.wantsLayer = true effect.layer?.cornerRadius = 12 effect.layer?.masksToBounds = true let tint = NSView() tint.wantsLayer = true tint.layer?.backgroundColor = panelTint.cgColor tint.translatesAutoresizingMaskIntoConstraints = false effect.addSubview(tint) 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), ]) return effect } /// Whether the pointer moved far enough from where the button went down to /// be a drag: a click on a trackpad or mouse moves it a point or two. func isDrag(from down: NSPoint, to now: NSPoint) -> Bool { hypot(now.x - down.x, now.y - down.y) > 4 } /// A click target that does not swallow drags: a drag moves the panel. class ClickView: NSView { var onClick: () -> Void = {} private var dragged = false private var downAt = NSPoint.zero var hovered = false { didSet { needsDisplay = true; hoverChanged() } } func hoverChanged() {} override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } // Own every click inside, including over labels: a text field would // otherwise take the click (and count as a window-drag background). override func hitTest(_ point: NSPoint) -> NSView? { bounds.contains(convert(point, from: superview)) ? self : nil } // Dragging is done here (performDrag), not by the window server. override var mouseDownCanMoveWindow: Bool { false } 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 downAt = NSEvent.mouseLocation } override func mouseDragged(with event: NSEvent) { if !dragged, isDrag(from: downAt, to: NSEvent.mouseLocation) { 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 } /// A menu item that runs a closure. final class ActionMenuItem: NSMenuItem { private let handler: () -> Void init(_ title: String, handler: @escaping () -> Void) { self.handler = handler super.init(title: title, action: #selector(run), keyEquivalent: "") target = self } required init(coder: NSCoder) { fatalError("not used") } @objc private func run() { handler() } } /// One agent row: colored symbol and name, then a dim line with where it /// runs, and at its right edge today's cost and a context meter, each in a /// fixed column so they line up down the list. Right-click offers "Mark as /// unread" for an idle agent. final class PanelRowView: ClickView { private let selected: Bool private let contextMenu: NSMenu? init(row: Row, title titleText: NSAttributedString? = nil, selected: Bool = false, onClick: @escaping (String) -> Void, onMarkUnread: ((String) -> Void)? = nil) { self.selected = selected if let onMarkUnread, row.canMarkUnread { let menu = NSMenu() menu.addItem(ActionMenuItem("Mark as unread") { onMarkUnread(row.paneId) }) contextMenu = menu } else { contextMenu = nil } super.init(frame: .zero) self.onClick = { onClick(row.paneId) } let title = singleLine(NSTextField( labelWithAttributedString: titleText ?? rowTitle(row, primary: mochaText, secondary: mochaSubtext))) let placeLabel = NSTextField(labelWithString: row.place) placeLabel.font = .systemFont(ofSize: 11) placeLabel.textColor = mochaSubtext let place = singleLine(placeLabel) let cost = NSTextField(labelWithString: row.cost >= 0.005 ? dollars(row.cost) : "") cost.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) cost.textColor = mochaSubtext cost.alignment = .right let meter = ContextMeter(percent: row.context) // The meter at the right edge, the cost before it; the place takes // what is left and truncates. let subtitle = NSView() for view in [place, cost, meter] { view.translatesAutoresizingMaskIntoConstraints = false subtitle.addSubview(view) } NSLayoutConstraint.activate([ meter.trailingAnchor.constraint(equalTo: subtitle.trailingAnchor), meter.centerYAnchor.constraint(equalTo: subtitle.centerYAnchor), meter.widthAnchor.constraint(equalToConstant: ContextMeter.size.width), meter.heightAnchor.constraint(equalToConstant: ContextMeter.size.height), cost.trailingAnchor.constraint(equalTo: meter.leadingAnchor, constant: -6), cost.widthAnchor.constraint(equalToConstant: 52), cost.firstBaselineAnchor.constraint(equalTo: place.firstBaselineAnchor), place.leadingAnchor.constraint(equalTo: subtitle.leadingAnchor), place.trailingAnchor.constraint(lessThanOrEqualTo: cost.leadingAnchor, constant: -6), place.topAnchor.constraint(equalTo: subtitle.topAnchor), place.bottomAnchor.constraint(equalTo: subtitle.bottomAnchor), ]) 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(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(equalTo: stack.widthAnchor), ]) toolTip = row.name.isEmpty ? nil : row.name } required init?(coder: NSCoder) { fatalError("not used") } override func menu(for event: NSEvent) -> NSMenu? { contextMenu } override func draw(_ dirtyRect: NSRect) { guard hovered || selected else { return } NSColor.white.withAlphaComponent(selected ? 0.14 : 0.08).setFill() NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6).fill() } } /// How full an agent's context window is: a small bar, gray, then yellow /// above 70% and red above 90% (as pi's footer); nothing when unknown. final class ContextMeter: NSView { static let size = NSSize(width: 44, height: 5) let percent: Double? init(percent: Double?) { self.percent = percent super.init(frame: NSRect(origin: .zero, size: Self.size)) translatesAutoresizingMaskIntoConstraints = false toolTip = percent.map { "\(contextText($0)) of context" } } required init?(coder: NSCoder) { fatalError("not used") } /// The filled part: at least a dot, at most the whole bar. static func fill(_ percent: Double, width: CGFloat) -> CGFloat { max(size.height, min(width, width * CGFloat(percent) / 100)) } override func draw(_ dirtyRect: NSRect) { guard let percent else { return } let radius = bounds.height / 2 mochaSurface.setFill() NSBezierPath(roundedRect: bounds, xRadius: radius, yRadius: radius).fill() (contextWarning(percent) ?? mochaOverlay.blended(withFraction: 0.35, of: mochaSubtext) ?? mochaOverlay).setFill() var filled = bounds filled.size.width = Self.fill(percent, width: bounds.width) NSBezierPath(roundedRect: filled, xRadius: radius, yRadius: radius).fill() } } /// The restore offer: "↺ Restore 9 sessions · 22 agents", what it was. private final class OfferView: ClickView { init(title: String, detail: String, onClick: @escaping () -> Void) { super.init(frame: .zero) self.onClick = onClick let font = NSFont.menuFont(ofSize: 0) let text = NSMutableAttributedString(string: "↺ ", attributes: [ .font: NSFont.monospacedSystemFont(ofSize: font.pointSize, weight: .bold), .foregroundColor: mochaBlue, ]) text.append(NSAttributedString(string: title, attributes: [.font: font, .foregroundColor: mochaText])) let titleLabel = singleLine(NSTextField(labelWithAttributedString: text)) let detailLabel = NSTextField(labelWithString: detail) detailLabel.font = .systemFont(ofSize: 11) detailLabel.textColor = mochaSubtext let stack = NSStackView(views: [titleLabel, singleLine(detailLabel)]) 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), ]) } required init?(coder: NSCoder) { fatalError("not used") } override func draw(_ dirtyRect: NSRect) { guard hovered else { return } NSColor.white.withAlphaComponent(0.08).setFill() NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6).fill() } } /// Clickable caption text: "Show all ▾" / "Show less ▴", "Not now", "✎ 2 drafts". private final class ToggleView: ClickView { private let label: NSTextField 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), ]) } 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 onMarkUnread: (String) -> Void private let onToggleFull: () -> Void private let onRestore: () -> Void private let onDismissRestore: () -> Void private var rendered: String? init(persistFrame: Bool = true, onClick: @escaping (String) -> Void, onMarkUnread: @escaping (String) -> Void = { _ in }, onToggleFull: @escaping () -> Void, onRestore: @escaping () -> Void = {}, onDismissRestore: @escaping () -> Void = {}) { self.onClick = onClick self.onMarkUnread = onMarkUnread self.onToggleFull = onToggleFull self.onRestore = onRestore self.onDismissRestore = onDismissRestore 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: .darkAqua) let effect = darkHUD() 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 !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)) } if persistFrame { panel.setFrameAutosaveName("dingger.panel") } } var isVisible: Bool { panel.isVisible } /// 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() } if let offer = content.offer, let title = content.offerTitle { add(offerLine(title: title, detail: offer.detail, restoring: content.restoring), fullWidth: true) if content.showsSummary || !content.rows.isEmpty { add(separatorLine()) } } for (index, row) in content.rows.enumerated() { if index == content.separatorBefore { add(separatorLine()) } add(PanelRowView(row: row, onClick: onClick, onMarkUnread: onMarkUnread), fullWidth: true) } if content.overflow > 0 { add(caption(NSAttributedString( string: "+\(content.overflow) more waiting, see the menu bar", attributes: [.font: NSFont.systemFont(ofSize: 11), .foregroundColor: mochaSubtext]))) } if content.showsSummary { add(summaryLine(content), fullWidth: true) } // 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) 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 { 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 } /// The offer row, with "Not now" beside it (a row takes every click /// inside it, so the link cannot be inside). private func offerLine(title: String, detail: String, restoring: Bool) -> NSView { let box = NSView() let row = OfferView(title: title, detail: detail, onClick: restoring ? {} : onRestore) row.translatesAutoresizingMaskIntoConstraints = false box.addSubview(row) var constraints = [ row.leadingAnchor.constraint(equalTo: box.leadingAnchor), row.topAnchor.constraint(equalTo: box.topAnchor), row.bottomAnchor.constraint(equalTo: box.bottomAnchor), ] if restoring { constraints.append(row.trailingAnchor.constraint(equalTo: box.trailingAnchor)) } else { let dismiss = ToggleView(text: "Not now", onClick: onDismissRestore) dismiss.translatesAutoresizingMaskIntoConstraints = false box.addSubview(dismiss) constraints += [ dismiss.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -8), dismiss.centerYAnchor.constraint(equalTo: box.centerYAnchor), row.trailingAnchor.constraint(equalTo: dismiss.leadingAnchor, constant: -8), ] } NSLayoutConstraint.activate(constraints) return box } /// Summary on the left; on the right, the forgotten drafts (a click /// jumps to the first) and the compact/full toggle. private func summaryLine(_ content: PanelContent) -> NSView { let box = caption(content.summary) var trailing = box.trailingAnchor var gap: CGFloat = -8 var constraints: [NSLayoutConstraint] = [] let onClick = self.onClick let draftsView = zip(content.draftsLabel.map { [$0] } ?? [], content.drafts.prefix(1)).map { label, first in ToggleView(text: label, onClick: { onClick(first.paneId) }) } let toggleView = content.canToggle ? [ToggleView(text: content.toggleLabel, onClick: onToggleFull)] : [] for view in (toggleView + draftsView) { view.translatesAutoresizingMaskIntoConstraints = false box.addSubview(view) constraints += [ view.trailingAnchor.constraint(equalTo: trailing, constant: gap), view.centerYAnchor.constraint(equalTo: box.centerYAnchor, constant: 1), ] trailing = view.leadingAnchor gap = -14 } NSLayoutConstraint.activate(constraints) return box } func show() { panel.orderFrontRegardless() } func hide() { panel.orderOut(nil) } func snapshot(to path: String) -> Bool { guard let view = panel.contentView else { return false } return snapshotView(view, to: path) } } /// Debug: draw a dark HUD view offscreen over a white and a black backdrop, /// side by side, as a PNG. func snapshotView(_ view: NSView, to path: String) -> Bool { 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) } } let t0 = Date(timeIntervalSince1970: 1_000) func at(_ seconds: TimeInterval) -> Date { t0.addingTimeInterval(seconds) } // Right-click: "Mark as unread" for idle agents only. func agent(_ status: Status) -> Row { Row(paneId: "%7", agent: "pi", name: "n", status: status, session: "s", window: "w", since: 1, path: "") } if let click = NSEvent.mouseEvent(with: .rightMouseDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, eventNumber: 0, clickCount: 1, pressure: 1) { var marked: [String] = [] let idleMenu = PanelRowView(row: agent(.idle), onClick: { _ in }, onMarkUnread: { marked.append($0) }).menu(for: click) check(idleMenu?.items.map(\.title) == ["Mark as unread"], "right-click: an idle agent offers Mark as unread") if let item = idleMenu?.items.first, let target = item.target, let action = item.action { _ = target.perform(action, with: item) } check(marked == ["%7"], "right-click: Mark as unread marks that agent") for status in [Status.done, .working, .blocked] { check(PanelRowView(row: agent(status), onClick: { _ in }, onMarkUnread: { _ in }).menu(for: click) == nil, "right-click: nothing to offer for a \(status.rawValue) agent") } } else { check(false, "right-click: could not make an event") } var panel = PanelVisibility() check(panel.enabled && panel.policy == .whenWaiting, "defaults: on, only when agents are waiting") check(!panel.visible(at: t0), "waiting policy: hidden with nothing waiting") panel.anyWaiting = true check(panel.visible(at: t0), "waiting policy: shown when an agent waits") panel.toggle(at: t0) check(!panel.visible(at: t0), "off: the shortcut hides it") panel.anyWaiting = false panel.anyWaiting = true check(!panel.visible(at: at(60)), "off: stays hidden when another agent starts waiting") panel.toggle(at: at(60)) check(panel.visible(at: at(60)), "on again: shown, an agent is waiting") panel.anyWaiting = false check(!panel.visible(at: at(70)), "waiting policy: hides when nothing waits") var peek = PanelVisibility(enabled: false, policy: .whenWaiting) peek.toggle(at: t0) check(peek.visible(at: at(1)), "turned on with nothing waiting: shown briefly") check(!peek.visible(at: at(PanelVisibility.peekSeconds + 0.1)), "the brief show ends after the peek") peek.toggle(at: at(10)) check(!peek.visible(at: at(10)) && peek.peekUntil == nil, "turned off: no peek") var always = PanelVisibility(enabled: true, policy: .always) check(always.visible(at: t0), "always: shown with nothing waiting") always.toggle(at: t0) always.anyWaiting = true check(!always.visible(at: at(5)), "always, off: an agent waiting does not show it") always.policy = .whenWaiting check(!always.enabled, "changing what it shows keeps it off") check(PanelVisibility.migrated(fromMode: "off") == PanelVisibility(enabled: false, policy: .whenWaiting), "migration: off") check(PanelVisibility.migrated(fromMode: "on") == PanelVisibility(enabled: true, policy: .always), "migration: always") check(PanelVisibility.migrated(fromMode: "auto") == PanelVisibility(enabled: true, policy: .whenWaiting), "migration: when waiting") var offered = PanelVisibility(enabled: true, policy: .whenWaiting) offered.restoreOffered = true check(offered.visible(at: t0), "a restore offer shows the panel") offered.toggle(at: t0) check(!offered.visible(at: at(10)), "off: a restore offer does not show it") check(!panelShowsFull(policy: .whenWaiting, prefersFull: true), "layout: compact when only waiting") check(panelShowsFull(policy: .always, prefersFull: true), "layout: full in always when chosen") check(!panelShowsFull(policy: .always, 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") // Context use. var filling = row("f", .idle) filling.cost = 3.12 filling.context = 78.4 check(filling.subtitle == "s · f · pi · $3.12 · 78%", "context: the menu's text, after the cost") check(filling.place == "s · f · pi" && filling.panelText.hasSuffix("\ts · f · pi\t$3.12 78%"), "context: the panel's columns") check(contextWarning(70) == nil && contextWarning(70.5) == mochaYellow && contextWarning(90.5) == mochaRed, "context: pi's colors, above 70% and 90%") check(ContextMeter.fill(0, width: 44) == 5 && ContextMeter.fill(50, width: 44) == 22 && ContextMeter.fill(150, width: 44) == 44, "context: the bar's fill") filling.context = nil filling.cost = 0 check(filling.subtitle == "s · f · pi" && !filling.panelText.contains("%"), "context: unknown, nothing") // Spend. check(dollars(0.42) == "$0.42" && dollars(19.849) == "$19.85" && dollars(99.994) == "$99.99" && dollars(99.996) == "$100" && dollars(1967.14) == "$1,967", "spend: cents below $100, then whole dollars") var spender = row("s", .done) spender.cost = 19.85 check(spender.subtitle.hasPrefix("s · s · pi · done ") && spender.subtitle.hasSuffix(" · $19.85"), "spend: today's in the menu's subtitle, after the wait") spender.cost = 0.004 check(!spender.subtitle.contains("$"), "spend: nothing spent, nothing shown") check(Spend(today: 8.86, week: 456.87, month: 8.86).line == "$8.86 today · $457 this week · $8.86 this month" && Spend(today: 0, week: 0, month: 0).isEmpty, "spend: the menu's line") // Forgotten drafts: a draft the user is not looking at. var drafty = [row("d", .done), row("w", .working), row("i1", .idle), row("i2", .idle), row("i3", .idle)] drafty[0].draft = "x"; drafty[1].draft = "x"; drafty[3].draft = "x"; drafty[4].draft = "x" drafty[4].attended = true check(drafty.map(\.forgottenDraft) == [true, true, false, true, false], "draft: forgotten unless looked at") check(drafty.sorted(by: listedBefore).map(\.paneId) == ["d", "w", "i2", "i1", "i3"], "draft: an idle agent with one first among idle agents only") check(rowTitlePrefix(drafty[3]) == "○ ✎ " && rowTitle(drafty[3]).string == "○ ✎ i2" && rowTitle(drafty[2]).string == "○ i1", "draft: ✎ before the name") let draftPanel = PanelContent(rows: drafty.sorted(by: listedBefore), canToggle: true) check(draftPanel.drafts.map(\.paneId) == ["w", "i2"] && draftPanel.draftsLabel == "✎ 2 drafts", "draft: compact counts those not shown, in list order") check(draftPanel.plainText.hasSuffix("○ 3 idle\t[✎ 2 drafts]\t[Show all ▾]"), "draft: the count before the toggle") check(PanelContent(rows: drafty, full: true).draftsLabel == nil, "draft: none counted when every row shows") check(PanelContent(rows: [drafty[3]]).draftsLabel == "✎ 1 draft", "draft: one") let offer = RestoreOffer(saved_at: 0, sessions: [ .init(name: "a", windows: 2, panes: 3, agents: 2), .init(name: "b", windows: 1, panes: 1, agents: 0), ], agents: 2, interrupted: 1) let afterReboot = PanelContent(rows: [], offer: offer) check(afterReboot.plainText.hasPrefix("↺ Restore 2 sessions · 2 agents\t") && afterReboot.plainText.contains("1 was mid-turn") && afterReboot.plainText.hasSuffix("[Not now]"), "offer: first, with what it brings back and a way to dismiss it") check(!afterReboot.plainText.contains("No agents"), "offer: no empty summary when tmux is gone") let running = PanelContent(rows: [], offer: offer, restoring: true) check(running.plainText.hasPrefix("↺ Restoring 2 sessions…") && !running.plainText.contains("Not now"), "offer: while restoring, no dismiss") check(PanelContent(rows: many, offer: offer).plainText.contains("○ 1 idle"), "offer: the summary stays when there are agents") return failures }