aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md16
-rw-r--r--menubar/hotkey.swift70
-rw-r--r--menubar/main.swift108
-rw-r--r--menubar/model.swift16
-rw-r--r--menubar/panel.swift435
-rwxr-xr-xtests/e2e.sh7
6 files changed, 479 insertions, 173 deletions
diff --git a/README.md b/README.md
index c2a2a71..126dae3 100644
--- a/README.md
+++ b/README.md
@@ -44,10 +44,14 @@ than a poll is still a completion.
(`◐ 2 working · ○ 15 idle`); click a row to jump there, drag to move it.
**⌃⌥⌘E** shows or hides it from anywhere. Modes (menu bar, "Show Panel
Automatically"): *When agents are waiting* (default; appears when
- something is blocked or done, hides when you have attended to everything),
- *Always*, *Off*. Hiding it with the shortcut lasts until another agent
- starts waiting. Change the shortcut in `~/.config/dingger/config.toml`
- (menu bar, Settings…); it reloads when you save.
+ something is blocked or done, hides when you have attended to everything;
+ compact), *Always* (with a "Show all / Show less" toggle for the full
+ list), *Off*. Hiding it with the shortcut lasts until another agent starts
+ waiting. The panel is always dark, in herdr's palette, so it reads the same
+ over any desktop.
+- **Jump shortcut**: **⌃⌥⌘U** jumps to the top waiting agent from anywhere,
+ like clicking the tmux status line. Both shortcuts live in
+ `~/.config/dingger/config.toml` (menu bar, Settings…), reloaded on save.
- **tmux status line** (`packaging/tmux/dingger.tmux`): the same summary after
the clock. Clicking it jumps to the top waiting agent (the menu's top row),
skipping the window you already look at.
@@ -104,8 +108,8 @@ tests/e2e.sh # end to end against an isolated tmux server
30 s, so everything asserted must arrive through events), fake agents driven
over the hook protocol, and a real tmux client whose focus it controls. It
asserts every status transition, attendance by navigation, both click paths
-(including the real menu bar binary), the floating panel's visibility rules
-and content, the status-line segment, latency with a
+(including the real menu bar binary), the floating panel's visibility and
+layout rules and content (compact, full, toggle), the status-line segment, latency with a
deliberately slow notifier, and state surviving a daemon restart. It never
touches your tmux server.
diff --git a/menubar/hotkey.swift b/menubar/hotkey.swift
index e6a5aa7..c5ab178 100644
--- a/menubar/hotkey.swift
+++ b/menubar/hotkey.swift
@@ -11,6 +11,7 @@ import Carbon.HIToolbox
struct Settings: Equatable {
var hotkey = "ctrl+opt+cmd+e"
+ var jumpHotkey = "ctrl+opt+cmd+u"
static var directory: String {
environment["DINGGER_CONFIG_DIR"] ?? (NSHomeDirectory() + "/.config/dingger")
@@ -19,6 +20,20 @@ struct Settings: Equatable {
static let template = """
# dingger settings. The menu bar app reloads this file when you save it.
+ # Shortcuts: modifiers ctrl, opt, cmd, shift; keys a-z, 0-9, f1-f12, space.
+
+ [shortcuts]
+ # Show or hide the floating panel.
+ hotkey = "ctrl+opt+cmd+e"
+ # Jump to the agent at the top of the waiting list (blocked first, then
+ # the longest done), like clicking the tmux status line.
+ jump_hotkey = "ctrl+opt+cmd+u"
+
+ """
+
+ /// The first template, replaced on upgrade if the user never edited it.
+ private static let firstTemplate = """
+ # dingger settings. The menu bar app reloads this file when you save it.
[panel]
# Global shortcut that shows or hides the floating panel.
@@ -27,9 +42,11 @@ struct Settings: Equatable {
"""
- /// Create the file with defaults and comments if it does not exist yet.
+ /// Create the file with defaults and comments if it does not exist yet;
+ /// upgrade it if it is still an unedited older template.
static func ensureFile() {
- guard !FileManager.default.fileExists(atPath: path) else { return }
+ let existing = try? String(contentsOfFile: path, encoding: .utf8)
+ guard existing == nil || existing == firstTemplate else { return }
try? FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
try? template.write(toFile: path, atomically: true, encoding: .utf8)
}
@@ -44,7 +61,11 @@ struct Settings: Equatable {
}
guard parts.count == 2, !parts[0].hasPrefix("#") else { continue }
let value = parts[1].trimmingCharacters(in: CharacterSet(charactersIn: "\""))
- if parts[0] == "hotkey" { settings.hotkey = value }
+ switch parts[0] {
+ case "hotkey": settings.hotkey = value
+ case "jump_hotkey": settings.jumpHotkey = value
+ default: break
+ }
}
return settings
}
@@ -113,34 +134,47 @@ struct Hotkey: Equatable {
// MARK: registration
-/// One process-wide global shortcut.
+/// Process-wide global shortcuts, each with its own id.
enum GlobalHotkey {
- private static var reference: EventHotKeyRef?
- private static var action: (() -> Void)?
+ enum Id: UInt32 {
+ case panel = 1
+ case jump = 2
+ }
+
+ private static var references: [UInt32: EventHotKeyRef] = [:]
+ private static var actions: [UInt32: () -> Void] = [:]
private static var handlerInstalled = false
- /// Register `hotkey`, replacing any previous one. Returns false if another
- /// app already owns the combination.
- static func register(_ hotkey: Hotkey, action newAction: @escaping () -> Void) -> Bool {
- unregister()
- action = newAction
+ /// Register `hotkey` under `id`, replacing what was there. Returns false
+ /// if another app already owns the combination.
+ static func register(_ id: Id, _ hotkey: Hotkey, action: @escaping () -> Void) -> Bool {
+ unregister(id)
+ actions[id.rawValue] = action
if !handlerInstalled {
var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard),
eventKind: UInt32(kEventHotKeyPressed))
- InstallEventHandler(GetApplicationEventTarget(), { _, _, _ in
- DispatchQueue.main.async { GlobalHotkey.action?() }
+ InstallEventHandler(GetApplicationEventTarget(), { _, event, _ in
+ var pressed = EventHotKeyID()
+ GetEventParameter(event, EventParamName(kEventParamDirectObject),
+ EventParamType(typeEventHotKeyID), nil,
+ MemoryLayout<EventHotKeyID>.size, nil, &pressed)
+ let id = pressed.id
+ DispatchQueue.main.async { GlobalHotkey.actions[id]?() }
return noErr
}, 1, &spec, nil, nil)
handlerInstalled = true
}
- let id = EventHotKeyID(signature: OSType(0x4447_4752), id: 1) // "DGGR"
- let status = RegisterEventHotKey(hotkey.keyCode, hotkey.carbonModifiers, id,
+ var reference: EventHotKeyRef?
+ let status = RegisterEventHotKey(hotkey.keyCode, hotkey.carbonModifiers,
+ EventHotKeyID(signature: OSType(0x4447_4752), id: id.rawValue),
GetApplicationEventTarget(), 0, &reference)
+ if status == noErr, let reference { references[id.rawValue] = reference }
return status == noErr
}
- static func unregister() {
- if let reference { UnregisterEventHotKey(reference) }
- reference = nil
+ static func unregister(_ id: Id) {
+ if let reference = references.removeValue(forKey: id.rawValue) {
+ UnregisterEventHotKey(reference)
+ }
}
}
diff --git a/menubar/main.swift b/menubar/main.swift
index e7532f6..e9294e8 100644
--- a/menubar/main.swift
+++ b/menubar/main.swift
@@ -14,7 +14,11 @@
// Debug flags (used by tests/e2e.sh):
// --print print the title and rows the menu would show, then exit
// (rows: "<symbol> <status>\t<title>\t<subtitle>")
-// --print-panel print what the floating panel would show, then exit
+// --print-panel [--full] [--toggle]
+// print what the floating panel would show, then exit
+// --snapshot-panel <png> [--full]
+// render the panel offscreen (after a different render, to
+// catch stale content) over white and black, then exit
// --click <pane> perform a row click for <pane>, then exit
// --selftest check the panel visibility rules, then exit
@@ -34,12 +38,17 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var settingsModified: Date?
private var hotkey: Hotkey?
private var hotkeyRegistered = false
+ private var jumpHotkey: Hotkey?
+ private var jumpHotkeyRegistered = false
+ private var prefersFull = UserDefaults.standard.bool(forKey: "panelFull")
private var rows: [Row] = []
func applicationDidFinishLaunching(_: Notification) {
menu.delegate = self
item.menu = menu
- panel = PanelController { [weak self] paneId in self?.jump(to: paneId) }
+ panel = PanelController(
+ onClick: { [weak self] paneId in self?.jump(to: paneId) },
+ onToggleFull: { [weak self] in self?.toggleFull() })
Settings.ensureFile()
reloadSettings()
refresh()
@@ -52,16 +61,24 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
private func reloadSettings() {
settingsModified = Settings.modified()
settings = Settings.load()
- guard let parsed = Hotkey.parse(settings.hotkey) else {
- GlobalHotkey.unregister()
- hotkey = nil
- hotkeyRegistered = false
- log("settings: invalid hotkey \"\(settings.hotkey)\"")
- return
+ (hotkey, hotkeyRegistered) = registerShortcut(.panel, settings.hotkey, "panel") { [weak self] in
+ self?.togglePanel()
+ }
+ (jumpHotkey, jumpHotkeyRegistered) = registerShortcut(.jump, settings.jumpHotkey, "jump") {
+ [weak self] in self?.jumpToTopWaiting()
}
- hotkey = parsed
- hotkeyRegistered = GlobalHotkey.register(parsed) { [weak self] in self?.togglePanel() }
- log("settings: hotkey \(parsed.display) \(hotkeyRegistered ? "registered" : "in use by another app")")
+ }
+
+ private func registerShortcut(_ id: GlobalHotkey.Id, _ spec: String, _ name: String,
+ action: @escaping () -> Void) -> (Hotkey?, Bool) {
+ guard let parsed = Hotkey.parse(spec) else {
+ GlobalHotkey.unregister(id)
+ log("settings: invalid \(name) shortcut \"\(spec)\"")
+ return (nil, false)
+ }
+ let registered = GlobalHotkey.register(id, parsed, action: action)
+ log("settings: \(name) shortcut \(parsed.display) \(registered ? "registered" : "in use by another app")")
+ return (parsed, registered)
}
// MARK: refresh
@@ -83,9 +100,18 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
applyPanel()
}
+ private var panelContent: PanelContent {
+ PanelContent(rows: rows,
+ full: panelShowsFull(mode: visibility.mode, prefersFull: prefersFull),
+ canToggle: visibility.mode == .on)
+ }
+
private func applyPanel() {
if visibility.visible {
- panel.render(PanelContent(rows: rows))
+ if panel.render(panelContent) {
+ let content = panelContent
+ log("panel: render \(content.rows.filter(\.waiting).count) waiting, \(content.full ? "full" : "compact")")
+ }
if !panel.isVisible {
panel.show()
log("panel: shown (\(visibility.mode.rawValue), forced: \(String(describing: visibility.forced)))")
@@ -101,6 +127,21 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
applyPanel()
}
+ private func toggleFull() {
+ prefersFull.toggle()
+ UserDefaults.standard.set(prefersFull, forKey: "panelFull")
+ applyPanel()
+ }
+
+ /// The jump shortcut: same as clicking the tmux status line.
+ @objc private func jumpToTopWaiting() {
+ DispatchQueue.global().async {
+ let output = run(dinggerBinary, ["focus"]) ?? "failed"
+ log("jump: dingger focus \(output.isEmpty ? "ok" : output)")
+ DispatchQueue.main.async { self.refresh() }
+ }
+ }
+
@objc private func selectMode(_ sender: NSMenuItem) {
guard let raw = sender.representedObject as? String, let mode = PanelMode(rawValue: raw) else { return }
visibility.setMode(mode)
@@ -154,6 +195,16 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
menu.addItem(toggle)
+ let jumpItem = NSMenuItem(title: "Jump to Waiting Agent", action: #selector(jumpToTopWaiting),
+ keyEquivalent: "")
+ jumpItem.target = self
+ jumpItem.isEnabled = rows.contains(where: \.waiting)
+ if let jumpHotkey, jumpHotkeyRegistered {
+ jumpItem.keyEquivalent = jumpHotkey.key.count == 1 ? jumpHotkey.key : ""
+ jumpItem.keyEquivalentModifierMask = jumpHotkey.modifierFlags
+ }
+ menu.addItem(jumpItem)
+
let modeMenu = NSMenu()
for mode in PanelMode.allCases {
let option = NSMenuItem(title: mode.label, action: #selector(selectMode(_:)), keyEquivalent: "")
@@ -166,16 +217,14 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
modeItem.submenu = modeMenu
menu.addItem(modeItem)
- if let hotkey, !hotkeyRegistered {
- let clash = NSMenuItem(title: "Shortcut \(hotkey.display) is used by another app", action: nil,
- keyEquivalent: "")
- clash.isEnabled = false
- menu.addItem(clash)
- } else if hotkey == nil {
- let invalid = NSMenuItem(title: "Shortcut \"\(settings.hotkey)\" is not valid", action: nil,
- keyEquivalent: "")
- invalid.isEnabled = false
- menu.addItem(invalid)
+ for (shortcut, registered, spec) in [(hotkey, hotkeyRegistered, settings.hotkey),
+ (jumpHotkey, jumpHotkeyRegistered, settings.jumpHotkey)] {
+ guard !registered else { continue }
+ let problem = shortcut.map { "Shortcut \($0.display) is used by another app" }
+ ?? "Shortcut \"\(spec)\" is not valid"
+ let item = NSMenuItem(title: problem, action: nil, keyEquivalent: "")
+ item.isEnabled = false
+ menu.addItem(item)
}
let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: ",")
settingsItem.target = self
@@ -199,6 +248,7 @@ if arguments.contains("--selftest") {
if Hotkey.parse("e") != nil { failures.append("hotkey: a key without modifiers is rejected") }
if Hotkey.parse("ctrl+opt+bogus") != nil { failures.append("hotkey: unknown keys are rejected") }
if Hotkey.parse("cmd+shift+f12")?.display != "⇧⌘F12" { failures.append("hotkey: parse cmd+shift+f12") }
+ if Hotkey.parse(Settings().jumpHotkey)?.display != "⌃⌥⌘U" { failures.append("hotkey: default jump is ⌃⌥⌘U") }
failures.forEach { print("FAIL \($0)") }
print(failures.isEmpty ? "selftest ok" : "selftest: \(failures.count) failed")
exit(failures.isEmpty ? 0 : 1)
@@ -212,9 +262,21 @@ if arguments.contains("--print") {
exit(0)
}
if arguments.contains("--print-panel") {
- print(PanelContent(rows: fetchRows()).plainText)
+ let full = arguments.contains("--full")
+ print(PanelContent(rows: fetchRows(), full: full, canToggle: full || arguments.contains("--toggle")).plainText)
exit(0)
}
+if let index = arguments.firstIndex(of: "--snapshot-panel"), index + 1 < arguments.count {
+ _ = NSApplication.shared
+ let controller = PanelController(persistFrame: false, onClick: { _ in }, onToggleFull: {})
+ // First render something else, so stale views would show in the image.
+ controller.render(PanelContent(rows: [Row(
+ paneId: "%stale", agent: "pi", name: "STALE ROW FROM A PREVIOUS RENDER", status: .blocked,
+ session: "old", window: "old", since: 0)]))
+ let full = arguments.contains("--full")
+ controller.render(PanelContent(rows: fetchRows(), full: full, canToggle: full))
+ exit(controller.snapshot(to: arguments[index + 1]) ? 0 : 1)
+}
if let index = arguments.firstIndex(of: "--click"), index + 1 < arguments.count {
focus(arguments[index + 1])
exit(0)
diff --git a/menubar/model.swift b/menubar/model.swift
index b4a54a0..e60cb84 100644
--- a/menubar/model.swift
+++ b/menubar/model.swift
@@ -19,6 +19,15 @@ let herdrYellow = herdrColor(dark: (249, 226, 175), light: (223, 142, 29))
let herdrGreen = herdrColor(dark: (166, 227, 161), light: (64, 160, 43))
let herdrGray = herdrColor(dark: (108, 112, 134), light: (156, 160, 176))
+/// herdr's mocha text colors, fixed (not appearance-dependent) for the
+/// always-dark floating panel.
+func mocha(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat, _ a: CGFloat = 1) -> NSColor {
+ NSColor(srgbRed: r / 255, green: g / 255, blue: b / 255, alpha: a)
+}
+let mochaText = mocha(205, 214, 244)
+let mochaSubtext = mocha(166, 173, 200)
+let mochaOverlay = mocha(108, 112, 134)
+
// MARK: herdr's status vocabulary
enum Status: String {
@@ -55,7 +64,7 @@ enum Status: String {
}
}
-struct Row {
+struct Row: Equatable {
let paneId: String
let agent: String
/// What the agent works on (its session/task name), if it has one.
@@ -187,13 +196,14 @@ func plainTitle(for rows: [Row]) -> String {
/// Row title: the colored status symbol, then the agent's name. Unnamed
/// agents show their harness, dimmed, so it never competes with real names.
-func rowTitle(_ row: Row) -> NSAttributedString {
+func rowTitle(_ row: Row, primary: NSColor = .labelColor,
+ secondary: NSColor = .secondaryLabelColor) -> NSAttributedString {
let font = NSFont.menuFont(ofSize: 0)
let mono = NSFont.monospacedSystemFont(ofSize: font.pointSize, weight: .medium)
let text = NSMutableAttributedString()
text.append(NSAttributedString(
string: "\(row.status.symbol) ", attributes: [.font: mono, .foregroundColor: row.status.color]))
- let labelColor = row.name.isEmpty ? NSColor.secondaryLabelColor : NSColor.labelColor
+ let labelColor = row.name.isEmpty ? secondary : primary
text.append(NSAttributedString(
string: row.label, attributes: [.font: font, .foregroundColor: labelColor]))
return text
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
}
diff --git a/tests/e2e.sh b/tests/e2e.sh
index 054bd51..50d7fa7 100755
--- a/tests/e2e.sh
+++ b/tests/e2e.sh
@@ -292,6 +292,13 @@ if [ -x "$MENUBAR" ]; then
DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$A2"
expect "A2 idle after the jump" "opt $A2 @agent_state" idle
expect "panel with nothing waiting" "mb --print-panel" "Nothing waiting · ○ 4 idle"
+ expect "full list: every agent, then the summary with the toggle" \
+ "mb --print-panel --full | cut -f1,2 | tr '\t\n' '|;'" \
+ "○ idle|Deploy the widget;○ idle|pi;○ idle|Review PR 42;○ idle|pi;Nothing waiting · ○ 4 idle|[Show less ▴];"
+ expect "compact in Always mode offers 'Show all'" "mb --print-panel --toggle" "Nothing waiting · ○ 4 idle [Show all ▾]"
+ hook "$A1" working; hook "$A1" idle
+ expect "A1 done" "opt $A1 @agent_state" done
+ expect "full list separates waiting from the rest" "mb --print-panel --full | sed -n 2p" "---"
else
fail "menu bar binary not found at $MENUBAR"
fi