feat(mac): refine menubar nodes layout
This commit is contained in:
@@ -44,6 +44,7 @@ final class InstancesStore {
|
|||||||
private var task: Task<Void, Never>?
|
private var task: Task<Void, Never>?
|
||||||
private let interval: TimeInterval = 30
|
private let interval: TimeInterval = 30
|
||||||
private var eventTask: Task<Void, Never>?
|
private var eventTask: Task<Void, Never>?
|
||||||
|
private var startCount = 0
|
||||||
private var lastPresenceById: [String: InstanceInfo] = [:]
|
private var lastPresenceById: [String: InstanceInfo] = [:]
|
||||||
private var lastLoginNotifiedAtMs: [String: Double] = [:]
|
private var lastLoginNotifiedAtMs: [String: Double] = [:]
|
||||||
|
|
||||||
@@ -57,6 +58,8 @@ final class InstancesStore {
|
|||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
|
self.startCount += 1
|
||||||
|
guard self.startCount == 1 else { return }
|
||||||
guard self.task == nil else { return }
|
guard self.task == nil else { return }
|
||||||
self.startGatewaySubscription()
|
self.startGatewaySubscription()
|
||||||
self.task = Task.detached { [weak self] in
|
self.task = Task.detached { [weak self] in
|
||||||
@@ -70,6 +73,10 @@ final class InstancesStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
|
guard !self.isPreview else { return }
|
||||||
|
guard self.startCount > 0 else { return }
|
||||||
|
self.startCount -= 1
|
||||||
|
guard self.startCount == 0 else { return }
|
||||||
self.task?.cancel()
|
self.task?.cancel()
|
||||||
self.task = nil
|
self.task = nil
|
||||||
self.eventTask?.cancel()
|
self.eventTask?.cancel()
|
||||||
|
|||||||
@@ -48,20 +48,8 @@ struct MenuContent: View {
|
|||||||
if self.showVoiceWakeMicPicker {
|
if self.showVoiceWakeMicPicker {
|
||||||
self.voiceWakeMicMenu
|
self.voiceWakeMicMenu
|
||||||
}
|
}
|
||||||
Divider()
|
|
||||||
Button("Open Chat") {
|
|
||||||
Task { @MainActor in
|
|
||||||
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
|
||||||
WebChatManager.shared.show(sessionKey: sessionKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button("Open Dashboard") {
|
|
||||||
Task { @MainActor in
|
|
||||||
await self.openDashboard()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
||||||
Text("Allow Canvas")
|
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
||||||
}
|
}
|
||||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||||
if !enabled {
|
if !enabled {
|
||||||
@@ -69,16 +57,36 @@ struct MenuContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.state.canvasEnabled {
|
if self.state.canvasEnabled {
|
||||||
Button(self.state.canvasPanelVisible ? "Close Canvas" : "Open Canvas") {
|
Button {
|
||||||
if self.state.canvasPanelVisible {
|
if self.state.canvasPanelVisible {
|
||||||
CanvasManager.shared.hideAll()
|
CanvasManager.shared.hideAll()
|
||||||
} else {
|
} else {
|
||||||
// Don't force a navigation on re-open: preserve the current web view state.
|
// Don't force a navigation on re-open: preserve the current web view state.
|
||||||
_ = try? CanvasManager.shared.show(sessionKey: "main", path: nil)
|
_ = try? CanvasManager.shared.show(sessionKey: "main", path: nil)
|
||||||
}
|
}
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
self.state.canvasPanelVisible ? "Close Canvas" : "Open Canvas",
|
||||||
|
systemImage: "rectangle.inset.filled.on.rectangle")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
|
Button {
|
||||||
|
Task { @MainActor in
|
||||||
|
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
||||||
|
WebChatManager.shared.show(sessionKey: sessionKey)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Open Chat", systemImage: "bubble.left.and.bubble.right")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Task { @MainActor in
|
||||||
|
await self.openDashboard()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Open Dashboard", systemImage: "gauge")
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
Toggle(
|
Toggle(
|
||||||
isOn: Binding(
|
isOn: Binding(
|
||||||
get: { self.browserControlEnabled },
|
get: { self.browserControlEnabled },
|
||||||
@@ -86,7 +94,7 @@ struct MenuContent: View {
|
|||||||
self.browserControlEnabled = enabled
|
self.browserControlEnabled = enabled
|
||||||
ClawdisConfigFile.setBrowserControlEnabled(enabled)
|
ClawdisConfigFile.setBrowserControlEnabled(enabled)
|
||||||
})) {
|
})) {
|
||||||
Text("Browser Control")
|
Label("Browser Control", systemImage: "globe")
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
Button("Settings…") { self.open(tab: .general) }
|
Button("Settings…") { self.open(tab: .general) }
|
||||||
|
|||||||
99
apps/macos/Sources/Clawdis/MenuHighlightedHostView.swift
Normal file
99
apps/macos/Sources/Clawdis/MenuHighlightedHostView.swift
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
final class HighlightedMenuItemHostView: NSView {
|
||||||
|
private var baseView: AnyView
|
||||||
|
private let hosting: NSHostingView<AnyView>
|
||||||
|
private var targetWidth: CGFloat
|
||||||
|
private var tracking: NSTrackingArea?
|
||||||
|
private var hovered = false {
|
||||||
|
didSet { self.updateHighlight() }
|
||||||
|
}
|
||||||
|
|
||||||
|
init(rootView: AnyView, width: CGFloat) {
|
||||||
|
self.baseView = rootView
|
||||||
|
self.hosting = NSHostingView(rootView: AnyView(rootView.environment(\.menuItemHighlighted, false)))
|
||||||
|
self.targetWidth = max(1, width)
|
||||||
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
self.addSubview(self.hosting)
|
||||||
|
self.hosting.autoresizingMask = [.width, .height]
|
||||||
|
self.updateSizing()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override var intrinsicContentSize: NSSize {
|
||||||
|
self.hosting.fittingSize
|
||||||
|
}
|
||||||
|
|
||||||
|
override func updateTrackingAreas() {
|
||||||
|
super.updateTrackingAreas()
|
||||||
|
if let tracking {
|
||||||
|
self.removeTrackingArea(tracking)
|
||||||
|
}
|
||||||
|
let options: NSTrackingArea.Options = [
|
||||||
|
.mouseEnteredAndExited,
|
||||||
|
.activeAlways,
|
||||||
|
.inVisibleRect,
|
||||||
|
]
|
||||||
|
let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
|
||||||
|
self.addTrackingArea(area)
|
||||||
|
self.tracking = area
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseEntered(with event: NSEvent) {
|
||||||
|
_ = event
|
||||||
|
self.hovered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseExited(with event: NSEvent) {
|
||||||
|
_ = event
|
||||||
|
self.hovered = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layout() {
|
||||||
|
super.layout()
|
||||||
|
self.hosting.frame = self.bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
|
if self.hovered {
|
||||||
|
NSColor.selectedContentBackgroundColor.setFill()
|
||||||
|
self.bounds.fill()
|
||||||
|
}
|
||||||
|
super.draw(dirtyRect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(rootView: AnyView, width: CGFloat) {
|
||||||
|
self.baseView = rootView
|
||||||
|
self.targetWidth = max(1, width)
|
||||||
|
self.updateHighlight()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateHighlight() {
|
||||||
|
self.hosting.rootView = AnyView(self.baseView.environment(\.menuItemHighlighted, self.hovered))
|
||||||
|
self.updateSizing()
|
||||||
|
self.needsDisplay = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSizing() {
|
||||||
|
self.hosting.frame.size.width = self.targetWidth
|
||||||
|
let size = self.hosting.fittingSize
|
||||||
|
self.frame = NSRect(origin: .zero, size: NSSize(width: self.targetWidth, height: size.height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MenuHostedHighlightedItem: NSViewRepresentable {
|
||||||
|
let width: CGFloat
|
||||||
|
let rootView: AnyView
|
||||||
|
|
||||||
|
func makeNSView(context _: Context) -> HighlightedMenuItemHostView {
|
||||||
|
HighlightedMenuItemHostView(rootView: self.rootView, width: self.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: HighlightedMenuItemHostView, context _: Context) {
|
||||||
|
nsView.update(rootView: self.rootView, width: self.width)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
static let shared = MenuSessionsInjector()
|
static let shared = MenuSessionsInjector()
|
||||||
|
|
||||||
private let tag = 9_415_557
|
private let tag = 9_415_557
|
||||||
|
private let nodesTag = 9_415_558
|
||||||
private let fallbackWidth: CGFloat = 320
|
private let fallbackWidth: CGFloat = 320
|
||||||
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
|
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
|
||||||
|
|
||||||
private weak var originalDelegate: NSMenuDelegate?
|
private weak var originalDelegate: NSMenuDelegate?
|
||||||
private weak var statusItem: NSStatusItem?
|
private weak var statusItem: NSStatusItem?
|
||||||
private var loadTask: Task<Void, Never>?
|
private var loadTask: Task<Void, Never>?
|
||||||
|
private var nodesLoadTask: Task<Void, Never>?
|
||||||
private var isMenuOpen = false
|
private var isMenuOpen = false
|
||||||
private var lastKnownMenuWidth: CGFloat?
|
private var lastKnownMenuWidth: CGFloat?
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
private var cachedErrorText: String?
|
private var cachedErrorText: String?
|
||||||
private var cacheUpdatedAt: Date?
|
private var cacheUpdatedAt: Date?
|
||||||
private let refreshIntervalSeconds: TimeInterval = 12
|
private let refreshIntervalSeconds: TimeInterval = 12
|
||||||
|
private let nodesStore = InstancesStore.shared
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
private var testControlChannelConnected: Bool?
|
private var testControlChannelConnected: Bool?
|
||||||
#endif
|
#endif
|
||||||
@@ -36,6 +39,8 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
if self.loadTask == nil {
|
if self.loadTask == nil {
|
||||||
self.loadTask = Task { await self.refreshCache(force: true) }
|
self.loadTask = Task { await self.refreshCache(force: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.nodesStore.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func menuWillOpen(_ menu: NSMenu) {
|
func menuWillOpen(_ menu: NSMenu) {
|
||||||
@@ -43,6 +48,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
self.isMenuOpen = true
|
self.isMenuOpen = true
|
||||||
|
|
||||||
self.inject(into: menu)
|
self.inject(into: menu)
|
||||||
|
self.injectNodes(into: menu)
|
||||||
|
|
||||||
// Refresh in background for the next open (but only when connected).
|
// Refresh in background for the next open (but only when connected).
|
||||||
self.loadTask?.cancel()
|
self.loadTask?.cancel()
|
||||||
@@ -53,6 +59,17 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
guard self.isMenuOpen else { return }
|
guard self.isMenuOpen else { return }
|
||||||
// SwiftUI might have refreshed menu items; re-inject once.
|
// SwiftUI might have refreshed menu items; re-inject once.
|
||||||
self.inject(into: menu)
|
self.inject(into: menu)
|
||||||
|
self.injectNodes(into: menu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.nodesLoadTask?.cancel()
|
||||||
|
self.nodesLoadTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.nodesStore.refresh()
|
||||||
|
await MainActor.run {
|
||||||
|
guard self.isMenuOpen else { return }
|
||||||
|
self.injectNodes(into: menu)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,6 +78,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
self.originalDelegate?.menuDidClose?(menu)
|
self.originalDelegate?.menuDidClose?(menu)
|
||||||
self.isMenuOpen = false
|
self.isMenuOpen = false
|
||||||
self.loadTask?.cancel()
|
self.loadTask?.cancel()
|
||||||
|
self.nodesLoadTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||||
@@ -159,6 +177,73 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func injectNodes(into menu: NSMenu) {
|
||||||
|
for item in menu.items where item.tag == self.nodesTag {
|
||||||
|
menu.removeItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let insertIndex = self.findNodesInsertIndex(in: menu) else { return }
|
||||||
|
let width = self.initialWidth(for: menu)
|
||||||
|
var cursor = insertIndex
|
||||||
|
|
||||||
|
let entries = self.sortedNodeEntries()
|
||||||
|
let header = self.makeNodesHeaderItem(width: width, count: entries.count)
|
||||||
|
menu.insertItem(header, at: cursor)
|
||||||
|
cursor += 1
|
||||||
|
|
||||||
|
guard self.isControlChannelConnected else {
|
||||||
|
menu.insertItem(
|
||||||
|
self.makeMessageItem(text: "No connection to gateway", symbolName: "wifi.slash", width: width),
|
||||||
|
at: cursor)
|
||||||
|
cursor += 1
|
||||||
|
let separator = NSMenuItem.separator()
|
||||||
|
separator.tag = self.nodesTag
|
||||||
|
menu.insertItem(separator, at: cursor)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = self.nodesStore.lastError?.nonEmpty {
|
||||||
|
menu.insertItem(self.makeMessageItem(text: "Error: \(error)", symbolName: "exclamationmark.triangle",
|
||||||
|
width: width), at: cursor)
|
||||||
|
cursor += 1
|
||||||
|
} else if let status = self.nodesStore.statusMessage?.nonEmpty {
|
||||||
|
menu.insertItem(self.makeMessageItem(text: status, symbolName: "info.circle", width: width), at: cursor)
|
||||||
|
cursor += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries.isEmpty {
|
||||||
|
let title = self.nodesStore.isLoading ? "Loading nodes..." : "No nodes yet"
|
||||||
|
menu.insertItem(self.makeMessageItem(text: title, symbolName: "circle.dashed", width: width), at: cursor)
|
||||||
|
cursor += 1
|
||||||
|
} else {
|
||||||
|
for entry in entries.prefix(5) {
|
||||||
|
let item = NSMenuItem()
|
||||||
|
item.tag = self.nodesTag
|
||||||
|
item.target = self
|
||||||
|
item.action = #selector(self.copyNodeSummary(_:))
|
||||||
|
item.representedObject = NodeMenuEntryFormatter.summaryText(entry)
|
||||||
|
item.view = HighlightedMenuItemHostView(
|
||||||
|
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
|
||||||
|
width: width)
|
||||||
|
menu.insertItem(item, at: cursor)
|
||||||
|
cursor += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries.count > 5 {
|
||||||
|
let moreItem = NSMenuItem()
|
||||||
|
moreItem.tag = self.nodesTag
|
||||||
|
moreItem.title = "More Nodes..."
|
||||||
|
moreItem.image = NSImage(systemSymbolName: "ellipsis.circle", accessibilityDescription: nil)
|
||||||
|
let overflow = Array(entries.dropFirst(5))
|
||||||
|
moreItem.submenu = self.buildNodesOverflowMenu(entries: overflow, width: width)
|
||||||
|
menu.insertItem(moreItem, at: cursor)
|
||||||
|
cursor += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = cursor
|
||||||
|
}
|
||||||
|
|
||||||
private var isControlChannelConnected: Bool {
|
private var isControlChannelConnected: Bool {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if let override = self.testControlChannelConnected { return override }
|
if let override = self.testControlChannelConnected { return override }
|
||||||
@@ -321,6 +406,21 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func buildNodesOverflowMenu(entries: [InstanceInfo], width: CGFloat) -> NSMenu {
|
||||||
|
let menu = NSMenu()
|
||||||
|
for entry in entries {
|
||||||
|
let item = NSMenuItem()
|
||||||
|
item.target = self
|
||||||
|
item.action = #selector(self.copyNodeSummary(_:))
|
||||||
|
item.representedObject = NodeMenuEntryFormatter.summaryText(entry)
|
||||||
|
item.view = HighlightedMenuItemHostView(
|
||||||
|
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
|
||||||
|
width: width)
|
||||||
|
menu.addItem(item)
|
||||||
|
}
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
private func patchThinking(_ sender: NSMenuItem) {
|
private func patchThinking(_ sender: NSMenuItem) {
|
||||||
guard let dict = sender.representedObject as? [String: Any],
|
guard let dict = sender.representedObject as? [String: Any],
|
||||||
@@ -423,6 +523,13 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
private func copyNodeSummary(_ sender: NSMenuItem) {
|
||||||
|
guard let summary = sender.representedObject as? String else { return }
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(summary, forType: .string)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Width + placement
|
// MARK: - Width + placement
|
||||||
|
|
||||||
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
||||||
@@ -442,6 +549,22 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
return menu.items.count
|
return menu.items.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func findNodesInsertIndex(in menu: NSMenu) -> Int? {
|
||||||
|
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
|
||||||
|
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
|
||||||
|
return sepIdx
|
||||||
|
}
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
|
||||||
|
return sepIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
if menu.items.count >= 1 { return 1 }
|
||||||
|
return menu.items.count
|
||||||
|
}
|
||||||
|
|
||||||
private func initialWidth(for menu: NSMenu) -> CGFloat {
|
private func initialWidth(for menu: NSMenu) -> CGFloat {
|
||||||
let candidates: [CGFloat] = [
|
let candidates: [CGFloat] = [
|
||||||
menu.minimumWidth,
|
menu.minimumWidth,
|
||||||
@@ -452,6 +575,53 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
return max(300, resolved)
|
return max(300, resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sortedNodeEntries() -> [InstanceInfo] {
|
||||||
|
let entries = self.nodesStore.instances.filter { entry in
|
||||||
|
let mode = entry.mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
return mode != "health"
|
||||||
|
}
|
||||||
|
return entries.sorted { lhs, rhs in
|
||||||
|
let lhsGateway = NodeMenuEntryFormatter.isGateway(lhs)
|
||||||
|
let rhsGateway = NodeMenuEntryFormatter.isGateway(rhs)
|
||||||
|
if lhsGateway != rhsGateway { return lhsGateway }
|
||||||
|
|
||||||
|
let lhsLocal = NodeMenuEntryFormatter.isLocal(lhs)
|
||||||
|
let rhsLocal = NodeMenuEntryFormatter.isLocal(rhs)
|
||||||
|
if lhsLocal != rhsLocal { return lhsLocal }
|
||||||
|
|
||||||
|
let lhsName = NodeMenuEntryFormatter.primaryName(lhs).lowercased()
|
||||||
|
let rhsName = NodeMenuEntryFormatter.primaryName(rhs).lowercased()
|
||||||
|
if lhsName == rhsName { return lhs.ts > rhs.ts }
|
||||||
|
return lhsName < rhsName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeNodesHeaderItem(width: CGFloat, count: Int) -> NSMenuItem {
|
||||||
|
let view = AnyView(
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "network")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Nodes")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text("\(count)")
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.leading, 18)
|
||||||
|
.padding(.trailing, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.frame(minWidth: 300, alignment: .leading))
|
||||||
|
|
||||||
|
let item = NSMenuItem()
|
||||||
|
item.tag = self.nodesTag
|
||||||
|
item.isEnabled = false
|
||||||
|
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Views
|
// MARK: - Views
|
||||||
|
|
||||||
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
|
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
|
||||||
@@ -490,81 +660,3 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private final class HighlightedMenuItemHostView: NSView {
|
|
||||||
private let baseView: AnyView
|
|
||||||
private let hosting: NSHostingView<AnyView>
|
|
||||||
private var targetWidth: CGFloat
|
|
||||||
private var tracking: NSTrackingArea?
|
|
||||||
private var hovered = false {
|
|
||||||
didSet { self.updateHighlight() }
|
|
||||||
}
|
|
||||||
|
|
||||||
init(rootView: AnyView, width: CGFloat) {
|
|
||||||
self.baseView = rootView
|
|
||||||
self.hosting = NSHostingView(rootView: AnyView(rootView.environment(\.menuItemHighlighted, false)))
|
|
||||||
self.targetWidth = max(1, width)
|
|
||||||
super.init(frame: .zero)
|
|
||||||
|
|
||||||
self.addSubview(self.hosting)
|
|
||||||
self.hosting.autoresizingMask = [.width, .height]
|
|
||||||
self.updateSizing()
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
||||||
|
|
||||||
override var intrinsicContentSize: NSSize {
|
|
||||||
self.hosting.fittingSize
|
|
||||||
}
|
|
||||||
|
|
||||||
override func updateTrackingAreas() {
|
|
||||||
super.updateTrackingAreas()
|
|
||||||
if let tracking {
|
|
||||||
self.removeTrackingArea(tracking)
|
|
||||||
}
|
|
||||||
let options: NSTrackingArea.Options = [
|
|
||||||
.mouseEnteredAndExited,
|
|
||||||
.activeAlways,
|
|
||||||
.inVisibleRect,
|
|
||||||
]
|
|
||||||
let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
|
|
||||||
self.addTrackingArea(area)
|
|
||||||
self.tracking = area
|
|
||||||
}
|
|
||||||
|
|
||||||
override func mouseEntered(with event: NSEvent) {
|
|
||||||
_ = event
|
|
||||||
self.hovered = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override func mouseExited(with event: NSEvent) {
|
|
||||||
_ = event
|
|
||||||
self.hovered = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layout() {
|
|
||||||
super.layout()
|
|
||||||
self.hosting.frame = self.bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
override func draw(_ dirtyRect: NSRect) {
|
|
||||||
if self.hovered {
|
|
||||||
NSColor.selectedContentBackgroundColor.setFill()
|
|
||||||
self.bounds.fill()
|
|
||||||
}
|
|
||||||
super.draw(dirtyRect)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateHighlight() {
|
|
||||||
self.hosting.rootView = AnyView(self.baseView.environment(\.menuItemHighlighted, self.hovered))
|
|
||||||
self.updateSizing()
|
|
||||||
self.needsDisplay = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateSizing() {
|
|
||||||
self.hosting.frame.size.width = self.targetWidth
|
|
||||||
let size = self.hosting.fittingSize
|
|
||||||
self.frame = NSRect(origin: .zero, size: NSSize(width: self.targetWidth, height: size.height))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
157
apps/macos/Sources/Clawdis/NodesMenu.swift
Normal file
157
apps/macos/Sources/Clawdis/NodesMenu.swift
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct NodeMenuEntryFormatter {
|
||||||
|
static func isGateway(_ entry: InstanceInfo) -> Bool {
|
||||||
|
entry.mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "gateway"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isLocal(_ entry: InstanceInfo) -> Bool {
|
||||||
|
entry.mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func primaryName(_ entry: InstanceInfo) -> String {
|
||||||
|
if self.isGateway(entry) {
|
||||||
|
let host = entry.host?.nonEmpty
|
||||||
|
if let host, host.lowercased() != "gateway" { return host }
|
||||||
|
return "Gateway"
|
||||||
|
}
|
||||||
|
return entry.host?.nonEmpty ?? entry.id
|
||||||
|
}
|
||||||
|
|
||||||
|
static func summaryText(_ entry: InstanceInfo) -> String {
|
||||||
|
entry.text.nonEmpty ?? self.primaryName(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func detailText(_ entry: InstanceInfo) -> String {
|
||||||
|
var parts: [String] = []
|
||||||
|
|
||||||
|
if self.isGateway(entry) {
|
||||||
|
parts.append("gateway")
|
||||||
|
} else if let mode = entry.mode?.nonEmpty {
|
||||||
|
parts.append(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let ip = entry.ip?.nonEmpty { parts.append(ip) }
|
||||||
|
if let version = entry.version?.nonEmpty { parts.append("app \(version)") }
|
||||||
|
if let platform = entry.platform?.nonEmpty { parts.append(platform) }
|
||||||
|
|
||||||
|
if parts.isEmpty, let text = entry.text.nonEmpty {
|
||||||
|
let trimmed = text
|
||||||
|
.replacingOccurrences(of: "Node: ", with: "")
|
||||||
|
.replacingOccurrences(of: "Gateway: ", with: "")
|
||||||
|
let candidates = trimmed
|
||||||
|
.components(separatedBy: " · ")
|
||||||
|
.filter { !$0.hasPrefix("mode ") && !$0.hasPrefix("reason ") }
|
||||||
|
if !candidates.isEmpty {
|
||||||
|
parts.append(contentsOf: candidates.prefix(2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts.isEmpty {
|
||||||
|
parts.append(entry.ageDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts.count > 2 {
|
||||||
|
parts = Array(parts.prefix(2))
|
||||||
|
}
|
||||||
|
return parts.joined(separator: " / ")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func leadingSymbol(_ entry: InstanceInfo) -> String {
|
||||||
|
if self.isGateway(entry) { return self.safeSystemSymbol("dot.radiowaves.left.and.right", fallback: "network") }
|
||||||
|
if let family = entry.deviceFamily?.lowercased() {
|
||||||
|
if family.contains("mac") {
|
||||||
|
return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer")
|
||||||
|
}
|
||||||
|
if family.contains("iphone") { return self.safeSystemSymbol("iphone", fallback: "iphone") }
|
||||||
|
if family.contains("ipad") { return self.safeSystemSymbol("ipad", fallback: "ipad") }
|
||||||
|
}
|
||||||
|
if let platform = entry.platform?.lowercased() {
|
||||||
|
if platform.contains("mac") { return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer") }
|
||||||
|
if platform.contains("ios") { return self.safeSystemSymbol("iphone", fallback: "iphone") }
|
||||||
|
if platform.contains("android") { return self.safeSystemSymbol("cpu", fallback: "cpu") }
|
||||||
|
}
|
||||||
|
return "cpu"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isAndroid(_ entry: InstanceInfo) -> Bool {
|
||||||
|
let family = entry.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
return family == "android"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func safeSystemSymbol(_ preferred: String, fallback: String) -> String {
|
||||||
|
if NSImage(systemSymbolName: preferred, accessibilityDescription: nil) != nil { return preferred }
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NodeMenuRowView: View {
|
||||||
|
let entry: InstanceInfo
|
||||||
|
let width: CGFloat
|
||||||
|
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||||
|
|
||||||
|
private var primaryColor: Color {
|
||||||
|
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
|
||||||
|
}
|
||||||
|
|
||||||
|
private var secondaryColor: Color {
|
||||||
|
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
self.leadingIcon
|
||||||
|
.frame(width: 22, height: 22, alignment: .center)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(NodeMenuEntryFormatter.primaryName(self.entry))
|
||||||
|
.font(.callout.weight(NodeMenuEntryFormatter.isGateway(self.entry) ? .semibold : .regular))
|
||||||
|
.foregroundStyle(self.primaryColor)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
|
||||||
|
Text(NodeMenuEntryFormatter.detailText(self.entry))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(self.secondaryColor)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.padding(.leading, 18)
|
||||||
|
.padding(.trailing, 12)
|
||||||
|
.frame(width: max(1, self.width), alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var leadingIcon: some View {
|
||||||
|
if NodeMenuEntryFormatter.isAndroid(self.entry) {
|
||||||
|
AndroidMark()
|
||||||
|
.foregroundStyle(self.secondaryColor)
|
||||||
|
} else {
|
||||||
|
Image(systemName: NodeMenuEntryFormatter.leadingSymbol(self.entry))
|
||||||
|
.font(.system(size: 18, weight: .regular))
|
||||||
|
.foregroundStyle(self.secondaryColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AndroidMark: View {
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let w = geo.size.width
|
||||||
|
let h = geo.size.height
|
||||||
|
let headHeight = h * 0.68
|
||||||
|
let headWidth = w * 0.92
|
||||||
|
let headX = (w - headWidth) * 0.5
|
||||||
|
let headY = (h - headHeight) * 0.5
|
||||||
|
let corner = min(w, h) * 0.18
|
||||||
|
RoundedRectangle(cornerRadius: corner, style: .continuous)
|
||||||
|
.frame(width: headWidth, height: headHeight)
|
||||||
|
.position(x: headX + headWidth * 0.5, y: headY + headHeight * 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user