import AppKit import CoreGraphics import SwiftUI /// The board's **zoom ladder** — the multiplier macOS never supplies, applied to the one scalar the /// whole strip is already derived from (03-board-ui.md ▸ Layout — "Zoom scales the ruler, never the /// strip"; 10-accessibility.md ▸ Text scaling). /// /// `Motion` and `Accommodations`' sibling, and deliberately the same shape: pure functions over a /// value, nothing rendered, nothing stored. `BoardZoomStore` owns the level and persists it; this /// owns what a level *means*, so every claim below is assertable without driving a view /// (`BoardZoomTests`). /// /// ### Why this feature is arithmetic rather than a magnification /// /// `BoardMetrics` is twenty-two `em(multiple, bodyPointSize:)` functions and one impure read of what /// the body point size is, because 10-accessibility.md committed the board to full relative scaling: /// "relative text styles everywhere, no fixed point sizes … so layout survives the largest system /// text sizes". On macOS that commitment has never once been exercised — there is no system text-size /// setting, so `NSFont.preferredFont(forTextStyle: .body).pointSize` is 13 forever. Zoom is that /// machinery's first consumer: a rung on the ladder multiplies the point size, and the twenty-two /// functions carry it to every gap, radius, inset and stripe on the board. /// /// A `scaleEffect` or an `NSScrollView.magnification` was the other candidate and is rejected on two /// counts. It would have to widen the strip, which means horizontal scroll — refused by /// 03-board-ui.md ▸ Layout, and refused in the pathfinder before it. And it would scale the /// coordinate space that `onGeometryChange` reports frames in while leaving `NSEvent.mouseLocation` /// alone, silently desynchronising every drop registry from the cursor. /// /// ### What the level does not touch /// /// **Lane width.** The window's width divides across the lanes' width units and that is the whole of /// the resting layout (`LaneLayoutMath.standardWidth`), so a rung moves the inter-lane gap — an em /// multiple like everything else — and nothing more. Lanes narrow by a few percent across the /// ladder's whole range; cards inside them grow. That asymmetry *is* the feature on a board that /// refuses horizontal scroll: zoom in for bigger, more legible cards and fewer per screen. /// /// **The window.** `BoardMetrics.windowMinimumSize` stays pinned to the system body size, because /// 03-board-ui.md ▸ Lane rules that moving the window belongs to the right-edge drag alone. enum BoardZoom { // MARK: - The ladder /// The rungs, ascending. Zoom In and Zoom Out step between adjacent entries; there is no /// continuous level, and nothing off this list is reachable through the UI. /// /// Discrete rather than a percentage field for the reason every zoom control on the platform is: /// the interesting question is "a bit bigger", not "117%", and a ladder makes ⌘+ ⌘+ ⌘− land back /// exactly where it started. The spacing widens as it climbs — 0.10 near the default, 0.25 at the /// top — because a fixed step reads as too coarse small and too fine large. /// /// The floor is 0.75 (≈10pt body text) rather than something smaller: below that the card face's /// title stops being readable at arm's length and the board stops being a board. The ceiling is /// 2.0, where a standard lane still fits a card with room for its title. static let levels: [CGFloat] = [0.75, 0.85, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0] /// The rung Actual Size returns to, and the one every metric on the board was tuned against. /// /// **The default renders pixel-for-pixel what it rendered before zoom existed** — `bodyPointSize` /// below multiplies by exactly 1 and `font` hands back the relative style untouched. That is the /// same guarantee `BoardMetrics`' em multiples were chosen under ("this milestone is meant to make /// the board *scale*, not to redesign it"), extended to the control that moves them. static let actualSize: CGFloat = 1.0 /// The next rung up, or `level` itself at the top — a fixed point rather than a wrap, so a held /// ⌘+ settles instead of cycling. static func stepIn(from level: CGFloat) -> CGFloat { levels.first { $0 > level } ?? levels[levels.count - 1] } /// The next rung down, or `level` itself at the bottom. static func stepOut(from level: CGFloat) -> CGFloat { levels.last { $0 < level } ?? levels[0] } static func canZoomIn(_ level: CGFloat) -> Bool { level < levels[levels.count - 1] } static func canZoomOut(_ level: CGFloat) -> Bool { level > levels[0] } /// Whether Actual Size would change anything. static func isActualSize(_ level: CGFloat) -> Bool { level == actualSize } // MARK: - Reading a stored level /// An arbitrary stored `Double` mapped onto a legal rung — clamped to the ladder's ends and /// snapped to the nearest entry. /// /// Every read of the persisted level goes through this, and it is not defensive decoration. The /// preference is a plain `UserDefaults` key: a user can `defaults write` it to anything, a future /// build can shorten the ladder under a value written by an older one, and `double(forKey:)` /// answers **0 for a key that was never set** — which, unfiltered, would drive every em multiple /// to its 1pt floor and draw a board of hairlines. Nothing downstream should have to ask whether /// its level is real. /// /// Non-finite input (NaN, infinity) resolves to `actualSize` rather than to an end of the ladder: /// there is no honest nearest rung to a value that is not a number, and the default is the only /// answer that cannot surprise. static func normalize(_ stored: Double) -> CGFloat { guard stored.isFinite else { return actualSize } let value = CGFloat(stored) guard let nearest = levels.min(by: { abs($0 - value) < abs($1 - value) }) else { return actualSize } return nearest } // MARK: - What a level means /// The body point size the board draws at: the system's, times the level. /// /// **Deliberately not rounded.** `BoardMetrics.em` already rounds every figure it produces to a /// whole point and floors it at one, so rounding here would round twice — and the two roundings /// disagree (`round(round(13 × 1.15) × 0.9)` is not `round(13 × 1.15 × 0.9)`). One rule, applied /// where it already lives: the level scales, `em` rounds. /// /// Floored at 1 for the same reason `em` is: no proposal downstream may be zero or negative, and /// a level cannot be trusted to be positive until `normalize` has seen it. static func bodyPointSize(system: CGFloat, level: CGFloat) -> CGFloat { max(1, system * level) } /// A text style at this level — **the relative style itself at Actual Size**, a fixed size scaled /// off it anywhere else. /// /// The early return is the whole reason this function exists rather than an unconditional /// `.system(size:)`. A relative style carries more than a number — the system's own leading and /// its accessibility traits ride along with it — and a user who never touches zoom should not pay /// for the feature by having every label on the board silently swapped for a point size. So the /// default rung keeps the semantic style verbatim and only a deliberate zoom trades it away. /// /// Off the default, every style scales by the same factor, so the type hierarchy the board /// encodes — `.headline` over `.body` over `.caption` — survives the trip intact. @MainActor static func font(_ style: Font.TextStyle, level: CGFloat, weight: Font.Weight? = nil) -> Font { guard level != actualSize else { return .system(style, weight: weight) } let system = NSFont.preferredFont(forTextStyle: style.appKitStyle).pointSize return .system(size: system * level, weight: weight) } /// The level as a percentage, for the announcement a zoom owes VoiceOver /// (10-accessibility.md ▸ Text scaling). static func percentLabel(_ level: CGFloat) -> String { "\(Int((level * 100).rounded()))%" } } // MARK: - The environment /// The zoom level as the strip's views see it — **the app's one custom environment value**. /// /// The environment rather than a read of the store inside each `body`, and the reason is specific /// enough to be worth stating: `CardFaceView` and `LaneView` are `.equatable()`, and /// `CardFaceView`'s own note says `@Environment` values are "deliberately NOT compared" because /// "SwiftUI invalidates on those itself". A level threaded through the environment therefore /// propagates *through* the render gates for free. A level read from a singleton or mirrored into /// `@State` would be swallowed by them on every card face — the board would zoom everywhere except /// the cards. /// /// The value carries the level rather than the multiplied point size so it stays `Sendable` and /// non-isolated (the environment's default must be constructible without touching AppKit); the /// system half is read on demand below, exactly as `BoardMetrics.bodyPointSize` reads it. struct BoardZoomContext: Equatable, Sendable { var level: CGFloat /// The unzoomed board — the environment's default, and what any view rendered outside a board /// window's injection gets. Unzoomed is the only honest fallback: it is what the board drew /// before this file existed. static let actualSize = BoardZoomContext(level: BoardZoom.actualSize) /// The number every `BoardMetrics` function takes. /// /// **This, not `BoardMetrics.bodyPointSize`, is what the strip asks.** The two are deliberately /// different readings and the distinction is load-bearing: `BoardMetrics.bodyPointSize` is the /// *system's* body size, which is still what the window minimum, the banner strip, the sheets and /// popovers, the welcome window and the whole card window want — none of them zoom (03-board-ui.md /// ▸ Layout: the level is the board's, and a sheet is a form, not the board). @MainActor var bodyPointSize: CGFloat { BoardZoom.bodyPointSize(system: BoardMetrics.bodyPointSize, level: level) } @MainActor func font(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> Font { BoardZoom.font(style, level: level, weight: weight) } } extension EnvironmentValues { /// Injected once, on the board window's content (`BoardWindowHost`). Everything under it — lanes, /// card faces, the trash column, drag shadows, the resize handle — reads its ruler from here. @Entry var boardZoom: BoardZoomContext = .actualSize } extension View { /// A text style at the board's current zoom — the strip's replacement for `.font(_:)`. /// /// Every `Text` inside the strip wears this instead of a bare relative style, because the style /// alone does not move: SwiftUI resolves `.caption` against the system, which zoom does not /// change. Chrome outside the strip keeps using `.font(_:)` and stays at system size on purpose. /// /// SF Symbols need no equivalent — `Image(systemName:)` under `.imageScale(_:)` sizes off the /// current font, which is why `BoardMetrics.newCardButtonReserve` is 1.7 em rather than 22pt. func boardFont(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> some View { modifier(BoardFontModifier(style: style, weight: weight)) } } private struct BoardFontModifier: ViewModifier { @Environment(\.boardZoom) private var zoom let style: Font.TextStyle let weight: Font.Weight? func body(content: Content) -> some View { content.font(zoom.font(style, weight: weight)) } } // MARK: - The style tables private extension Font.TextStyle { /// SwiftUI's text style as AppKit names it, so `NSFont.preferredFont(forTextStyle:)` can be asked /// what point size it resolves to. /// /// A table rather than a rawValue bridge because there is no bridge: the two enumerations agree on /// most names and disagree on three — SwiftUI's `.title` is AppKit's `.title1`, its `.caption` is /// `.caption1`, and the sizes SwiftUI adds beyond AppKit's list have nowhere to land. Those fall /// back to `.body`, which is the closest AppKit *has*; none of them appears on the board. var appKitStyle: NSFont.TextStyle { switch self { case .largeTitle: .largeTitle case .title: .title1 case .title2: .title2 case .title3: .title3 case .headline: .headline case .subheadline: .subheadline case .body: .body case .callout: .callout case .footnote: .footnote case .caption: .caption1 case .caption2: .caption2 @unknown default: .body } } }