diff options
Diffstat (limited to 'menubar/main.swift')
| -rw-r--r-- | menubar/main.swift | 351 |
1 files changed, 140 insertions, 211 deletions
diff --git a/menubar/main.swift b/menubar/main.swift index 5fb0ab6..e7532f6 100644 --- a/menubar/main.swift +++ b/menubar/main.swift @@ -1,250 +1,130 @@ -// dingger menu bar: herdr's agent panel as a macOS status item. +// dingger menu bar: herdr's agent panel as a macOS status item, plus a +// floating always-on-top panel (panel.swift) toggled by a global shortcut +// (hotkey.swift). Rows and colors come from model.swift. // -// Reads the pane options the dingger daemon writes (@agent, @agent_state, -// @dingger_since) and renders herdr's Symbols style with herdr's palette: +// Reads the pane options the dingger daemon writes and renders herdr's +// Symbols style with herdr's palette: // // blocked × red done ✓ teal working ◐ yellow idle ○ green // -// in herdr's priority order (blocked > done > working > idle), oldest -// waiting first. Clicking a row runs `dingger focus --pane`, the same tested -// code path as the tmux status-line click. +// in herdr's priority order, oldest waiting first. Clicking a row runs +// `dingger focus --pane`, the same tested code path as the tmux status-line +// click. // // 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 // --click <pane> perform a row click for <pane>, then exit +// --selftest check the panel visibility rules, then exit import AppKit -// MARK: herdr's palette (catppuccin mocha on dark, latte on light) - -func herdrColor(dark: (CGFloat, CGFloat, CGFloat), light: (CGFloat, CGFloat, CGFloat)) -> NSColor { - NSColor(name: nil) { appearance in - let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - let (r, g, b) = isDark ? dark : light - return NSColor(srgbRed: r / 255, green: g / 255, blue: b / 255, alpha: 1) - } +func log(_ message: String) { + FileHandle.standardError.write(Data("\(Date()) \(message)\n".utf8)) } -let herdrRed = herdrColor(dark: (243, 139, 168), light: (210, 15, 57)) -let herdrTeal = herdrColor(dark: (148, 226, 213), light: (23, 146, 153)) -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)) - -// MARK: herdr's status vocabulary - -enum Status: String { - case blocked, done, working, idle, unknown +final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { + private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + private let menu = NSMenu() + private var panel: PanelController! + private var visibility = PanelVisibility( + mode: PanelMode(rawValue: UserDefaults.standard.string(forKey: "panelMode") ?? "") ?? .auto) + private var settings = Settings() + private var settingsModified: Date? + private var hotkey: Hotkey? + private var hotkeyRegistered = false + private var rows: [Row] = [] - var symbol: String { - switch self { - case .blocked: return "×" - case .done: return "✓" - case .working: return "◐" - case .idle: return "○" - case .unknown: return "·" - } + func applicationDidFinishLaunching(_: Notification) { + menu.delegate = self + item.menu = menu + panel = PanelController { [weak self] paneId in self?.jump(to: paneId) } + Settings.ensureFile() + reloadSettings() + refresh() + let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in self?.refresh() } + RunLoop.main.add(timer, forMode: .common) } - var color: NSColor { - switch self { - case .blocked: return herdrRed - case .done: return herdrTeal - case .working: return herdrYellow - case .idle: return herdrGreen - case .unknown: return herdrGray - } - } + // MARK: settings and shortcut - var priority: Int { - switch self { - case .blocked: return 4 - case .done: return 3 - case .working: return 2 - case .idle: return 1 - case .unknown: return 0 + 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 = parsed + hotkeyRegistered = GlobalHotkey.register(parsed) { [weak self] in self?.togglePanel() } + log("settings: hotkey \(parsed.display) \(hotkeyRegistered ? "registered" : "in use by another app")") } -} -struct Row { - let paneId: String - let agent: String - /// What the agent works on (its session/task name), if it has one. - let name: String - let status: Status - let session: String - let window: String - let since: Int + // MARK: refresh - var location: String { "\(session) · \(window)" } - var waiting: Bool { status == .blocked || status == .done } + private func refresh() { + if Settings.modified() != settingsModified { reloadSettings() } + rows = fetchRows() + item.button?.attributedTitle = title(for: rows) + var tip: [String] = [] + for status in [Status.blocked, .done, .working, .idle] { + let count = rows.filter { $0.status == status }.count + if count > 0 { tip.append("\(count) \(status.rawValue)") } + } + let tipText = tip.isEmpty ? "No agents" : tip.joined(separator: " · ") + item.button?.toolTip = tipText + item.button?.setAccessibilityLabel("dingger, " + tipText) - /// The row's title text: the agent's name, or its harness when unnamed. - var label: String { - guard !name.isEmpty else { return agent } - return name.count > 80 ? String(name.prefix(79)) + "…" : name + visibility.update(waiting: Set(rows.filter(\.waiting).map(\.paneId))) + applyPanel() } - /// Dim second line: where it runs, which harness, and how long it waits. - var subtitle: String { - var parts = [location, agent] - if waiting, since != Int.max { - parts.append("\(status.rawValue) \(elapsed(sinceMs: since))") + private func applyPanel() { + if visibility.visible { + panel.render(PanelContent(rows: rows)) + if !panel.isVisible { + panel.show() + log("panel: shown (\(visibility.mode.rawValue), forced: \(String(describing: visibility.forced)))") + } + } else if panel.isVisible { + panel.hide() + log("panel: hidden (\(visibility.mode.rawValue), forced: \(String(describing: visibility.forced)))") } - return parts.joined(separator: " · ") - } -} - -/// Compact age: "now", "4m", "2h", "3d". -func elapsed(sinceMs: Int) -> String { - let seconds = max(0, Int(Date().timeIntervalSince1970) - sinceMs / 1000) - switch seconds { - case ..<60: return "now" - case ..<3600: return "\(seconds / 60)m" - case ..<86400: return "\(seconds / 3600)h" - default: return "\(seconds / 86400)d" } -} - -// MARK: tmux and dingger access - -let environment = ProcessInfo.processInfo.environment -func run(_ path: String, _ args: [String]) -> String? { - let process = Process() - process.executableURL = URL(fileURLWithPath: path) - process.arguments = args - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = FileHandle.nullDevice - do { - try process.run() - } catch { - return nil + @objc private func togglePanel() { + visibility.toggle() + applyPanel() } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - guard process.terminationStatus == 0 else { return nil } - return String(data: data, encoding: .utf8) -} -func tmux(_ args: [String]) -> String? { - var all = ["-u"] - if let socket = environment["DINGGER_TMUX_SOCKET"] { - all += ["-L", socket] + @objc private func selectMode(_ sender: NSMenuItem) { + guard let raw = sender.representedObject as? String, let mode = PanelMode(rawValue: raw) else { return } + visibility.setMode(mode) + UserDefaults.standard.set(mode.rawValue, forKey: "panelMode") + applyPanel() } - return run(environment["DINGGER_TMUX"] ?? "/opt/homebrew/bin/tmux", all + args) -} - -let dinggerBinary = environment["DINGGER_BIN"] ?? (NSHomeDirectory() + "/.local/bin/dingger") -/// Every agent pane, in herdr's priority order, oldest waiting first. -func fetchRows() -> [Row] { - let format = [ - "#{pane_id}", "#{@agent}", "#{@agent_state}", "#{@dingger_since}", "#{@agent_name}", - "#{session_name}", "#{window_name}", "#{window_panes}", "#{pane_index}", - ].joined(separator: "\t") - guard let out = tmux(["list-panes", "-a", "-F", format]) else { return [] } - let rows: [Row] = out.split(separator: "\n").compactMap { line in - let f = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) - guard f.count >= 9, !f[1].isEmpty else { return nil } - let status = Status(rawValue: f[2]) ?? .unknown - guard status != .unknown else { return nil } - let window = (Int(f[7]) ?? 1) > 1 ? "\(f[6]).\(f[8])" : f[6] - return Row(paneId: f[0], agent: f[1], name: f[4], status: status, - session: f[5], window: window, since: Int(f[3]) ?? Int.max) + @objc private func openSettings() { + Settings.ensureFile() + _ = run("/usr/bin/open", ["-t", Settings.path]) } - return rows.sorted { - if $0.status.priority != $1.status.priority { return $0.status.priority > $1.status.priority } - if $0.since != $1.since { return $0.since < $1.since } - return $0.location < $1.location - } -} -/// Show the pane and mark it attended; the same code path as the tmux -/// status-line click. -func focus(_ paneId: String) { - _ = run(dinggerBinary, ["focus", "--pane", paneId]) -} - -// MARK: rendering - -/// herdr-style summary: `×1 ✓3 ◐2` in priority order; `○` when every agent -/// is idle; `·` when there are none. -func title(for rows: [Row]) -> NSAttributedString { - let font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium) - let text = NSMutableAttributedString() - for status in [Status.blocked, .done, .working] { - let count = rows.filter { $0.status == status }.count - guard count > 0 else { continue } - if text.length > 0 { - text.append(NSAttributedString(string: " ", attributes: [.font: font])) - } - text.append(NSAttributedString( - string: "\(status.symbol)\(count)", - attributes: [.font: font, .foregroundColor: status.color])) - } - if text.length == 0 { - let status: Status = rows.isEmpty ? .unknown : .idle - text.append(NSAttributedString( - string: status.symbol, attributes: [.font: font, .foregroundColor: status.color])) - } - return text -} - -func plainTitle(for rows: [Row]) -> String { - title(for: rows).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 { - 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 - text.append(NSAttributedString( - string: row.label, attributes: [.font: font, .foregroundColor: labelColor])) - return text -} - -// MARK: app - -final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { - private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - private let menu = NSMenu() - - func applicationDidFinishLaunching(_: Notification) { - menu.delegate = self - item.menu = menu - refreshTitle() - let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in - self?.refreshTitle() + private func jump(to paneId: String) { + DispatchQueue.global().async { + focus(paneId) + DispatchQueue.main.async { self.refresh() } } - RunLoop.main.add(timer, forMode: .common) } - private func refreshTitle() { - let rows = fetchRows() - item.button?.attributedTitle = title(for: rows) - var tip: [String] = [] - for status in [Status.blocked, .done, .working, .idle] { - let count = rows.filter { $0.status == status }.count - if count > 0 { tip.append("\(count) \(status.rawValue)") } - } - let tipText = tip.isEmpty ? "No agents" : tip.joined(separator: " · ") - item.button?.toolTip = tipText - item.button?.setAccessibilityLabel("dingger, " + tipText) - } + // MARK: menu (rebuilt from fresh data every time it opens) - // Rebuilt from fresh data every time the menu opens. func menuNeedsUpdate(_ menu: NSMenu) { menu.removeAllItems() - let rows = fetchRows() + rows = fetchRows() if rows.isEmpty { let none = NSMenuItem(title: "No agents", action: nil, keyEquivalent: "") none.isEnabled = false @@ -263,6 +143,44 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { entry.representedObject = row.paneId menu.addItem(entry) } + + menu.addItem(.separator()) + let toggle = NSMenuItem(title: visibility.visible ? "Hide Panel" : "Show Panel", + action: #selector(togglePanel), keyEquivalent: "") + toggle.target = self + if let hotkey, hotkeyRegistered { + toggle.keyEquivalent = hotkey.key.count == 1 ? hotkey.key : "" + toggle.keyEquivalentModifierMask = hotkey.modifierFlags + } + menu.addItem(toggle) + + let modeMenu = NSMenu() + for mode in PanelMode.allCases { + let option = NSMenuItem(title: mode.label, action: #selector(selectMode(_:)), keyEquivalent: "") + option.target = self + option.representedObject = mode.rawValue + option.state = visibility.mode == mode ? .on : .off + modeMenu.addItem(option) + } + let modeItem = NSMenuItem(title: "Show Panel Automatically", action: nil, keyEquivalent: "") + 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) + } + let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: ",") + settingsItem.target = self + menu.addItem(settingsItem) + menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Quit dingger", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) @@ -270,14 +188,21 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { @objc private func rowClicked(_ sender: NSMenuItem) { guard let paneId = sender.representedObject as? String else { return } - DispatchQueue.global().async { - focus(paneId) - DispatchQueue.main.async { self.refreshTitle() } - } + jump(to: paneId) } } let arguments = CommandLine.arguments +if arguments.contains("--selftest") { + var failures = panelSelftest() + if Hotkey.parse("ctrl+opt+cmd+e")?.display != "⌃⌥⌘E" { failures.append("hotkey: parse ctrl+opt+cmd+e") } + 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") } + failures.forEach { print("FAIL \($0)") } + print(failures.isEmpty ? "selftest ok" : "selftest: \(failures.count) failed") + exit(failures.isEmpty ? 0 : 1) +} if arguments.contains("--print") { let rows = fetchRows() print("title: \(plainTitle(for: rows))") @@ -286,6 +211,10 @@ if arguments.contains("--print") { } exit(0) } +if arguments.contains("--print-panel") { + print(PanelContent(rows: fetchRows()).plainText) + exit(0) +} if let index = arguments.firstIndex(of: "--click"), index + 1 < arguments.count { focus(arguments[index + 1]) exit(0) |
