diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-22 18:01:04 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-22 18:01:04 +0300 |
| commit | f4a7346fd32f906ccc814364462c18afb3f10616 (patch) | |
| tree | c4502bc0a6725233e9ba45c5350bf7be44100f1d /menubar/panel.swift | |
| parent | 7649396bf30a65096fc3944e1404835e661f00af (diff) | |
agent search: spotlight-style fuzzy finder on ctrl+opt+cmd+shift+u
a centered, non-activating key panel that lists every agent and jumps to
the chosen one through the same dingger focus path as a row click.
- fzf-style matching: each word must match one of name, session, window,
harness, status, or directory, letters in order; the best alignment wins
(word starts and consecutive runs score higher), matched title letters
are highlighted.
- empty query keeps the menu's order, so enter jumps to the top waiting
agent.
- the selection follows the agent, not the position, as states change;
the list scrolls to keep it visible.
- keys: arrows, ctrl+n/p, ctrl+j/k, enter, esc, ctrl+u; the shortcut again
or clicking elsewhere closes it.
- search_hotkey setting; an unedited config file is upgraded. menu item
"Search Agents...". rows carry the pane's directory.
- --print-search and --snapshot-search debug flags; the panel's background
and snapshot code are shared.
selftest covers matching, ranking, highlighting, and selection; e2e step
15 checks queries through the real binary (fails when search only looks at
titles). e2e resolves relative binary paths, which tmux hooks need.
Diffstat (limited to 'menubar/panel.swift')
| -rw-r--r-- | menubar/panel.swift | 108 |
1 files changed, 61 insertions, 47 deletions
diff --git a/menubar/panel.swift b/menubar/panel.swift index 2b3cad2..003a061 100644 --- a/menubar/panel.swift +++ b/menubar/panel.swift @@ -147,8 +147,31 @@ private let inset: CGFloat = 8 /// desktops. private let panelTint = mocha(30, 30, 46, 0.72) +/// The panel's background: HUD blur under herdr's mocha base, rounded. +func darkHUD() -> NSVisualEffectView { + let effect = NSVisualEffectView() + effect.material = .hudWindow + effect.blendingMode = .behindWindow + effect.state = .active + effect.wantsLayer = true + effect.layer?.cornerRadius = 12 + effect.layer?.masksToBounds = true + let tint = NSView() + tint.wantsLayer = true + tint.layer?.backgroundColor = panelTint.cgColor + tint.translatesAutoresizingMaskIntoConstraints = false + effect.addSubview(tint) + 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), + ]) + return effect +} + /// A click target that does not swallow drags: a drag moves the panel. -private class ClickView: NSView { +class ClickView: NSView { var onClick: () -> Void = {} private var dragged = false var hovered = false { didSet { needsDisplay = true; hoverChanged() } } @@ -185,7 +208,6 @@ private class ClickView: NSView { } override func mouseUp(with event: NSEvent) { - log("panel: mouse up (dragged: \(dragged))") if !dragged { onClick() } } } @@ -210,12 +232,16 @@ private func singleLine(_ label: NSTextField) -> NSTextField { } /// One agent row: colored symbol and name, then a dim subtitle. -private final class PanelRowView: ClickView { - init(row: Row, onClick: @escaping (String) -> Void) { +final class PanelRowView: ClickView { + private let selected: Bool + + init(row: Row, title titleText: NSAttributedString? = nil, selected: Bool = false, + onClick: @escaping (String) -> Void) { + self.selected = selected super.init(frame: .zero) self.onClick = { onClick(row.paneId) } let title = singleLine(NSTextField( - labelWithAttributedString: rowTitle(row, primary: mochaText, secondary: mochaSubtext))) + labelWithAttributedString: titleText ?? rowTitle(row, primary: mochaText, secondary: mochaSubtext))) let subtitleLabel = NSTextField(labelWithString: row.subtitle) subtitleLabel.font = .systemFont(ofSize: 11) subtitleLabel.textColor = mochaSubtext @@ -240,8 +266,8 @@ private final class PanelRowView: ClickView { required init?(coder: NSCoder) { fatalError("not used") } override func draw(_ dirtyRect: NSRect) { - guard hovered else { return } - NSColor.white.withAlphaComponent(0.08).setFill() + guard hovered || selected else { return } + NSColor.white.withAlphaComponent(selected ? 0.14 : 0.08).setFill() NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6).fill() } } @@ -294,31 +320,15 @@ final class PanelController { panel.hasShadow = true panel.appearance = NSAppearance(named: .darkAqua) - let effect = NSVisualEffectView() - effect.material = .hudWindow - effect.blendingMode = .behindWindow - effect.state = .active - effect.wantsLayer = true - effect.layer?.cornerRadius = 12 - effect.layer?.masksToBounds = true + let effect = darkHUD() 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), @@ -420,32 +430,36 @@ final class PanelController { 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 + return snapshotView(view, to: path) + } +} + +/// Debug: draw a dark HUD view offscreen over a white and a black backdrop, +/// side by side, as a PNG. +func snapshotView(_ view: NSView, to path: String) -> Bool { + 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 |
