diff options
Diffstat (limited to 'menubar/main.swift')
| -rw-r--r-- | menubar/main.swift | 145 |
1 files changed, 77 insertions, 68 deletions
diff --git a/menubar/main.swift b/menubar/main.swift index 6c6c9a3..d0a3b30 100644 --- a/menubar/main.swift +++ b/menubar/main.swift @@ -1,9 +1,10 @@ // dingger menu bar: live agent states from tmux pane user options // (@agent / @agent_state), written by the dingger daemon. // -// Visual language is herdr's agent panel: green = ready (finished, waiting -// for you), yellow = working, red = blocked; filled glyph = in the ready -// queue (unseen), outline = already seen. Priority order = queue order. +// Visual language is herdr's agent panel, verbatim: five statuses +// (working ◐ yellow, blocked × red, done ✓ teal, idle ○ green, +// unknown · gray), priority order blocked > done > working > idle, +// and the number in the title is herdr's actionable set (done + blocked). import AppKit @@ -16,8 +17,9 @@ struct PaneRow { let red = NSColor.systemRed let yellow = NSColor.systemYellow +let teal = NSColor.systemTeal let green = NSColor.systemGreen -let dim = NSColor.systemGray +let gray = NSColor.systemGray /// Tint a symbol image (non-template, so it keeps its color in menus). func tinted(_ image: NSImage?, _ color: NSColor) -> NSImage? { @@ -38,13 +40,24 @@ func symbol(_ name: String, _ color: NSColor? = nil) -> NSImage? { return color.map { tinted(image, $0) } ?? image } -func rowSymbol(_ state: String, queued: Bool) -> NSImage? { - switch (state, queued) { - case ("blocked", true): return symbol("exclamationmark.circle.fill", red) - case ("idle", true): return symbol("checkmark.circle.fill", green) - case ("working", _): return symbol("circle.dotted", yellow) - case ("blocked", _): return symbol("exclamationmark.circle", dim) - default: return symbol("checkmark.circle", dim) +/// Herdr's status_color + Symbols style, as SF Symbols. +func statusSymbol(_ status: String) -> NSImage? { + switch status { + case "working": return symbol("circle.dotted", yellow) + case "blocked": return symbol("exclamationmark.circle.fill", red) + case "done": return symbol("checkmark.circle.fill", teal) + case "idle": return symbol("checkmark.circle", green) + default: return symbol("questionmark.circle", gray) + } +} + +func statusPriority(_ status: String) -> Int { + switch status { + case "blocked": return 4 + case "done": return 3 + case "working": return 2 + case "idle": return 1 + default: return 0 } } @@ -56,7 +69,7 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { func applicationDidFinishLaunching(_: Notification) { NSApp.setActivationPolicy(.accessory) // no Dock icon - item.button?.image = symbol("bell", dim) + item.button?.image = symbol("bell", gray) item.button?.font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular) item.menu = menu menu.delegate = self @@ -88,22 +101,21 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { } private func fetchPanes() -> [PaneRow] { - let fmt = "#{pane_id}\t#{@agent}\t#{@agent_state}\t#{@dingger_ack}\t#{session_name}\t#{window_name}\t#{window_panes}\t#{pane_index}" + let fmt = "#{pane_id}\t#{@agent}\t#{@agent_state}\t#{@dingger_ack}\t#{session_name}:#{window_name}\t#{window_panes}\t#{pane_index}" guard let out = runTmux(["-u", "list-panes", "-a", "-F", fmt]) else { return [] } return out.split(separator: "\n").compactMap { line in let fields = line.split(separator: "\t", omittingEmptySubsequences: false) - guard fields.count >= 8 else { return nil } + guard fields.count >= 7 else { return nil } let agent = String(fields[1]) let state = fields[2].isEmpty ? "unknown" : String(fields[2]) - // Only tracked panes with a live state belong in the menu. - guard !agent.isEmpty, ["idle", "working", "blocked"].contains(state) else { + guard !agent.isEmpty, ["working", "blocked", "done", "idle"].contains(state) else { return nil } - // Display target: session:window-name, with the pane index only - // when the window is split. (Switching uses the pane id.) - var target = "\(fields[4]):\(fields[5])" - if Int(fields[6]) ?? 1 > 1 { - target += ".\(fields[7])" + // fields[4] is already `session:window-name`; add the pane index + // only when the window is split. + var target = String(fields[4]) + if Int(fields[5]) ?? 1 > 1 { + target += ".\(fields[6])" } return PaneRow(paneId: String(fields[0]), target: target, agent: agent, state: state) } @@ -115,6 +127,23 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { .split(separator: ",").map { $0.split(separator: ":").first.map(String.init) ?? "" } } + private func orderedPanes() -> [PaneRow] { + // herdr's priority: blocked > done > working > idle; FIFO within the + // queue (oldest waiting first), target order for the rest. + let priority = { (row: PaneRow) -> Int in statusPriority(row.state) } + var rows = panes + rows.sort { + let (pa, pb) = (priority($0), priority($1)) + if pa != pb { return pa > pb } + let (qa, qb) = (queue.firstIndex(of: $0.paneId), queue.firstIndex(of: $1.paneId)) + if let qa, let qb { return qa < qb } + if qa != nil { return true } + if qb != nil { return false } + return $0.target < $1.target + } + return rows + } + // MARK: title (timer-driven; never touches the open menu) private func refreshTitle() { @@ -122,28 +151,32 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { queue = fetchQueue() FileHandle.standardError.write(Data( "\(Date()) menubar: panes=\(panes.count) queue=\(queue.count)\n".utf8)) - let queued = panes.filter { row in queue.contains(row.paneId) } - let blocked = queued.filter { $0.state == "blocked" }.count + redrawTitle() + } + + private func redrawTitle() { + let blocked = panes.filter { $0.state == "blocked" }.count + let done = panes.filter { $0.state == "done" }.count let working = panes.filter { $0.state == "working" }.count - let (name, color, count): (String, NSColor, String) + let (image, count): (NSImage?, String) if blocked > 0 { - (name, color, count) = ("bell.fill", red, String(queued.count)) - } else if !queued.isEmpty { - (name, color, count) = ("bell.fill", green, String(queued.count)) + (image, count) = (symbol("bell.fill", red), String(blocked)) + } else if done > 0 { + (image, count) = (symbol("bell.fill", teal), String(done)) } else if working > 0 { - (name, color, count) = ("bell", yellow, "") + (image, count) = (symbol("bell", yellow), "") } else { - (name, color, count) = ("bell", dim, "") + (image, count) = (symbol("bell", gray), "") } - item.button?.image = symbol(name, color) + item.button?.image = image item.button?.title = count var tip: [String] = [] if blocked > 0 { tip.append("\(blocked) blocked") } - if queued.count - blocked > 0 { tip.append("\(queued.count - blocked) waiting") } + if done > 0 { tip.append("\(done) done") } if working > 0 { tip.append("\(working) working") } - let tipText = tip.isEmpty ? "Nothing waiting" : tip.joined(separator: " · ") + let tipText = tip.isEmpty ? "No agents" : tip.joined(separator: " · ") item.button?.toolTip = tipText item.button?.setAccessibilityLabel("dingger, " + tipText) } @@ -157,39 +190,25 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { private func rebuildMenu() { menu.removeAllItems() - // Queue order (oldest waiting first), then the rest by target. - var ordered: [PaneRow] = [] - for paneId in queue { - if let row = panes.first(where: { $0.paneId == paneId && $0.state != "working" }) { - ordered.append(row) - } - } - let orderedIds = ordered.map { $0.paneId } - let rest = panes - .filter { !orderedIds.contains($0.paneId) } - .sorted { $0.target < $1.target } - ordered.append(contentsOf: rest) - - if panes.isEmpty { + let rows = orderedPanes() + if rows.isEmpty { let none = NSMenuItem(title: "No agents", action: nil, keyEquivalent: "") none.isEnabled = false menu.addItem(none) } - var previousWaiting: Bool? - for pane in ordered { - let queued = queue.contains(pane.paneId) - let waiting = pane.state == "idle" || pane.state == "blocked" - // One separator between waiting agents and busy ones. - if let previous = previousWaiting, previous && !waiting { + var previousPriority: Int? + for pane in rows { + let priority = statusPriority(pane.state) + if let previous = previousPriority, previous != priority { menu.addItem(.separator()) } - previousWaiting = waiting + previousPriority = priority let row = NSMenuItem(title: "\(pane.agent)\t\(pane.target)", action: #selector(paneClicked(_:)), keyEquivalent: "") row.target = self - row.image = rowSymbol(pane.state, queued: queued) + row.image = statusSymbol(pane.state) row.attributedTitle = attributedRow(agent: pane.agent, target: pane.target) row.toolTip = "\(pane.state) — \(pane.target)" row.representedObject = pane.paneId @@ -228,22 +247,12 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { // MARK: actions func printPanesForDebug() { - let rows = fetchPanes() - let queue = fetchQueue() + panes = fetchPanes() + queue = fetchQueue() + let rows = orderedPanes() if rows.isEmpty { print("(no agents)") } for pane in rows { - let queued = queue.contains(pane.paneId) - print("\(emojilessState(pane.state, queued: queued)) \(pane.agent) \(pane.target)") - } - } - - private func emojilessState(_ state: String, queued: Bool) -> String { - switch (state, queued) { - case ("blocked", true): return "[blocked!]" - case ("idle", true): return "[waiting]" - case ("working", _): return "[working]" - case ("blocked", _): return "[blocked]" - default: return "[idle]" + print("\(pane.state) \(pane.agent) \(pane.target)") } } |
