Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 21 additions & 35 deletions apps/native/Sources/VisionNativeUI/Components/AuroraCanvas.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,14 @@ struct AuroraCanvas: View {
}
}

/// `radial-gradient(120rem 60rem at 70% -20%, primary / wash, transparent 60%)`.
/// `radial-gradient(120rem 60rem at 70% -20%, primary / wash, transparent 60%)`: an ellipse of
/// 1920 by 960 pt radii that is clear from 60 % of them. `EllipticalGradient` counts its
/// radius fractions against the frame's width and height (0.5 reaches the edge), so the
/// 3840 by 1920 pt frame ends the fade at 0.3.
private func wash(_ size: CGSize) -> some View {
EllipticalGradient(
colors: [theme.primary.opacity(alphas.wash), .clear], center: .center, startRadiusFraction: 0,
endRadiusFraction: 0.6
colors: [theme.primary.opacity(alphas.wash), theme.primary.opacity(0)], center: .center,
startRadiusFraction: 0, endRadiusFraction: 0.3
)
.frame(width: 1920 * 2, height: 960 * 2)
.position(x: size.width * 0.7, y: -size.height * 0.2)
Expand Down Expand Up @@ -253,7 +256,11 @@ private struct AuroraBlobs: NSViewRepresentable {
}
}

