326 lines · 13.3 KB · Swift
RawDownload
import SwiftUI /// Static progress fill with no implicit animations, used inside the menu card.struct UsageProgressBar: View {    enum MarkerKind: Equatable {        case quotaWarning        case workdayBoundary    }     struct Marker: Equatable {        let percent: Double        let kind: MarkerKind    }     private static let paceStripeCount = 3    private static let stripePunchOpacity = 0.9     private nonisolated static var warningMarkerPunchWidth: CGFloat {        5    }     private nonisolated static var warningMarkerStripeWidth: CGFloat {        1    }     private static func paceStripeWidth(for scale: CGFloat) -> CGFloat {        2    }     private static func paceStripeSpan(for scale: CGFloat) -> CGFloat {        let stripeCount = max(1, Self.paceStripeCount)        return Self.paceStripeWidth(for: scale) * CGFloat(stripeCount)    }     let percent: Double    let tint: Color    let accessibilityLabel: String    let pacePercent: Double?    let paceOnTop: Bool    let warningMarkerPercents: [Double]    let workdayMarkerPercents: [Double]    @Environment(\.menuItemHighlighted) private var isHighlighted    @Environment(\.displayScale) private var displayScale     init(        percent: Double,        tint: Color,        accessibilityLabel: String,        pacePercent: Double? = nil,        paceOnTop: Bool = true,        warningMarkerPercents: [Double] = [],        workdayMarkerPercents: [Double] = [])    {        self.percent = percent        self.tint = tint        self.accessibilityLabel = accessibilityLabel        self.pacePercent = pacePercent        self.paceOnTop = paceOnTop        self.warningMarkerPercents = warningMarkerPercents        self.workdayMarkerPercents = workdayMarkerPercents    }     private var clamped: Double {        min(100, max(0, self.percent))    }     var body: some View {        // Draw the entire progress bar — track, fill, and pace-tip punch-out — in a single Canvas.        // A single Canvas uses Core Graphics internally and avoids the SwiftUI compositing modifiers        // (.compositingGroup, .blendMode) that trigger Metal/RenderBox shader compilation on macOS 26.x,        // which caused the status item icon to disappear (issue #805).        Canvas { context, size in            let scale = max(self.displayScale, 1)            let fillPercent = Self.renderedFillPercent(self.clamped)            let fillWidth = size.width * fillPercent / 100            let paceWidth = size.width * Self.clampedPercent(self.pacePercent) / 100            let tipWidth = max(25, size.height * 6.5)            let stripeInset = 1 / scale            let tipOffset = paceWidth - tipWidth + (Self.paceStripeSpan(for: scale) / 2) + stripeInset            let showTip = self.pacePercent != nil && tipWidth > 0.5            let markers = Self.resolvedMarkers(                warningPercents: self.warningMarkerPercents,                workdayPercents: self.workdayMarkerPercents)             let cornerRadius = size.height / 2            let cornerSize = CGSize(width: cornerRadius, height: cornerRadius)            let rect = CGRect(origin: .zero, size: size)             context.clip(to: Path(rect))             // Track            let trackPath = Path { p in p.addRoundedRect(in: rect, cornerSize: cornerSize) }            context.fill(trackPath, with: .color(MenuHighlightStyle.progressTrack(self.isHighlighted)))             // Fill            if fillWidth > 0 {                let fillRect = CGRect(x: 0, y: 0, width: min(fillWidth, size.width), height: size.height)                let fillPath = Path { p in p.addRoundedRect(in: fillRect, cornerSize: cornerSize) }                context.fill(                    fillPath,                    with: .color(MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint)))            }             for marker in markers {                let x = size.width * marker.percent / 100                switch marker.kind {                case .quotaWarning:                    let markerRect = Self.warningMarkerRect(x: x, size: size, scale: scale)                    let markerStripeRect = Self.warningMarkerStripeRect(markerRect, scale: scale)                    let markerPunchPath = Path { p in                        p.addRect(Self.extendedMarkerRect(markerRect, size: size))                    }                    let markerStripePath = Path { p in                        p.addRect(Self.extendedMarkerRect(markerStripeRect, size: size))                    }                     // Match the pace stripe treatment: punch through the bar, then draw a slimmer neutral stripe.                    context.blendMode = .destinationOut                    context.fill(markerPunchPath, with: .color(.white.opacity(Self.stripePunchOpacity)))                    context.blendMode = .normal                    context.fill(                        markerStripePath,                        with: .color(Self.warningMarkerColor(isHighlighted: self.isHighlighted)))                case .workdayBoundary:                    let markerRect = Self.workdayMarkerRect(x: x, size: size, scale: scale)                    context.fill(                        Path(markerRect),                        with: .color(Self.workdayMarkerColor(isHighlighted: self.isHighlighted)))                }            }             // Pace tip: punch-out + center stripe drawn within the canvas context using Core Graphics            // blend modes so no SwiftUI compositing modifier (.blendMode, .compositingGroup) is needed.            if showTip {                let isDeficit = self.paceOnTop == false                let useDeficitRed = isDeficit && self.isHighlighted == false                let stripeColor: Color = if self.isHighlighted {                    .white                } else if useDeficitRed {                    .red                } else {                    .green                }                 let tipSize = CGSize(width: tipWidth, height: size.height)                let stripes = Self.paceStripePaths(size: tipSize, scale: scale)                let shift = CGAffineTransform(translationX: tipOffset, y: 0)                 // Punch out of the accumulated track+fill pixels.                context.blendMode = .destinationOut                context.fill(stripes.punched.applying(shift), with: .color(.white.opacity(Self.stripePunchOpacity)))                context.blendMode = .normal                 context.fill(stripes.center.applying(shift), with: .color(stripeColor))            }        }        .frame(height: 6)        .accessibilityLabel(self.accessibilityLabel)        .accessibilityValue(self.markerAccessibilityValue)    }     private var markerAccessibilityValue: String {        var parts = [L("%d percent", Self.displayPercent(self.clamped))]        let markers = Self.resolvedMarkers(            warningPercents: self.warningMarkerPercents,            workdayPercents: self.workdayMarkerPercents)        let warnings = markers.filter { $0.kind == .quotaWarning }.map(Self.markerPercentText)        let workdays = markers.filter { $0.kind == .workdayBoundary }.map(Self.markerPercentText)        if !warnings.isEmpty {            parts.append("\(L("quota_warnings_title")): \(warnings.joined(separator: ", "))")        }        if !workdays.isEmpty {            parts.append("\(L("weekly_progress_work_days_title")): \(workdays.joined(separator: ", "))")        }        return parts.joined(separator: ". ")    }     nonisolated static func resolvedMarkers(        warningPercents: [Double],        workdayPercents: [Double]) -> [Marker]    {        let warnings = Self.normalizedMarkerPercents(warningPercents)        let workdays = Self.normalizedMarkerPercents(workdayPercents)            .filter { workday in !warnings.contains { abs($0 - workday) < 0.001 } }        return (            warnings.map { Marker(percent: $0, kind: .quotaWarning) } +                workdays.map { Marker(percent: $0, kind: .workdayBoundary) })            .sorted { lhs, rhs in lhs.percent < rhs.percent }    }     private nonisolated static func normalizedMarkerPercents(_ values: [Double]) -> [Double] {        values            .map(self.clampedPercent)            .filter { $0 > 0 && $0 < 100 }            .reduce(into: [Double]()) { result, value in                if !result.contains(where: { abs($0 - value) < 0.001 }) {                    result.append(value)                }            }    }     private nonisolated static func markerPercentText(_ marker: Marker) -> String {        "\(Int(marker.percent.rounded()))%"    }     /// Aligns edge rendering with the rounded percent label: sub-0.5% is empty and 99.5%+ is full.    nonisolated static func renderedFillPercent(_ percent: Double) -> Double {        let clamped = Self.clampedPercent(percent)        let displayPercent = Self.displayPercent(clamped)        if displayPercent <= 0 { return 0 }        if displayPercent >= 100 { return 100 }        return clamped    }     private static func paceStripePaths(size: CGSize, scale: CGFloat) -> (punched: Path, center: Path) {        let rect = CGRect(origin: .zero, size: size)        let extend = size.height * 2        let stripeTopY: CGFloat = -extend        let stripeBottomY: CGFloat = size.height + extend        let align: (CGFloat) -> CGFloat = { value in            (value * scale).rounded() / scale        }         let stripeWidth = Self.paceStripeWidth(for: scale)        let punchWidth = stripeWidth * 3        let stripeInset = 1 / scale        let stripeAnchorX = align(rect.maxX - stripeInset)        let stripeMinY = align(stripeTopY)        let stripeMaxY = align(stripeBottomY)        let anchorTopX = stripeAnchorX        var punchedStripe = Path()        var centerStripe = Path()        let availableWidth = (anchorTopX - punchWidth) - rect.minX        guard availableWidth >= 0 else { return (punchedStripe, centerStripe) }         let punchRightTopX = align(anchorTopX)        let punchLeftTopX = punchRightTopX - punchWidth        let punchRightBottomX = punchRightTopX        let punchLeftBottomX = punchLeftTopX        punchedStripe.addPath(Path { path in            path.move(to: CGPoint(x: punchLeftTopX, y: stripeMinY))            path.addLine(to: CGPoint(x: punchRightTopX, y: stripeMinY))            path.addLine(to: CGPoint(x: punchRightBottomX, y: stripeMaxY))            path.addLine(to: CGPoint(x: punchLeftBottomX, y: stripeMaxY))            path.closeSubpath()        })         let centerLeftTopX = align(punchLeftTopX + (punchWidth - stripeWidth) / 2)        let centerRightTopX = centerLeftTopX + stripeWidth        let centerRightBottomX = centerRightTopX        let centerLeftBottomX = centerLeftTopX        centerStripe.addPath(Path { path in            path.move(to: CGPoint(x: centerLeftTopX, y: stripeMinY))            path.addLine(to: CGPoint(x: centerRightTopX, y: stripeMinY))            path.addLine(to: CGPoint(x: centerRightBottomX, y: stripeMaxY))            path.addLine(to: CGPoint(x: centerLeftBottomX, y: stripeMaxY))            path.closeSubpath()        })         return (punchedStripe, centerStripe)    }     nonisolated static func warningMarkerRect(x: CGFloat, size: CGSize, scale rawScale: CGFloat) -> CGRect {        let scale = max(rawScale, 1)        let width = Self.warningMarkerPunchWidth        let align: (CGFloat) -> CGFloat = { value in            (value * scale).rounded() / scale        }         return CGRect(            x: align(x - width / 2),            y: 0,            width: width,            height: align(size.height))    }     nonisolated static func warningMarkerStripeRect(_ markerRect: CGRect, scale rawScale: CGFloat) -> CGRect {        let scale = max(rawScale, 1)        let width = min(markerRect.width, max(1 / scale, Self.warningMarkerStripeWidth))        let align: (CGFloat) -> CGFloat = { value in            (value * scale).rounded() / scale        }         return CGRect(            x: align(markerRect.midX - width / 2),            y: markerRect.minY,            width: width,            height: markerRect.height)    }     nonisolated static func workdayMarkerRect(x: CGFloat, size: CGSize, scale rawScale: CGFloat) -> CGRect {        let scale = max(rawScale, 1)        let width = 1 / scale        let height = max(1 / scale, size.height * 0.5)        let align: (CGFloat) -> CGFloat = { value in            (value * scale).rounded() / scale        }        return CGRect(            x: align(x - width / 2),            y: align(size.height - height),            width: width,            height: align(height))    }     private nonisolated static func extendedMarkerRect(_ rect: CGRect, size: CGSize) -> CGRect {        let extend = size.height * 2        return rect.insetBy(dx: 0, dy: -extend)    }     nonisolated static func warningMarkerColor(isHighlighted: Bool) -> Color {        isHighlighted ? .white.opacity(0.96) : .primary.opacity(0.68)    }     nonisolated static func workdayMarkerColor(isHighlighted: Bool) -> Color {        isHighlighted ? .white.opacity(0.55) : .primary.opacity(0.30)    }     private nonisolated static func displayPercent(_ percent: Double) -> Int {        Int(self.clampedPercent(percent).rounded())    }     private nonisolated static func clampedPercent(_ value: Double?) -> Double {        guard let value else { return 0 }        return min(100, max(0, value))    }}