diff options
| -rw-r--r-- | README.md | 16 | ||||
| -rw-r--r-- | menubar/hotkey.swift | 70 | ||||
| -rw-r--r-- | menubar/main.swift | 108 | ||||
| -rw-r--r-- | menubar/model.swift | 16 | ||||
| -rw-r--r-- | menubar/panel.swift | 435 | ||||
| -rwxr-xr-x | tests/e2e.sh | 7 |
6 files changed, 479 insertions, 173 deletions
@@ -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 |
