Keep group submenu open during URLTest updates

This commit is contained in:
世界
2026-01-08 16:13:33 +08:00
parent ce2292d34e
commit 1707a3412a
2 changed files with 188 additions and 26 deletions
+1 -8
View File
@@ -2881,14 +2881,7 @@
"shouldTranslate" : false "shouldTranslate" : false
}, },
"URLTest" : { "URLTest" : {
"localizations" : { "shouldTranslate" : false
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "URL 测试"
}
}
}
}, },
"User" : { "User" : {
"localizations" : { "localizations" : {
+186 -17
View File
@@ -19,6 +19,10 @@ public class StatusBarController: NSObject, NSMenuDelegate {
private var urlTestAllItem: NSMenuItem? private var urlTestAllItem: NSMenuItem?
private var urlTestAllView: StatusBarURLTestView? private var urlTestAllView: StatusBarURLTestView?
private var urlTestGroupViews: [String: StatusBarURLTestView] = [:] private var urlTestGroupViews: [String: StatusBarURLTestView] = [:]
private var groupMenuItems: [String: NSMenuItem] = [:]
private var groupOutboundItems: [String: [String: NSMenuItem]] = [:]
private var groupOrder: [String] = []
private var outboundOrderByGroup: [String: [String]] = [:]
private var groupsItem: NSMenuItem? private var groupsItem: NSMenuItem?
private var profilesItem: NSMenuItem? private var profilesItem: NSMenuItem?
private var currentGroups: [LibboxOutboundGroup] = [] private var currentGroups: [LibboxOutboundGroup] = []
@@ -80,6 +84,10 @@ public class StatusBarController: NSObject, NSMenuDelegate {
urlTestAllView = nil urlTestAllView = nil
urlTestAllItem = nil urlTestAllItem = nil
urlTestGroupViews.removeAll() urlTestGroupViews.removeAll()
groupMenuItems.removeAll()
groupOutboundItems.removeAll()
groupOrder.removeAll()
outboundOrderByGroup.removeAll()
currentGroups = [] currentGroups = []
isURLTestingAll = false isURLTestingAll = false
urlTestingGroups.removeAll() urlTestingGroups.removeAll()
@@ -213,8 +221,6 @@ public class StatusBarController: NSObject, NSMenuDelegate {
private func updateGroupsMenu(_ groups: [LibboxOutboundGroup]?) { private func updateGroupsMenu(_ groups: [LibboxOutboundGroup]?) {
currentGroups = groups ?? [] currentGroups = groups ?? []
guard let submenu = groupsItem?.submenu else { return } guard let submenu = groupsItem?.submenu else { return }
submenu.removeAllItems()
urlTestGroupViews.removeAll()
let groups = currentGroups let groups = currentGroups
@@ -227,6 +233,43 @@ public class StatusBarController: NSObject, NSMenuDelegate {
groupsItem?.isHidden = environments.extensionProfile?.status.isConnected != true groupsItem?.isHidden = environments.extensionProfile?.status.isConnected != true
let newGroupOrder = selectableGroups.map(\.tag)
var newOutboundOrderByGroup: [String: [String]] = [:]
for group in selectableGroups {
var tags: [String] = []
let items = group.getItems()!
while items.hasNext() {
tags.append(items.next()!.tag)
}
newOutboundOrderByGroup[group.tag] = tags
}
let structureSame = newGroupOrder == groupOrder
&& newOutboundOrderByGroup == outboundOrderByGroup
&& urlTestAllView != nil
&& !groupMenuItems.isEmpty
if structureSame {
if !updateGroupMenuItems(selectableGroups) {
rebuildGroupsMenu(selectableGroups, submenu: submenu)
groupOrder = newGroupOrder
outboundOrderByGroup = newOutboundOrderByGroup
}
} else {
rebuildGroupsMenu(selectableGroups, submenu: submenu)
groupOrder = newGroupOrder
outboundOrderByGroup = newOutboundOrderByGroup
}
updateURLTestAvailability()
}
private func rebuildGroupsMenu(_ selectableGroups: [LibboxOutboundGroup], submenu: NSMenu) {
submenu.removeAllItems()
urlTestGroupViews.removeAll()
groupMenuItems.removeAll()
groupOutboundItems.removeAll()
let urlTestAllView = StatusBarURLTestView(title: NSLocalizedString("URLTest", comment: "")) { [weak self] in let urlTestAllView = StatusBarURLTestView(title: NSLocalizedString("URLTest", comment: "")) { [weak self] in
self?.performURLTestForAllGroups() self?.performURLTestForAllGroups()
} }
@@ -262,6 +305,7 @@ public class StatusBarController: NSObject, NSMenuDelegate {
groupSubmenu.addItem(NSMenuItem.separator()) groupSubmenu.addItem(NSMenuItem.separator())
urlTestGroupViews[group.tag] = groupURLTestView urlTestGroupViews[group.tag] = groupURLTestView
var outboundItemsByTag: [String: NSMenuItem] = [:]
var outboundData: [(LibboxOutboundGroupItem, NSMenuItem)] = [] var outboundData: [(LibboxOutboundGroupItem, NSMenuItem)] = []
var maxTagWidth: CGFloat = 0 var maxTagWidth: CGFloat = 0
var maxDelayWidth: CGFloat = 0 var maxDelayWidth: CGFloat = 0
@@ -277,6 +321,7 @@ public class StatusBarController: NSObject, NSMenuDelegate {
outboundItem.target = self outboundItem.target = self
outboundItem.representedObject = ["groupTag": group.tag, "outboundTag": outbound.tag] outboundItem.representedObject = ["groupTag": group.tag, "outboundTag": outbound.tag]
outboundItem.state = group.selected == outbound.tag ? .on : .off outboundItem.state = group.selected == outbound.tag ? .on : .off
outboundItemsByTag[outbound.tag] = outboundItem
let tagWidth = (outbound.tag as NSString).size(withAttributes: attrs).width let tagWidth = (outbound.tag as NSString).size(withAttributes: attrs).width
maxTagWidth = max(maxTagWidth, tagWidth) maxTagWidth = max(maxTagWidth, tagWidth)
@@ -318,9 +363,67 @@ public class StatusBarController: NSObject, NSMenuDelegate {
groupItem.submenu = groupSubmenu groupItem.submenu = groupSubmenu
submenu.addItem(groupItem) submenu.addItem(groupItem)
groupMenuItems[group.tag] = groupItem
groupOutboundItems[group.tag] = outboundItemsByTag
}
} }
updateURLTestAvailability() private func updateGroupMenuItems(_ selectableGroups: [LibboxOutboundGroup]) -> Bool {
let font = NSFont.menuFont(ofSize: 0)
let attrs: [NSAttributedString.Key: Any] = [.font: font]
for group in selectableGroups {
guard let outboundItemsByTag = groupOutboundItems[group.tag] else { return false }
var outboundData: [(LibboxOutboundGroupItem, NSMenuItem)] = []
var maxTagWidth: CGFloat = 0
var maxDelayWidth: CGFloat = 0
let items = group.getItems()!
while items.hasNext() {
let outbound = items.next()!
guard let outboundItem = outboundItemsByTag[outbound.tag] else { return false }
outboundItem.state = group.selected == outbound.tag ? .on : .off
let tagWidth = (outbound.tag as NSString).size(withAttributes: attrs).width
maxTagWidth = max(maxTagWidth, tagWidth)
if outbound.urlTestDelay > 0 {
let delayText = "\(outbound.urlTestDelay)ms"
let delayWidth = (delayText as NSString).size(withAttributes: attrs).width
maxDelayWidth = max(maxDelayWidth, delayWidth)
}
outboundData.append((outbound, outboundItem))
}
let tabLocation = maxTagWidth + 20 + maxDelayWidth
let tabStop = NSTextTab(textAlignment: .right, location: tabLocation)
let style = NSMutableParagraphStyle()
style.tabStops = [tabStop]
for (outbound, outboundItem) in outboundData {
let delay = outbound.urlTestDelay
let delayText = delay > 0 ? "\(delay)ms" : ""
let fullText = "\(outbound.tag)\t\(delayText)"
let attrString = NSMutableAttributedString(
string: fullText,
attributes: [.font: font, .paragraphStyle: style]
)
if delay > 0 {
let color = NSColor.delayColor(for: UInt16(delay))
let delayStart = (fullText as NSString).length - (delayText as NSString).length
attrString.addAttribute(
.foregroundColor,
value: color,
range: NSRange(location: delayStart, length: (delayText as NSString).length)
)
}
outboundItem.attributedTitle = attrString
}
}
return true
} }
@objc private func selectProfile(_ sender: NSMenuItem) { @objc private func selectProfile(_ sender: NSMenuItem) {
@@ -504,17 +607,19 @@ public class StatusBarController: NSObject, NSMenuDelegate {
@MainActor @MainActor
private class StatusBarURLTestView: NSView { private class StatusBarURLTestView: NSView {
private let button: NSButton private let titleLabel: NSTextField
private let progressIndicator: NSProgressIndicator private let progressIndicator: NSProgressIndicator
private let action: () -> Void private let action: () -> Void
private var isEnabled = true private var isEnabled = true
private var isLoading = false private var isLoading = false
private var isHighlighted = false
private var trackingArea: NSTrackingArea?
init(title: String, action: @escaping () -> Void) { init(title: String, action: @escaping () -> Void) {
self.action = action self.action = action
button = NSButton(title: title, target: nil, action: nil) titleLabel = NSTextField(labelWithString: title)
progressIndicator = NSProgressIndicator() progressIndicator = NSProgressIndicator()
super.init(frame: NSRect(x: 0, y: 0, width: 250, height: 30)) super.init(frame: NSRect(x: 0, y: 0, width: 250, height: 22))
setupView() setupView()
} }
@@ -524,12 +629,10 @@ private class StatusBarURLTestView: NSView {
} }
private func setupView() { private func setupView() {
button.target = self titleLabel.font = NSFont.menuFont(ofSize: 0)
button.action = #selector(buttonClicked) titleLabel.lineBreakMode = .byTruncatingTail
button.bezelStyle = .texturedRounded titleLabel.translatesAutoresizingMaskIntoConstraints = false
button.controlSize = .small addSubview(titleLabel)
button.translatesAutoresizingMaskIntoConstraints = false
addSubview(button)
progressIndicator.style = .spinning progressIndicator.style = .spinning
progressIndicator.controlSize = .small progressIndicator.controlSize = .small
@@ -538,13 +641,14 @@ private class StatusBarURLTestView: NSView {
addSubview(progressIndicator) addSubview(progressIndicator)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
button.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 18),
button.trailingAnchor.constraint(lessThanOrEqualTo: progressIndicator.leadingAnchor, constant: -8), titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: progressIndicator.leadingAnchor, constant: -8),
button.centerYAnchor.constraint(equalTo: centerYAnchor), titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
progressIndicator.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), progressIndicator.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
progressIndicator.centerYAnchor.constraint(equalTo: centerYAnchor), progressIndicator.centerYAnchor.constraint(equalTo: centerYAnchor),
]) ])
updateState()
} }
func setEnabled(_ enabled: Bool) { func setEnabled(_ enabled: Bool) {
@@ -558,17 +662,82 @@ private class StatusBarURLTestView: NSView {
} }
private func updateState() { private func updateState() {
button.isEnabled = isEnabled && !isLoading let canInteract = isEnabled && !isLoading
if !canInteract {
isHighlighted = false
}
if isHighlighted, canInteract {
titleLabel.textColor = NSColor.selectedMenuItemTextColor
} else {
titleLabel.textColor = canInteract ? NSColor.labelColor : NSColor.disabledControlTextColor
}
if isLoading { if isLoading {
progressIndicator.startAnimation(nil) progressIndicator.startAnimation(nil)
} else { } else {
progressIndicator.stopAnimation(nil) progressIndicator.stopAnimation(nil)
} }
needsDisplay = true
} }
@objc private func buttonClicked() { override func updateTrackingAreas() {
if let trackingArea {
removeTrackingArea(trackingArea)
}
let area = NSTrackingArea(
rect: bounds,
options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
owner: self,
userInfo: nil
)
addTrackingArea(area)
trackingArea = area
super.updateTrackingAreas()
}
override func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
guard isEnabled, !isLoading else { return }
isHighlighted = true
titleLabel.textColor = NSColor.selectedMenuItemTextColor
needsDisplay = true
}
override func mouseExited(with event: NSEvent) {
super.mouseExited(with: event)
guard isEnabled, !isLoading else { return }
isHighlighted = false
titleLabel.textColor = NSColor.labelColor
needsDisplay = true
}
override func mouseDown(with _: NSEvent) {
guard isEnabled, !isLoading else { return }
isHighlighted = true
titleLabel.textColor = NSColor.selectedMenuItemTextColor
needsDisplay = true
}
override func mouseUp(with event: NSEvent) {
guard isEnabled, !isLoading else { return }
let point = convert(event.locationInWindow, from: nil)
if bounds.contains(point) {
action() action()
} }
isHighlighted = false
titleLabel.textColor = NSColor.labelColor
needsDisplay = true
}
override func draw(_ dirtyRect: NSRect) {
if isHighlighted {
NSColor.selectedMenuItemColor.setFill()
dirtyRect.fill()
} else {
NSColor.clear.setFill()
dirtyRect.fill()
}
super.draw(dirtyRect)
}
} }
// MARK: - StatusBarHeaderView // MARK: - StatusBarHeaderView