From da97ff5575b56617289c16c036a0d320cd768754 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 21:54:39 +0000 Subject: [PATCH] perf(native): keep hover state local and drop offscreen passes - VisionTable and LocalDataTable: the hover band observes its own state, so a pointer move no longer re-runs the table body, cells and layout. - Home: chart hover state lives in ChartSelectionScope, not the page. - Tooltips: the AppKit anchor view exists only while its control is hovered. - Shell: clip the detail column with a rectangle instead of a mask view. - Cards: no shadow cut-out mask under an opaque fill. - Buttons: shadow-sm is cast by the fill and only where a variant has one. - Topbar: no backdrop blur on the reduced tier or with Reduce Transparency. --- .../Components/ChartSupport.swift | 19 +++ .../Components/LocalDataTable.swift | 41 +++++- .../Components/NativeTopbar.swift | 16 ++- .../Components/VisionButtonStyles.swift | 21 +++- .../Components/VisionCardLayers.swift | 5 + .../Components/VisionTable.swift | 118 +++++++++--------- .../Components/VisionTooltip.swift | 25 +++- .../Sources/VisionNativeUI/HomeView.swift | 41 +++--- .../VisionNativeUI/ShellRootView.swift | 15 ++- docs/architecture/native-app.md | 22 +++- 10 files changed, 227 insertions(+), 96 deletions(-) diff --git a/apps/native/Sources/VisionNativeUI/Components/ChartSupport.swift b/apps/native/Sources/VisionNativeUI/Components/ChartSupport.swift index cb1061c35..d88e9f36a 100644 --- a/apps/native/Sources/VisionNativeUI/Components/ChartSupport.swift +++ b/apps/native/Sources/VisionNativeUI/Components/ChartSupport.swift @@ -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: View { + @State private var selection: String? + @State private var rangeStart: String? + private let content: (Binding, Binding) -> Content + + init(@ViewBuilder content: @escaping (Binding, Binding) -> Content) { + self.content = content + } + + var body: some View { + content($selection, $rangeStart) + } +} diff --git a/apps/native/Sources/VisionNativeUI/Components/LocalDataTable.swift b/apps/native/Sources/VisionNativeUI/Components/LocalDataTable.swift index 5f7a756d3..d4a804b10 100644 --- a/apps/native/Sources/VisionNativeUI/Components/LocalDataTable.swift +++ b/apps/native/Sources/VisionNativeUI/Components/LocalDataTable.swift @@ -409,10 +409,8 @@ struct LocalDataTable: 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 { @@ -455,6 +453,41 @@ struct LocalDataTable: 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: 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: View { diff --git a/apps/native/Sources/VisionNativeUI/Components/NativeTopbar.swift b/apps/native/Sources/VisionNativeUI/Components/NativeTopbar.swift index 1e9c337f9..c7378f873 100644 --- a/apps/native/Sources/VisionNativeUI/Components/NativeTopbar.swift +++ b/apps/native/Sources/VisionNativeUI/Components/NativeTopbar.swift @@ -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. @@ -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: [ diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionButtonStyles.swift b/apps/native/Sources/VisionNativeUI/Components/VisionButtonStyles.swift index a0f8f381a..bacd9309b 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionButtonStyles.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionButtonStyles.swift @@ -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) @@ -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 diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionCardLayers.swift b/apps/native/Sources/VisionNativeUI/Components/VisionCardLayers.swift index 37ce763ca..1f25b086e 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionCardLayers.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionCardLayers.swift @@ -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) } diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift b/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift index 7d1e8ae7e..80d0518f1 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift @@ -36,14 +36,11 @@ struct VisionTable: 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) { @@ -56,52 +53,71 @@ struct VisionTable: 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 } @@ -109,12 +125,10 @@ struct VisionTableLayout: Layout { let columns: [HorizontalAlignment] let columnSpacing: CGFloat let fixedColumns: Set - /// 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? @@ -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 { @@ -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() { diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionTooltip.swift b/apps/native/Sources/VisionNativeUI/Components/VisionTooltip.swift index 8a4d5ab09..d44f24e51 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionTooltip.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionTooltip.swift @@ -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 @@ -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() } @@ -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) @@ -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) diff --git a/apps/native/Sources/VisionNativeUI/HomeView.swift b/apps/native/Sources/VisionNativeUI/HomeView.swift index 608627674..953d8e6b2 100644 --- a/apps/native/Sources/VisionNativeUI/HomeView.swift +++ b/apps/native/Sources/VisionNativeUI/HomeView.swift @@ -8,12 +8,6 @@ public struct HomeView: View { let shell: NativeShellModel @State private var customize = false @State private var diagnostics = false - @State private var heroSelection: String? - @State private var heroRange: String? - @State private var monthSelection: String? - @State private var monthRange: String? - @State private var netSelection: String? - @State private var netRange: String? @State private var windowWidth = InsightsBreakpoint.initialWidth @Environment(\.visionTheme) private var theme @@ -465,9 +459,16 @@ public struct HomeView: View { } } } + /// The hovered month of the sparkline belongs to the card: a hover re-renders this card, not + /// the whole dashboard. private var summary: some View { + ChartSelectionScope { selection, rangeStart in + summaryCard(netSelection: selection, netRange: rangeStart) + } + } + private func summaryCard(netSelection: Binding, netRange: Binding) -> some View { let months = Array((model.monthly?.orderedMonths ?? []).filter { $0.transaction_count > 0 }.suffix(12)) - let selected = months.first { $0.id == netSelection } ?? months.last + let selected = months.first { $0.id == netSelection.wrappedValue } ?? months.last let net = selected.map { $0.total_income - abs($0.total_spending) } ?? 0 return card("dashboard.statCards", hero: net >= 0 ? .gain : .loss) { Text(strings.t("dashboard.stat.lastMonthNet")).font(.body.weight(.medium)).foregroundStyle( @@ -495,7 +496,7 @@ public struct HomeView: View { title: strings.t("dashboard.stat.netTrend", ["n": String(months.count)]), areas: true, stackedAreas: false, height: 80, customColors: ["net": net >= 0 ? theme.gain : theme.loss], sparkline: true, - selection: $netSelection, rangeStart: $netRange) + selection: netSelection, rangeStart: netRange) routeRow( strings.t("dashboard.stat.lastMonthIncome"), route: InsightsRoutes.drill( @@ -567,17 +568,19 @@ public struct HomeView: View { fillsHeight: true ) { let months = Array((model.monthlyData(for: "monthlyTrends")?.orderedMonths ?? []).suffix(6)) - InsightTimelineChart( - series: [ - InsightChartSeries( - id: "income", label: strings.t("monthlyTrends.income"), - points: months.map { InsightChartPoint(key: $0.id, value: $0.total_income) }), - InsightChartSeries( - id: "spending", label: strings.t("monthlyTrends.spending"), - points: months.map { InsightChartPoint(key: $0.id, value: abs($0.total_spending)) }), - ], currency: currency, settings: settings, title: strings.t("monthlyTrends.title"), - bars: true, yAxis: .lengthAware, xLabels: .appMonthYear, - selection: $monthSelection, rangeStart: $monthRange) + ChartSelectionScope { selection, rangeStart in + InsightTimelineChart( + series: [ + InsightChartSeries( + id: "income", label: strings.t("monthlyTrends.income"), + points: months.map { InsightChartPoint(key: $0.id, value: $0.total_income) }), + InsightChartSeries( + id: "spending", label: strings.t("monthlyTrends.spending"), + points: months.map { InsightChartPoint(key: $0.id, value: abs($0.total_spending)) }), + ], currency: currency, settings: settings, title: strings.t("monthlyTrends.title"), + bars: true, yAxis: .lengthAware, xLabels: .appMonthYear, + selection: selection, rangeStart: rangeStart) + } // `mt-6 grid grid-cols-2 gap-3`: the totals as two tinted tiles. HStack(spacing: 12) { HomeTotalTile( diff --git a/apps/native/Sources/VisionNativeUI/ShellRootView.swift b/apps/native/Sources/VisionNativeUI/ShellRootView.swift index e62f74cf1..d308c82be 100644 --- a/apps/native/Sources/VisionNativeUI/ShellRootView.swift +++ b/apps/native/Sources/VisionNativeUI/ShellRootView.swift @@ -82,7 +82,7 @@ public struct ShellRootView: View { // never rebuilds it, resizes its Metal drawable or restarts its drift. if isNativeDetail { FrozenWidthLayout(width: frozenDetailWidth) { DetailGlassBackground() } - .mask { Rectangle().padding(.vertical, -400) } + .clipShape(ColumnSidewaysClip()) } if case .native(let screen) = model.detail { FrozenWidthLayout(width: frozenDetailWidth) { @@ -117,8 +117,9 @@ public struct ShellRootView: View { } } // Clip the page to its column sideways only: the canvas still runs up under - // the titlebar strip. - .mask { Rectangle().padding(.vertical, -400) } + // the titlebar strip. A rectangular clip, not a mask: a mask view makes the + // whole page an offscreen pass composited through the mask's alpha. + .clipShape(ColumnSidewaysClip()) } else if case .notFound = model.detail { NativePageZoomView(factor: model.pageZoom) { NativeNotFoundView(shell: model) @@ -923,6 +924,14 @@ struct FrozenWidthLayout: Layout { } } +/// The column's rectangle, 400 pt taller at each end: it clips sideways and lets the canvas and +/// page run up under the titlebar strip. +struct ColumnSidewaysClip: Shape { + func path(in rect: CGRect) -> Path { + Path(rect.insetBy(dx: 0, dy: -400)) + } +} + /// Column widths read by the sidebar freeze; deliberately not observable. final class ShellColumnWidths { var detail: CGFloat = 0 diff --git a/docs/architecture/native-app.md b/docs/architecture/native-app.md index 49177d67c..c0ae38ac6 100644 --- a/docs/architecture/native-app.md +++ b/docs/architecture/native-app.md @@ -2,7 +2,7 @@ title: Vision Native App Architecture type: architecture-doc status: active -date: 2026-11-13 +date: 2026-11-14 updated: 2026-11-07 tags: [ @@ -1911,6 +1911,26 @@ counterpart: - **Halos.** Under Full effects the wide dark-mode halo stays on headings, and the page subtitle keeps the single 8 pt halo. +### Swift redraw and layer optimisations + +These have no Electron counterpart; they remove work SwiftUI and Core Animation did per pointer +move or per composited frame. + +- **Hover state stays with what it draws.** `VisionTable` keeps the hovered row in `VisionTableHover` + (an `@Observable` object that only `VisionTableHoverBand` reads; the layout records its row + extents there), so a pointer moving over a table redraws the band and never re-runs the table + body, its cells or its layout. `LocalDataTable` rows use `RowHoverBand` the same way. On Home, + `ChartSelectionScope` owns the hovered point of the summary sparkline and the monthly trends + chart, so a chart hover re-renders its card and not the dashboard. +- **Tooltip anchors.** `VisionTooltipModifier` creates its AppKit anchor view only while the pointer + is over the control; `VisionTooltipPresenter` reads it when the tooltip opens. +- **Offscreen passes.** The detail column is clipped sideways with a rectangular `clipShape` + (`ColumnSidewaysClip`), not a mask view. A card with an opaque fill (reduced tier, Reduce + Transparency) drops the shadow cut-out mask of `VisionCardLayerView`. `VisionButtonChrome` casts + `shadow-sm` from the fill shape and only on the variants that have one. +- **Topbar.** On the reduced tier and under Reduce Transparency the veil lies over an opaque + `--card` instead of a blurred backdrop (`:root.fx-reduced .app-topbar`). + Chromium-only items have no Swift counterpart: squircle clips, React memoisation, virtualizer estimates, chart entrance layers, `material-host` pseudo-elements and `backdrop-filter` declaration order. The same goes for ledger rows ignoring the pointer while scrolling, because the