menubar/panel.swift (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 |
// 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
}
|