diff options
Diffstat (limited to 'menubar/main.swift')
| -rw-r--r-- | menubar/main.swift | 108 |
1 files changed, 85 insertions, 23 deletions
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) |
