View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight rungs, app-wide and persisted (the Show Comments precedent) — a viewing comfort, not a property of any one board. The level travels as BoardZoomContext in the environment, injected on BoardView alone so the banner strip, search bar, sheets and popovers stay at the system size; the environment is also what carries it through CardFaceView's equality gate, which compares nothing that moves with the level. Every BoardMetrics figure follows zoom.bodyPointSize — card and lane chrome, drag replicas and the count badge, the resize handle, the trash column — and the drop registry carries the ruler for event-time reads, with the autoscroller's three reaches turning font-derived (reachSide named as the stripGap it always equalled). Lanes still divide the window; zoom never moves the window or its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung announces itself to VoiceOver, and the render suite pins both invariants: a rung repaints every face, a no-op Actual Size repaints nothing. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
248 lines
13 KiB
Swift
248 lines
13 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|