Skip to content
Merged
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
19 changes: 19 additions & 0 deletions apps/native/Sources/VisionNativeUI/Components/ChartSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,22 @@ enum ChartCurve {
return path
}
}

// MARK: Selection scope

/// Owns a chart's hovered point and compared range. A page that keeps them in its own `@State`
/// re-runs its whole body (every card, grid and chart) each time the pointer crosses to another
/// point; held here, the hover re-renders only what `content` builds.
struct ChartSelectionScope<Content: View>: View {
@State private var selection: String?
@State private var rangeStart: String?
private let content: (Binding<String?>, Binding<String?>) -> Content

init(@ViewBuilder content: @escaping (Binding<String?>, Binding<String?>) -> Content) {
self.content = content
}

var body: some View {
content($selection, $rangeStart)
}
}
41 changes: 37 additions & 4 deletions apps/native/Sources/VisionNativeUI/Components/LocalDataTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -409,10 +409,8 @@ struct LocalDataTable<Row: Identifiable, Empty: View>: View {
// MARK: Rows

private func rowView(_ row: Row) -> some View {
// `TableRow`'s `hover:bg-foreground/[0.03]`; the hover state stays in the row.
HoverTracking { hovered in
rowContent(row).background(theme.foreground.opacity(hovered ? 0.03 : 0))
}
// `TableRow`'s `hover:bg-foreground/[0.03]`; only the band redraws under the pointer.
RowHoverBand { rowContent(row) }
}

private func rowContent(_ row: Row) -> some View {
Expand Down Expand Up @@ -455,6 +453,41 @@ struct LocalDataTable<Row: Identifiable, Empty: View>: View {
}
}

/// Whether the pointer is over a view. Only the view that draws the hover reads it.
@Observable
final class HoverFlag {
var inside = false
}

/// `hover:bg-foreground/[0.03]` behind its content. The content is built once by the caller and
/// the hover state is read by the fill alone, so the pointer entering or leaving a row redraws
/// one rectangle and never rebuilds the row's cells.
struct RowHoverBand<Content: View>: View {
@State private var flag = HoverFlag()
private let content: Content

init(@ViewBuilder content: () -> Content) {
self.content = content()
}

var body: some View {
content
.background(RowHoverFill(flag: flag))
.onHover { inside in
if flag.inside != inside { flag.inside = inside }
}
}
}

private struct RowHoverFill: View {
let flag: HoverFlag
@Environment(\.visionTheme) private var theme

var body: some View {
theme.foreground.opacity(flag.inside ? 0.03 : 0)
}
}

/// Hands its content whether the pointer is over it. The hover state belongs to this small view,
/// so a pointer moving over a header never invalidates the table that contains it.
struct HoverTracking<Content: View>: View {
Expand Down
16 changes: 15 additions & 1 deletion apps/native/Sources/VisionNativeUI/Components/NativeTopbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,22 @@ struct NativeTopbar: View {
/// The page header's title (`usePageTitle`); the sidebar item's title when the page has none.
var pageTitle: String?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@Environment(\.visionVisualEffects) private var visualEffects
@Environment(\.visionTheme) private var theme

/// What the veil's gradient lies over: the blurred page, or the opaque `--card` on the reduced
/// tier and under Reduce Transparency (`:root.fx-reduced .app-topbar` and the
/// `prefers-reduced-transparency` block of index.css set `backdrop-filter: none` and
/// `background: hsl(var(--card))`), so those never blur the page scrolling under the bar.
@ViewBuilder private var veilBackdrop: some View {
if visualEffects == .reduced || reduceTransparency {
Rectangle().fill(theme.card)
} else {
Rectangle().fill(.ultraThinMaterial)
}
}

/// AppLayout's topbar `h-14`: the page content starts below it.
static let barHeight: CGFloat = 56
/// `INLINE_TITLE_THRESHOLD` in AppLayout.
Expand Down Expand Up @@ -60,7 +74,7 @@ struct NativeTopbar: View {
// `.app-topbar::before` (index.css): a `--background` gradient (0.82 → 0.68 light,
// 0.7 → 0.56 dark) over a 16 px backdrop blur, the bottom border (`--border` at 0.5 /
// 0.7), the inset glass highlight and a soft drop shadow; hidden at scroll-top.
Rectangle().fill(.ultraThinMaterial)
veilBackdrop
.overlay(
LinearGradient(
colors: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,8 @@ struct VisionButtonChrome: View {
.foregroundStyle(foreground(resolved, hover: hover))
.padding(.horizontal, metrics.horizontalPadding)
.frame(minWidth: metrics.isIcon ? metrics.height : nil, minHeight: metrics.height)
.background(shape.fill(fill(resolved, hover: hover)))
.background { surface(shape, variant: resolved, hover: hover) }
.overlay(shape.strokeBorder(stroke(resolved), lineWidth: VisionControlMetrics.borderWidth))
.shadow(
color: .black.opacity(hasShadow(resolved) ? VisionControlMetrics.shadowSmAlpha : 0),
radius: VisionControlMetrics.shadowSmRadius, y: VisionControlMetrics.shadowSmY
)
.contentShape(shape)
.visionFocusRing(cornerRadius: VisionControlMetrics.radius)
.opacity(isEnabled ? 1 : VisionControlMetrics.disabledOpacity)
Expand All @@ -167,6 +163,21 @@ struct VisionButtonChrome: View {
variant == .default && configuration.role == .destructive ? .destructive : variant
}

/// The fill, with `shadow-sm` on the variants that have one. The shadow is cast by the fill's
/// shape: on the whole button it was a shadow effect over the label, fill and border of every
/// button, the ghost and outline ones (each ledger row's Edit button) included at alpha 0.
@ViewBuilder
private func surface(_ shape: RoundedRectangle, variant: VisionButtonVariant, hover: Bool) -> some View {
if hasShadow(variant) {
shape.fill(fill(variant, hover: hover))
.shadow(
color: .black.opacity(VisionControlMetrics.shadowSmAlpha),
radius: VisionControlMetrics.shadowSmRadius, y: VisionControlMetrics.shadowSmY)
} else {
shape.fill(fill(variant, hover: hover))
}
}

private func hasShadow(_ variant: VisionButtonVariant) -> Bool {
switch variant {
case .default, .destructive, .accent: return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ struct VisionCardLayerStyle: Equatable {
for layer in shadowLayers { shadowHost.insertSublayer(layer, below: liftLayer) }
}
for (layer, shadow) in zip(shadowLayers, style.shadows) { Self.setShadow(layer, shadow) }
// An opaque fill (the reduced tier, Reduce Transparency) hides the shadow under the card
// by itself. The cut-out mask is an offscreen pass the size of the card on every
// composited frame, so only a translucent card keeps it.
let cutsOut = style.fillColors.contains { $0.alpha < 1 }
if cutsOut != (shadowHost.mask != nil) { shadowHost.mask = cutsOut ? shadowMask : nil }
let fill = Self.premultipliedStops(colors: style.fillColors, locations: style.fillLocations)
fillLayer.colors = fill.colors
fillLayer.locations = fill.locations.map { NSNumber(value: $0) }
Expand Down
118 changes: 60 additions & 58 deletions apps/native/Sources/VisionNativeUI/Components/VisionTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,11 @@ struct VisionTable<Content: View>: View {
/// `TableRow`'s `hover:bg-foreground/[0.03]` band under the pointer's row.
var rowHover = true
@ViewBuilder var content: () -> Content
/// The cell row under the pointer. A row index, not the pointer's y, so the layout is
/// invalidated (and the table re-measured) only when the pointer crosses into another row.
@State private var hoveredRow: Int?
/// The row bounds of the last placement, for mapping the pointer to a row.
@State private var rowBounds = VisionTableRowBounds()
/// The hovered row. Only the band reads it, so a pointer moving over the table redraws the
/// band and never re-runs this body, the cells or the table layout.
@State private var hover = VisionTableHover()
/// The width the table is offered: the scroll view proposes no width to its content.
@State private var availableWidth: CGFloat?
@Environment(\.visionTheme) private var theme

var body: some View {
ScrollView(.horizontal) {
Expand All @@ -56,65 +53,82 @@ struct VisionTable<Content: View>: View {
}
}

@ViewBuilder
private var table: some View {
VisionTableLayout(
columns: columns, columnSpacing: columnSpacing, fixedColumns: fixedColumns, hoveredRow: rowHover ? hoveredRow : nil, rowBounds: rowBounds,
availableWidth: availableWidth
) {
if rowHover {
Rectangle().fill(theme.foreground.opacity(0.03)).allowsHitTesting(false)
.accessibilityHidden(true)
.layoutValue(key: VisionTableBandKey.self, value: true)
}
content()
}
.onContinuousHover { phase in
guard rowHover else { return }
let row: Int?
switch phase {
case .active(let point): row = rowBounds.row(at: point.y)
case .ended: row = nil
}
if hoveredRow != row { hoveredRow = row }
let layout = VisionTableLayout(
columns: columns, columnSpacing: columnSpacing, fixedColumns: fixedColumns,
availableWidth: availableWidth, hover: rowHover ? hover : nil)
if rowHover {
layout { content() }
.background(alignment: .topLeading) { VisionTableHoverBand(hover: hover) }
.onContinuousHover { phase in
switch phase {
case .active(let point): hover.pointerMoved(toY: point.y)
case .ended: hover.pointerMoved(toY: nil)
}
}
} else {
layout { content() }
}
}
}

extension View {
/// The view takes a whole row of its `VisionTable`.
func visionTableSpan() -> some View { layoutValue(key: VisionTableSpanKey.self, value: true) }
/// The vertical extent of one cell row of a `VisionTable`.
struct VisionTableRowSpan: Equatable {
var top: CGFloat
var height: CGFloat
}

private struct VisionTableSpanKey: LayoutValueKey {
static let defaultValue = false
/// Where the pointer is over a `VisionTable`. The layout records where its cell rows are and the
/// hover handler turns the pointer's y into one of them; `band` changes only when the pointer
/// crosses into another row, and only `VisionTableHoverBand` observes it.
@Observable
final class VisionTableHover {
/// The row under the pointer; nil hides the band.
private(set) var band: VisionTableRowSpan?
/// The cell rows of the last layout pass (dividers and full-width rows left out).
@ObservationIgnored var rows: [VisionTableRowSpan] = []

func pointerMoved(toY y: CGFloat?) {
let next = y.flatMap { y in rows.first { y >= $0.top && y < $0.top + $0.height } }
if band != next { band = next }
}
}

/// Where the cell rows were placed, written by the layout and read by the hover handler. A
/// reference box: writing it during placement must not invalidate the view.
final class VisionTableRowBounds {
/// Top and height of each cell row (spanning rows are `nil`: they take no hover band).
var rows: [(top: CGFloat, height: CGFloat)?] = []
/// `TableRow`'s `hover:bg-foreground/[0.03]` band, behind the cells of the row under the pointer.
private struct VisionTableHoverBand: View {
let hover: VisionTableHover
@Environment(\.visionTheme) private var theme

func row(at y: CGFloat) -> Int? {
rows.firstIndex { row in row.map { y >= $0.top && y < $0.top + $0.height } ?? false }
var body: some View {
if let band = hover.band {
Rectangle().fill(theme.foreground.opacity(0.03))
.frame(height: band.height)
.offset(y: band.top)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
}
}

/// The hover band: drawn behind the cells over the hovered row, outside the row grouping.
private struct VisionTableBandKey: LayoutValueKey {
extension View {
/// The view takes a whole row of its `VisionTable`.
func visionTableSpan() -> some View { layoutValue(key: VisionTableSpanKey.self, value: true) }
}

private struct VisionTableSpanKey: LayoutValueKey {
static let defaultValue = false
}

struct VisionTableLayout: Layout {
let columns: [HorizontalAlignment]
let columnSpacing: CGFloat
let fixedColumns: Set<Int>
/// The plan row under the pointer, for the hover band; nil hides it.
var hoveredRow: Int? = nil
/// Receives the placed row bounds, so the view can map the pointer to a row.
var rowBounds: VisionTableRowBounds? = nil
/// The width to fill when the proposal has none (inside the horizontal scroll view).
var availableWidth: CGFloat? = nil
/// Receives the cell rows' extents for the hover band; nil for a table without one.
var hover: VisionTableHover? = nil

struct Cache {
var width: CGFloat?
Expand Down Expand Up @@ -146,18 +160,8 @@ struct VisionTableLayout: Layout {

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
let plan = plan(width: bounds.width, subviews: subviews, cache: &cache)
rowBounds?.rows = plan.rows.map { $0.spans ? nil : ($0.top, $0.height) }
for index in subviews.indices where subviews[index][VisionTableBandKey.self] {
// Over the cell row under the pointer (not dividers or full-width rows), else empty.
if let hoveredRow, plan.rows.indices.contains(hoveredRow), !plan.rows[hoveredRow].spans {
let row = plan.rows[hoveredRow]
subviews[index].place(
at: CGPoint(x: bounds.minX, y: bounds.minY + row.top), anchor: .topLeading,
proposal: ProposedViewSize(width: bounds.width, height: row.height))
} else {
subviews[index].place(at: bounds.origin, anchor: .topLeading, proposal: .zero)
}
}
// Plain storage, not observed: the next pointer move reads it.
hover?.rows = plan.rows.filter { !$0.spans }.map { VisionTableRowSpan(top: $0.top, height: $0.height) }
for row in plan.rows {
if row.spans {
for index in row.indices {
Expand Down Expand Up @@ -237,9 +241,7 @@ struct VisionTableLayout: Layout {
let finite = width.flatMap { $0.isFinite ? $0 : nil } ?? availableWidth
if let plan = cache.plan, cache.width == finite { return plan }
let count = max(1, columns.count)
let cells = subviews.indices.filter { !subviews[$0][VisionTableBandKey.self] }
let grouped = Self.rows(spans: cells.map { subviews[$0][VisionTableSpanKey.self] }, columnCount: count)
.map { row in (indices: row.indices.map { cells[$0] }, spans: row.spans) }
let grouped = Self.rows(spans: subviews.map { $0[VisionTableSpanKey.self] }, columnCount: count)
var ideal = [CGFloat](repeating: 0, count: count)
for row in grouped where !row.spans {
for (column, index) in row.indices.enumerated() {
Expand Down
25 changes: 20 additions & 5 deletions apps/native/Sources/VisionNativeUI/Components/VisionTooltip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ private struct VisionTooltipRequest {
let locale: Locale
let theme: VisionTheme
let edge: Edge
weak var anchor: NSView?
/// The anchor view is made when the pointer enters, so it is read when the tooltip opens.
let anchor: VisionTooltipAnchorBox
}

/// Shows tooltips in a borderless, mouse-transparent child panel of the anchor's window, with the
Expand Down Expand Up @@ -93,6 +94,13 @@ final class VisionTooltipPresenter {
let delay = skip ? Duration.zero : openDelay
openTask = Task { [weak self, delay] in
if delay > .zero { try? await Task.sleep(for: delay) }
// The anchor view joins the window in the update after the hover; without the open
// delay (another tooltip just closed) that can be a moment after this task starts.
var waits = 0
while !Task.isCancelled, self?.pendingAnchorIsInWindow == false, waits < 12 {
try? await Task.sleep(for: .milliseconds(8))
waits += 1
}
guard !Task.isCancelled else { return }
self?.presentPending()
}
Expand All @@ -112,8 +120,10 @@ final class VisionTooltipPresenter {
dismissPanel()
}

private var pendingAnchorIsInWindow: Bool { pending?.anchor.view?.window != nil }

private func presentPending() {
guard let request = pending, let anchor = request.anchor, let window = anchor.window else { return }
guard let request = pending, let anchor = request.anchor.view, let window = anchor.window else { return }
let hosting = NSHostingView(
rootView: AnyView(
VisionTooltipBubble(text: request.text, edge: request.edge, theme: request.theme)
Expand Down Expand Up @@ -218,15 +228,20 @@ private struct VisionTooltipModifier: ViewModifier {
@Environment(\.visionTheme) private var theme
@Environment(\.locale) private var locale
@State private var anchor = VisionTooltipAnchorBox()
/// The pointer is over the view. The anchor is an AppKit view, and 70-odd controls carry a
/// tooltip (one in every ledger row), so it exists only while its control is hovered.
@State private var hovering = false

func body(content: Content) -> some View {
content
.background(VisionTooltipAnchor(box: anchor))
.background { if hovering { VisionTooltipAnchor(box: anchor) } }
.onHover { inside in
let owner = ObjectIdentifier(anchor)
if inside && !isEmpty {
let show = inside && !isEmpty
if hovering != show { hovering = show }
if show {
VisionTooltipPresenter.shared.enter(
VisionTooltipRequest(text: text, locale: locale, theme: theme, edge: edge, anchor: anchor.view),
VisionTooltipRequest(text: text, locale: locale, theme: theme, edge: edge, anchor: anchor),
owner: owner)
} else {
VisionTooltipPresenter.shared.leave(owner: owner)
Expand Down
Loading
Loading