aboutsummaryrefslogtreecommitdiff
path: root/menubar
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-11-05 19:45:08 +0200
committerIgor Soarez <igor@soarez.org>2026-11-05 19:45:08 +0200
commit9575ead9c637a2dc482c00f417a1d784d46e076a (patch)
treea47abc5722d4f978e7c94428e06634e88101152a /menubar
parent5842b093cc502b46076423e4f8efdbbc568f86aa (diff)
name pi agents by their reported session name, not the terminal title; scroll a panel list taller than the screen
Diffstat (limited to 'menubar')
-rw-r--r--menubar/panel.swift98
1 files changed, 84 insertions, 14 deletions
diff --git a/menubar/panel.swift b/menubar/panel.swift
index 174d30e..35494e0 100644
--- a/menubar/panel.swift
+++ b/menubar/panel.swift
@@ -472,9 +472,23 @@ private final class ToggleView: ClickView {
override func hoverChanged() { label.textColor = hovered ? mochaText : mochaSubtext }
}
+private extension Optional where Wrapped == CGFloat {
+ var orZero: CGFloat { self ?? 0 }
+}
+
+/// A document view whose content starts at the top.
+private final class FlippedView: NSView {
+ override var isFlipped: Bool { true }
+}
+
final class PanelController {
private let panel: NSPanel
+ /// The list (offer, rows, overflow), which scrolls once the panel would
+ /// reach below the screen; the summary line stays under it.
private let stack = NSStackView()
+ private let scroll = NSScrollView()
+ private let summary = NSStackView()
+ private var scrollHeight: NSLayoutConstraint!
private let onClick: (String) -> Void
private let onMarkUnread: (String) -> Void
private let onToggleFull: () -> Void
@@ -506,15 +520,39 @@ final class PanelController {
let effect = darkHUD()
panel.contentView = effect
- stack.orientation = .vertical
- stack.alignment = .leading
- stack.spacing = 0
- stack.translatesAutoresizingMaskIntoConstraints = false
- effect.addSubview(stack)
+ for column in [stack, summary] {
+ column.orientation = .vertical
+ column.alignment = .leading
+ column.spacing = 0
+ column.translatesAutoresizingMaskIntoConstraints = false
+ }
+ let document = FlippedView()
+ document.translatesAutoresizingMaskIntoConstraints = false
+ document.addSubview(stack)
+ scroll.documentView = document
+ scroll.drawsBackground = false
+ scroll.hasVerticalScroller = true
+ scroll.autohidesScrollers = true
+ scroll.scrollerStyle = .overlay
+ scroll.translatesAutoresizingMaskIntoConstraints = false
+ effect.addSubview(scroll)
+ effect.addSubview(summary)
+ scrollHeight = scroll.heightAnchor.constraint(equalToConstant: 0)
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),
+ scroll.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: inset),
+ scroll.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -inset),
+ scroll.topAnchor.constraint(equalTo: effect.topAnchor, constant: inset),
+ scrollHeight,
+ document.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
+ document.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
+ document.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
+ stack.leadingAnchor.constraint(equalTo: document.leadingAnchor),
+ stack.trailingAnchor.constraint(equalTo: document.trailingAnchor),
+ stack.topAnchor.constraint(equalTo: document.topAnchor),
+ stack.bottomAnchor.constraint(equalTo: document.bottomAnchor),
+ summary.leadingAnchor.constraint(equalTo: scroll.leadingAnchor),
+ summary.trailingAnchor.constraint(equalTo: scroll.trailingAnchor),
+ summary.topAnchor.constraint(equalTo: scroll.bottomAnchor),
])
// Remember where the user put it; first time, top right of the screen.
@@ -535,9 +573,11 @@ final class PanelController {
guard text != rendered else { return false }
rendered = text
- stack.arrangedSubviews.forEach { view in
- stack.removeArrangedSubview(view)
- view.removeFromSuperview()
+ for column in [stack, summary] {
+ column.arrangedSubviews.forEach { view in
+ column.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)
@@ -552,17 +592,34 @@ final class PanelController {
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) }
+ if content.showsSummary {
+ let line = summaryLine(content)
+ summary.addArrangedSubview(line)
+ line.widthAnchor.constraint(equalTo: summary.widthAnchor).isActive = true
+ }
- // Grow or shrink downward from the top edge.
+ // Grow or shrink downward from the top edge, down to the bottom of
+ // the screen; past that the list scrolls.
stack.layoutSubtreeIfNeeded()
- let height = stack.fittingSize.height + inset * 2
+ summary.layoutSubtreeIfNeeded()
+ let list = stack.arrangedSubviews.isEmpty ? 0 : stack.fittingSize.height
+ let below = summary.arrangedSubviews.isEmpty ? 0 : summary.fittingSize.height
let top = panel.frame.maxY
+ let room = top - (panel.screen ?? NSScreen.main).map { $0.visibleFrame.minY + 16 }.orZero
+ // At least a few rows, wherever the panel was dragged.
+ let shown = max(min(list, 150), min(list, room - below - inset * 2))
+ scrollHeight.constant = shown
+ let height = shown + below + inset * 2
panel.setFrame(NSRect(x: panel.frame.minX, y: top - height, width: panelWidth, height: height),
display: true)
+ listScrolls = shown < list
return true
}
+ /// Whether the list is taller than the panel shows (tests).
+ private(set) var listScrolls = false
+ var frame: NSRect { panel.frame }
+
private func add(_ view: NSView, fullWidth: Bool = false) {
stack.addArrangedSubview(view)
if fullWidth { view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true }
@@ -785,6 +842,19 @@ func panelSelftest() -> [String] {
check(full.plainText.hasSuffix("[Show less ▴]") && !compact.plainText.contains("Show"),
"toggle offered only when allowed, labelled by layout")
+ // A long list scrolls instead of running off the screen.
+ _ = NSApplication.shared
+ if let area = NSScreen.main?.visibleFrame {
+ let panel = PanelController(persistFrame: false, onClick: { _ in }, onToggleFull: {})
+ panel.render(PanelContent(rows: (0..<80).map { row("long\($0)", .idle) }, full: true, canToggle: true))
+ check(panel.frame.minY >= area.minY && panel.frame.maxY <= area.maxY && panel.listScrolls,
+ "panel: 80 rows stay on screen and scroll (\(panel.frame) in \(area))")
+ panel.render(PanelContent(rows: (0..<3).map { row("short\($0)", .idle) }, full: true, canToggle: true))
+ check(!panel.listScrolls && panel.frame.height < 300, "panel: 3 rows shown whole (\(panel.frame))")
+ } else {
+ check(false, "panel: no screen to measure against")
+ }
+
// A failed run.
var broken = row("b", .done)
broken.error = "401: invalid x-api-key"