Files
lanework/Kanban/LiveStore/BoardZoomStore.swift
T
rzen cfee4a4b41 The board learns to zoom — eight rungs on one ruler, and Actual Size is the untouched board
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
2026-08-07 11:22:02 -04:00

104 lines
5.5 KiB
Swift

import CoreGraphics
import Foundation
import Observation
/// The board's zoom level: one number, app-wide, persisted (11-command-nexus.md ▸ View ▸ Actual Size
/// — "app-wide and persisted across restarts"; 03-board-ui.md ▸ Layout — zoom).
///
/// `StyleRecents`' shape exactly, for its reasons. The *rules* — what the rungs are, how stepping
/// terminates, what an illegal stored value becomes — are `BoardZoom`'s pure functions; this object is
/// their persistence and their observability, and `defaults` is injectable so a test drives a suite of
/// its own rather than the user's.
///
/// ### Why app-wide, and not per board
///
/// A zoom level describes how this user likes to read, not what a board is. It has no business in a
/// lane's frontmatter (where `width` lives — that *is* board data, shared with every collaborator and
/// every agent), and no business in `BoardRegistry` either: a board opened on a laptop and on a studio
/// display wants the same *preference* applied, not a per-board memory of a window that no longer
/// exists. It sits beside Show Comments and Comments Beside Body, which are app-wide and persisted for
/// the same reason.
///
/// ### Why `@Observable` rather than `@AppStorage`
///
/// Two consumers need change notification that a property wrapper in a view cannot give them. The
/// board toolbar's validation is *observed*, not polled — `WindowToolbarController.trackValidationState`
/// re-arms `withObservationTracking` over each spec's `isEnabled`, so Zoom In greys out at the top rung
/// only if the level it reads is observable. And menu commands live outside every scene's environment,
/// so they receive `AppModel` as a plain `let` and re-render only because it is `@Observable`. One
/// observable holder serves both, and the level has exactly one home rather than a mirror per surface.
@MainActor
@Observable
public final class BoardZoomStore {
/// The current rung — always a member of `BoardZoom.levels`, guaranteed by construction: the
/// initialiser normalises what it reads and `setLevel` normalises what it is given, so no reader
/// anywhere has to ask whether its level is legal.
public private(set) var level: CGFloat
@ObservationIgnored
private let defaults: UserDefaults
/// - Parameter defaults: the domain to persist in. Injected for `StyleRecents`' reason — a test
/// must be able to hold its own without touching the user's.
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
// `object(forKey:)` rather than `double(forKey:)` so an unset key arrives as nil rather than as
// 0, and `normalize` rather than `?? 1.0` so a hand-edited or stale value lands on a rung too.
// Either way the answer is legal; the distinction only decides *which* legal value an absent
// key becomes, and both roads lead to Actual Size.
level = BoardZoom.normalize((defaults.object(forKey: AppPreferences.boardZoomLevelKey) as? Double) ?? Double(BoardZoom.actualSize))
}
// MARK: - Moving
/// Sets the level to the nearest legal rung and persists it.
///
/// The single write path — Zoom In, Zoom Out, Actual Size and the two toolbar buttons all arrive
/// here, which is what keeps the menu row and its toolbar twin from being two implementations of
/// one command (the `BoardStore.setTrashVisible` precedent).
///
/// **An unchanged level writes nothing and publishes nothing**, and unlike `StyleRecents.record`'s
/// unconditional write that guard is load-bearing rather than an optimisation. `@Observable`
/// notifies on *every* set, equal or not, so an ungated assignment would invalidate the board
/// window's zoom environment on a no-op — Actual Size when already at 100%, ⌘+ at the top rung —
/// and re-run the whole strip to draw exactly what it was drawing. A board nobody zoomed must not
/// pay for the feature existing.
///
/// The *announcement* is deliberately not gated with it (`step(_:)`): a user who pressed ⌘+ at the
/// top rung is still owed the answer.
public func setLevel(_ newValue: CGFloat) {
let normalized = BoardZoom.normalize(Double(newValue))
guard normalized != level else { return }
level = normalized
defaults.set(Double(normalized), forKey: AppPreferences.boardZoomLevelKey)
}
/// One rung up; a fixed point at the top.
public func zoomIn() { setLevel(BoardZoom.stepIn(from: level)) }
/// One rung down; a fixed point at the bottom.
public func zoomOut() { setLevel(BoardZoom.stepOut(from: level)) }
/// Back to 100%, where the strip renders exactly what it rendered before zoom existed.
public func actualSize() { setLevel(BoardZoom.actualSize) }
// MARK: - Reading
public var canZoomIn: Bool { BoardZoom.canZoomIn(level) }
public var canZoomOut: Bool { BoardZoom.canZoomOut(level) }
public var isActualSize: Bool { BoardZoom.isActualSize(level) }
/// The level as the strip's views take it.
///
/// Internal rather than `public` like its neighbours, and the asymmetry is the type's, not an
/// oversight: `BoardZoomContext` is an environment value, which is a UI-layer concern the way
/// `dragSession` is — nothing outside this module has any business rendering a board.
var context: BoardZoomContext { BoardZoomContext(level: level) }
/// "125%" — the announcement's value (10-accessibility.md ▸ Text scaling).
public var percentLabel: String { BoardZoom.percentLabel(level) }
}