/// `.liquid-canvas-grain`: a tiled 160×160 fractal-noise texture.
/// `.liquid-canvas-grain`: a tiled 160 by 160 film grain. The web tile is an SVG
/// `feTurbulence` (`fractalNoise`, base frequency 0.8, three octaves): at that frequency a noise
/// cell is about one pixel, so the tile reads as fine per-pixel grain with every channel,
/// alpha included, noisy around its middle value. Three octaves of white noise, weighted like
/// the turbulence sum, give the same grain; the pixels are premultiplied.
private struct AuroraGrain: View {
private static let tile: Image? = {
let side = 160
Expand All @@ -262,43 +269,22 @@ private struct AuroraGrain: View {
state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
return Double(state >> 33) / Double(UInt64(1) << 31)
}
var lattices: [[Double]] = []
for octave in 0..<3 {
let cells = 8 << octave
lattices.append((0..<(cells * cells)).map { _ in next() })
/// One channel: 0.5 plus the octave sum (amplitudes 1, 1/2, 1/4), halved as `fractalNoise` does.
func channel() -> Double {
let sum = (next() - 0.5) + (next() - 0.5) / 2 + (next() - 0.5) / 4
return max(0, min(1, 0.5 + sum / 2))
}
var pixels = [UInt8](repeating: 0, count: side * side * 4)
for y in 0..<side {
for x in 0..<side {
var value = 0.0
var amplitude = 0.5
for (octave, lattice) in lattices.enumerated() {
let cells = 8 << octave
let fx = Double(x) / Double(side) * Double(cells)
let fy = Double(y) / Double(side) * Double(cells)
let x0 = Int(fx) % cells
let y0 = Int(fy) % cells
let x1 = (x0 + 1) % cells
let y1 = (y0 + 1) % cells
let tx = fx - floor(fx)
let ty = fy - floor(fy)
let top = lattice[y0 * cells + x0] * (1 - tx) + lattice[y0 * cells + x1] * tx
let bottom = lattice[y1 * cells + x0] * (1 - tx) + lattice[y1 * cells + x1] * tx
value += amplitude * (top * (1 - ty) + bottom * ty)
amplitude *= 0.5
}
let level = UInt8(max(0, min(255, value / 0.875 * 255)))
let index = (y * side + x) * 4
pixels[index] = level
pixels[index + 1] = level
pixels[index + 2] = level
pixels[index + 3] = 255
}
for index in 0..<(side * side) {
let alpha = channel()
for offset in 0..<3 { pixels[index * 4 + offset] = UInt8((channel() * alpha * 255).rounded()) }
pixels[index * 4 + 3] = UInt8((alpha * 255).rounded())
}
guard let provider = CGDataProvider(data: Data(pixels) as CFData),
let image = CGImage(
width: side, height: side, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: side * 4,
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue),
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent)
else { return nil }
return Image(decorative: image, scale: 1)
Expand Down
111 changes: 111 additions & 0 deletions apps/native/Sources/VisionNativeUI/Components/ChartHoverTooltip.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import SwiftUI
import VisionNativeCore

/// One line of a chart tooltip: the series swatch, its label and its formatted value.
struct ChartTooltipItem: Identifiable, Equatable {
let id: String
let label: String
let value: String
var color: Color?
}

/// `ChartTooltip` (components/charts/ChartTooltip.tsx): the floating readout of the hovered
/// category. It sits centred above its anchor with a 12 pt gap, moves beside the anchor when it
/// would leave the chart sideways and below it when there is no room above. `--popover` with the
/// elevation shadow; `glass-thick` under Full effects.
///
/// Place it in a chart overlay that spans `bounds`, with `anchor` in the overlay's coordinates.
struct ChartHoverTooltip: View {
let title: String?
let items: [ChartTooltipItem]
let anchor: CGPoint
/// The chart box the tooltip stays within sideways.
let bounds: CGSize
@State private var size: CGSize = .zero
@Environment(\.visionTheme) private var theme
@Environment(\.visionFullEffects) private var fullEffects

private static let gap: CGFloat = 12
private static let pad: CGFloat = 8

/// `computePosition`: where the tooltip's top-left corner goes for its measured size.
static func origin(anchor: CGPoint, size: CGSize, bounds: CGSize, roomAbove: CGFloat) -> CGPoint {
var x = anchor.x - size.width / 2
var y = anchor.y - size.height - gap
var side = false
if x + size.width > bounds.width - pad {
side = true
x = anchor.x - size.width - gap
y = anchor.y - size.height / 2
} else if x < pad {
side = true
x = anchor.x + gap
y = anchor.y - size.height / 2
}
// No room above the chart: drop below the anchor.
if !side, y < -roomAbove { y = anchor.y + gap }
if x + size.width > bounds.width - pad { x = max(pad, bounds.width - size.width - pad) }
if x < pad { x = pad }
return CGPoint(x: x, y: y)
}

var body: some View {
let origin = Self.origin(anchor: anchor, size: size, bounds: bounds, roomAbove: 160)
VStack(alignment: .leading, spacing: 6) {
if let title, !title.isEmpty {
Text(title)
.font(.caption2.weight(.medium))
.tracking(1.3)
.textCase(.uppercase)
.foregroundStyle(Color(token: theme.palette.mutedForeground).opacity(0.8))
}
VStack(alignment: .leading, spacing: 4) {
ForEach(items) { item in
HStack(spacing: 12) {
HStack(spacing: 8) {
if let color = item.color {
Circle().fill(color).frame(width: 8, height: 8).accessibilityHidden(true)
}
Text(item.label).foregroundStyle(theme.foreground.opacity(0.8))
}
Spacer(minLength: 0)
Text(item.value).fontWeight(.medium).monospacedDigit().foregroundStyle(theme.foreground)
}
.lineLimit(1)
}
}
}
.font(.footnote)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(minWidth: 140, maxWidth: 260, alignment: .leading)
.fixedSize()
.background(surface)
.onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 }
.offset(x: origin.x, y: origin.y)
// Unmeasured, it would flash at the wrong place for a frame.
.opacity(size == .zero ? 0 : 1)
.frame(width: bounds.width, height: bounds.height, alignment: .topLeading)
.allowsHitTesting(false)
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.updatesFrequently)
}

@ViewBuilder
private var surface: some View {
let radius = DesignTokens.Radius.card
let shape = RoundedRectangle(cornerRadius: radius, style: .continuous)
if fullEffects {
VisionGlassSurface(material: .thick, cornerRadius: radius)
} else {
// `bg-popover shadow-elevation-3`: `0 4px 8px shadow/0.06` (dark 0.35) and the wide
// ambient layer, whose negative spread SwiftUI cannot draw, at a third of its alpha.
shape.fill(theme.popover)
.overlay(shape.strokeBorder(theme.border.opacity(0.6), lineWidth: 1))
.shadow(
color: Color(token: theme.palette.glassShadow, opacity: theme.isDark ? 0.35 : 0.06), radius: 4, y: 4)
.shadow(
color: Color(token: theme.palette.glassShadow, opacity: theme.isDark ? 0.25 : 0.1), radius: 14, y: 12)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,6 @@ struct NativeSidebarRail: View {
.font(.system(size: 15))
.foregroundStyle(Color(token: theme.glass.icon))
.frame(width: Self.buttonSize, height: Self.buttonSize)
.background(
RoundedRectangle(cornerRadius: DesignTokens.Radius.chip)
.fill(
isActive
? Color(token: theme.glass.selection, opacity: theme.glass.selectionOpacity)
: Color.clear)
)
.overlay(alignment: .topTrailing) { countPill(badge) }
// The update-ready dot, collapsed: `h-[7px] w-[7px]` primary at `right-1 top-1`.
.overlay(alignment: .topTrailing) {
Expand All @@ -177,7 +170,7 @@ struct NativeSidebarRail: View {
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(SidebarRowButtonStyle(isActive: isActive))
.help(tooltip)
.accessibilityLabel(label)
.accessibilityValue(badgeLabel)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ struct VisionCardBackground: View {
}
}

/// `glass-thin`'s surface for `VisionCardLayers`. SwiftUI shades a view's shadow by the view's
/// own alpha, so the shadows are scaled by the surface's alpha at the bottom edge, where they show.
/// `glass-thin`'s surface for `VisionCardLayers`. The layers cut the card's shape out of its
/// shadows, as CSS does for a `box-shadow`, so the shadows keep their token alphas.
private func layerStyle(frame: Color, highlightAlpha: Double, lift: ShadowLayer?, liftAlpha: Double)
-> VisionCardLayerStyle
{
Expand All @@ -114,11 +114,11 @@ struct VisionCardBackground: View {
edgeBottom: NSColor(Color(token: palette.glassShadow, opacity: dark ? 0 : 0.05)).cgColor,
frame: NSColor(frame).cgColor,
shadow: VisionCardShadow(
color: NSColor(Color(token: palette.glassShadow, opacity: (dark ? 0.2 : 0.04) * bottomAlpha)).cgColor,
color: NSColor(Color(token: palette.glassShadow, opacity: dark ? 0.2 : 0.04)).cgColor,
radius: dark ? 8 : 6, x: 0, y: dark ? 5 : 4),
lift: interactive
? VisionCardShadow(
color: NSColor(Color(token: palette.glassShadow, opacity: liftAlpha * bottomAlpha)).cgColor,
color: NSColor(Color(token: palette.glassShadow, opacity: liftAlpha)).cgColor,
radius: CGFloat((lift?.blur ?? 0) / 2), x: CGFloat(lift?.x ?? 0), y: CGFloat(lift?.y ?? 0))
: nil,
transitionDuration: hoverAnimation == nil ? nil : DesignTokens.Duration.normal,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ struct VisionCardLayerStyle: Equatable {
private let host = CALayer()
private let shadowLayer = CALayer()
private let liftLayer = CALayer()
/// Holds the two shadows and cuts the card's own shape out of them: a CSS `box-shadow` is
/// not painted under its box, so the translucent surface stays as clear as the web card's.
private let shadowHost = CALayer()
private let shadowMask = CAShapeLayer()
private let fillLayer = CAGradientLayer()
private let edgeLayer = CAGradientLayer()
private let edgeMask = CAShapeLayer()
Expand All @@ -57,10 +61,15 @@ struct VisionCardLayerStyle: Equatable {
layer = host
wantsLayer = true
host.masksToBounds = false
for shadow in [shadowLayer, liftLayer] {
shadow.shadowOpacity = 1
host.addSublayer(shadow)
}
shadowMask.fillRule = .evenOdd
shadowMask.fillColor = CGColor(gray: 0, alpha: 1)
shadowHost.mask = shadowMask
host.addSublayer(shadowHost)
shadowLayer.shadowOpacity = 1
// No colour is not "no shadow" to Core Animation (it falls back to opaque black), so
// the lift stays off until a card has one.
liftLayer.shadowOpacity = 0
for shadow in [shadowLayer, liftLayer] { shadowHost.addSublayer(shadow) }
for gradient in [fillLayer, edgeLayer] {
gradient.startPoint = CGPoint(x: 0.5, y: 1)
gradient.endPoint = CGPoint(x: 0.5, y: 0)
Expand Down Expand Up @@ -89,7 +98,7 @@ struct VisionCardLayerStyle: Equatable {
override func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()
let scale = window?.backingScaleFactor ?? 2
for layer in [host, shadowLayer, liftLayer, fillLayer, edgeLayer, edgeMask, frameLayer] {
for layer in [host, shadowHost, shadowMask, shadowLayer, liftLayer, fillLayer, edgeLayer, edgeMask, frameLayer] {
layer.contentsScale = scale
}
}
Expand All @@ -114,8 +123,9 @@ struct VisionCardLayerStyle: Equatable {
Self.setShadow(shadowLayer, style.shadow)
if let lift = style.lift {
Self.setShadow(liftLayer, lift)
liftLayer.shadowOpacity = 1
} else {
liftLayer.shadowColor = nil
liftLayer.shadowOpacity = 0
}
CATransaction.commit()
needsLayout = true
Expand All @@ -134,6 +144,14 @@ struct VisionCardLayerStyle: Equatable {
}
shadowLayer.shadowPath = outline
liftLayer.shadowPath = outline
// Everything around the card, far enough out for the widest shadow, minus the card.
let reach = bounds.insetBy(dx: -Self.shadowReach, dy: -Self.shadowReach)
let around = CGMutablePath()
around.addRect(reach)
around.addPath(outline)
shadowHost.frame = bounds
shadowMask.frame = bounds
shadowMask.path = around
fillLayer.cornerRadius = radius
frameLayer.cornerRadius = radius
// `shape.inset(by: 1).strokeBorder(…, lineWidth: 1)`: a 1 pt line just inside 1 pt of inset.
Expand All @@ -142,6 +160,9 @@ struct VisionCardLayerStyle: Equatable {
CATransaction.commit()
}

/// How far outside the card a shadow may paint (offset plus blur of the largest lift).
private static let shadowReach: CGFloat = 96

/// SwiftUI's `.shadow(color:radius:x:y:)`: y grows downward there and upward here.
private static func setShadow(_ layer: CALayer, _ shadow: VisionCardShadow) {
layer.shadowColor = shadow.color
Expand Down
Loading
Loading