Keep group submenu open during URLTest updates
This commit is contained in:
@@ -2881,14 +2881,7 @@
|
|||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
},
|
},
|
||||||
"URLTest" : {
|
"URLTest" : {
|
||||||
"localizations" : {
|
"shouldTranslate" : false
|
||||||
"zh-Hans" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "URL 测试"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"User" : {
|
"User" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateURLTestAvailability()
|
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,16 +662,81 @@ 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() {
|
||||||
action()
|
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()
|
||||||
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user