221 lines · 9.7 KB · Swift
RawDownload
import AppKit /// Pre-harvest snapshot of one live content row, captured before card views are detached/// into the recycle pool so reconciliation can still compare row shapes afterwards.struct MenuRowShape {    let isSeparator: Bool    let requiresNativeImageReplacement: Bool    let id: String?    let viewClassName: String?} extension StatusItemController {    func menuContentShapes(in menu: NSMenu, fromIndex: Int) -> [MenuRowShape] {        guard fromIndex >= 0, fromIndex <= menu.items.count else { return [] }        return menu.items[fromIndex...].map { item in            MenuRowShape(                isSeparator: item.isSeparatorItem,                requiresNativeImageReplacement: self.shouldReplaceNativeImageItemDuringReconcile(item),                id: item.representedObject as? String,                viewClassName: item.view.map { String(describing: type(of: $0)) })        }    }     /// Identifies leaf AppKit image items that should be replaced instead of updated in place.    ///    /// AppKit can retain stale layout state for standard image-backed menu items after repeated    /// in-place updates, which makes rows such as "Status Page" drift horizontally. Submenu rows    /// stay on the normal reconciliation path because replacing their parent item can disturb an    /// active submenu.    private func shouldReplaceNativeImageItemDuringReconcile(_ item: NSMenuItem) -> Bool {        !item.isSeparatorItem && item.view == nil && item.image != nil && item.submenu == nil    }     /// Position-wise in-place reconciliation: live rows whose shape matches the freshly    /// built content (separator placement, card identifier, view class) are updated in    /// place — views transplanted, plain rows recopied — and only the mismatched middle    /// span is removed and reinserted. Matching runs from both ends, so the expensive card    /// rows at the top and the shared action rows at the bottom survive even a provider    /// switch whose middle sections differ; AppKit then relayouts the open tracked menu for    /// the few changed rows instead of once per row.    func reconcileMenuContent(        _ menu: NSMenu,        fromIndex: Int,        shapes: [MenuRowShape],        with scratch: NSMenu)    {        defer { self.finishReconciledHighlightTracking(in: menu) }        let newItems = scratch.items        scratch.removeAllItems()        guard menu.items.count - fromIndex == shapes.count else {            // The live region changed underneath the snapshot; replace it wholesale.            self.replaceMenuContent(menu, fromIndex: fromIndex, with: newItems)            return        }         func updatable(_ shape: MenuRowShape, _ newItem: NSMenuItem) -> Bool {            guard shape.isSeparator == newItem.isSeparatorItem else { return false }            if shape.isSeparator { return true }            guard !shape.requiresNativeImageReplacement,                  !self.shouldReplaceNativeImageItemDuringReconcile(newItem)            else { return false }            guard shape.id == newItem.representedObject as? String else { return false }            return shape.viewClassName == newItem.view.map { String(describing: type(of: $0)) }        }         var prefix = 0        while prefix < min(shapes.count, newItems.count), updatable(shapes[prefix], newItems[prefix]) {            prefix += 1        }        var suffix = 0        while suffix < min(shapes.count, newItems.count) - prefix,              updatable(shapes[shapes.count - 1 - suffix], newItems[newItems.count - 1 - suffix])        {            suffix += 1        }         for offset in 0..<prefix {            self.updateMenuItemInPlace(menu.items[fromIndex + offset], from: newItems[offset])        }        for offset in 0..<suffix {            self.updateMenuItemInPlace(                menu.items[menu.items.count - 1 - offset],                from: newItems[newItems.count - 1 - offset])        }         let liveMiddleCount = shapes.count - prefix - suffix        let insertionIndex = fromIndex + prefix        for _ in 0..<liveMiddleCount {            menu.removeItem(at: insertionIndex)        }        let newMiddle = newItems[prefix..<(newItems.count - suffix)]        for (offset, item) in newMiddle.enumerated() {            menu.insertItem(item, at: insertionIndex + offset)        }    }     /// Replaces cached content without first emptying the tracked menu. Compatible item shells    /// stay attached while their payloads swap; only separator or row-count differences cause    /// structural mutations.    func replaceMenuContentKeepingRowsVisible(        _ menu: NSMenu,        fromIndex: Int,        with newItems: [NSMenuItem])        -> [NSMenuItem]    {        guard fromIndex >= 0, fromIndex <= menu.items.count else { return [] }        defer { self.finishReconciledHighlightTracking(in: menu) }         let liveItems = Array(menu.items[fromIndex...])        let liveCount = liveItems.count        let sharedCount = min(liveCount, newItems.count)        var displacedItems: [NSMenuItem] = []        displacedItems.reserveCapacity(liveCount)        for offset in 0..<sharedCount {            let index = fromIndex + offset            let liveItem = liveItems[offset]            let newItem = newItems[offset]            let requiresNativeImageReplacement =                self.shouldReplaceNativeImageItemDuringReconcile(liveItem) ||                self.shouldReplaceNativeImageItemDuringReconcile(newItem)            if liveItem.isSeparatorItem == newItem.isSeparatorItem, !requiresNativeImageReplacement {                if !liveItem.isSeparatorItem {                    self.swapMenuItemContents(liveItem, newItem)                }                displacedItems.append(newItem)            } else {                menu.insertItem(newItem, at: index)                menu.removeItem(liveItem)                displacedItems.append(liveItem)            }        }        if newItems.count > liveCount {            for offset in liveCount..<newItems.count {                menu.insertItem(newItems[offset], at: fromIndex + offset)            }        } else if liveCount > newItems.count {            for offset in newItems.count..<liveCount {                menu.removeItem(liveItems[offset])                displacedItems.append(liveItems[offset])            }        }        return displacedItems    }     private func finishReconciledHighlightTracking(in menu: NSMenu) {        let menuKey = ObjectIdentifier(menu)        guard let highlightedItem = self.highlightedMenuItems[menuKey] else { return }        guard highlightedItem.menu === menu else {            self.highlightedMenuItems.removeValue(forKey: menuKey)            (highlightedItem.view as? MenuCardHighlighting)?.setHighlighted(false)            return        }        guard highlightedItem.isEnabled,              (highlightedItem.view as? MenuCardHighlighting)?.allowsMenuHighlight != false        else {            self.highlightedMenuItems.removeValue(forKey: menuKey)            (highlightedItem.view as? MenuCardHighlighting)?.setHighlighted(false)            return        }        (highlightedItem.view as? MenuCardHighlighting)?.setHighlighted(true)    }     private func replaceMenuContent(_ menu: NSMenu, fromIndex: Int, with newItems: [NSMenuItem]) {        while menu.items.count > fromIndex {            menu.removeItem(at: fromIndex)        }        for item in newItems {            menu.addItem(item)        }    }     private func updateMenuItemInPlace(_ liveItem: NSMenuItem, from newItem: NSMenuItem) {        if liveItem.isSeparatorItem { return }        let remainsHighlighted = liveItem.menu.map {            self.highlightedMenuItems[ObjectIdentifier($0)] === liveItem        } ?? false        // Detach from the scratch item first so a view or submenu is never referenced by        // two menu items at once.        let view = newItem.view        newItem.view = nil        let submenu = newItem.submenu        newItem.submenu = nil        liveItem.view = view        liveItem.submenu = submenu        liveItem.title = newItem.title        liveItem.attributedTitle = newItem.attributedTitle        liveItem.action = newItem.action        liveItem.target = newItem.target        liveItem.representedObject = newItem.representedObject        liveItem.state = newItem.state        liveItem.isEnabled = newItem.isEnabled        let allowsHighlight = (view as? MenuCardHighlighting)?.allowsMenuHighlight != false        (view as? MenuCardHighlighting)?.setHighlighted(newItem.isEnabled && allowsHighlight && remainsHighlighted)        liveItem.image = newItem.image        liveItem.toolTip = newItem.toolTip        liveItem.keyEquivalent = newItem.keyEquivalent        liveItem.keyEquivalentModifierMask = newItem.keyEquivalentModifierMask        liveItem.indentationLevel = newItem.indentationLevel        liveItem.tag = newItem.tag        liveItem.identifier = newItem.identifier        liveItem.isHidden = newItem.isHidden        liveItem.isAlternate = newItem.isAlternate        liveItem.allowsKeyEquivalentWhenHidden = newItem.allowsKeyEquivalentWhenHidden        liveItem.onStateImage = newItem.onStateImage        liveItem.offStateImage = newItem.offStateImage        liveItem.mixedStateImage = newItem.mixedStateImage        if #available(macOS 14.4, *) {            liveItem.subtitle = newItem.subtitle        }        if self.isPersistentRefreshItem(liveItem) {            self.persistentRefreshItems.add(liveItem)        }    }     private func swapMenuItemContents(_ liveItem: NSMenuItem, _ cachedItem: NSMenuItem) {        let holder = NSMenuItem()        self.updateMenuItemInPlace(holder, from: liveItem)        self.updateMenuItemInPlace(liveItem, from: cachedItem)        self.updateMenuItemInPlace(cachedItem, from: holder)    }}