327 lines
13 KiB
Swift
327 lines
13 KiB
Swift
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))
|
|
}
|
|
}
|