import SwiftUI // MARK: - View ▸ Zoom In / Zoom Out / Actual Size /// The board's three zoom rows (11-command-nexus.md ▸ View; behaviour in 03-board-ui.md ▸ Layout — /// zoom). /// /// They step one app-wide level (`BoardZoomStore`), which every open board window reads through the /// environment, so this is deliberately *not* a per-window command: ⌘+ in one window zooms the board /// in the other too, the way Show Comments checks in every card window at once. /// /// ### Why they are scoped to a board window anyway /// /// The level is app-wide but the *surface* it moves is not: only board windows zoom (the card window /// is a later milestone). A ⌘+ that changed nothing visible while a card window was frontmost would /// be a command that silently missed, so `@FocusedValue(\.boardStore)` scopes the rows the way every /// other board command is scoped — present and live over a board, disabled everywhere else. /// /// ### The three disabled states /// /// Each end of the ladder disables its own direction and Actual Size disables at 100%: "an item whose /// only outcome is a no-op reads better disabled than dead" (`LaneWidthCommands.canDecrease`). /// /// **All three also disable while a drag session is in flight**, which is an invariant stated rather /// than a defence. A card or lane drag freezes geometry the level feeds — the drag's frozen card /// heights, and `RestingLayoutCache`, whose entry key does not include the point size — so a level /// that moved underneath one would leave the proposal resolving against a layout the board is no /// longer drawing. In practice an AppKit drag loop swallows key equivalents and no toolbar button can /// be clicked with the mouse already down, so the guard should never fire; `BoardView` clears the /// resting layouts on a level change regardless, which is what actually makes the case safe. This is /// the honest statement of the rule, and the thing a test can hold /// (`ShowCommentsCommand.isEnabled`'s pattern). /// /// The read-only lock is deliberately absent, for `ShowTrashCommand`'s reason: zooming is a view /// change, not a mutation, and a locked board is exactly when a user wants to read it more /// comfortably. struct ZoomCommands: View { /// The app-wide level. A plain `let` rather than an `@Environment` read because menu commands live /// in the menu bar, outside every scene's environment; it re-renders on a level change because /// `AppModel` is `@Observable` (`NewBoardCommand`'s pattern). let appModel: AppModel @FocusedValue(\.boardStore) private var store var body: some View { Button("Zoom In") { appModel.zoom.step(.in) } .keyboardShortcut("+", modifiers: .command) .disabled(!Self.isEnabled(store: store, session: appModel.dragSession) || !appModel.zoom.canZoomIn) Button("Zoom Out") { appModel.zoom.step(.out) } .keyboardShortcut("-", modifiers: .command) .disabled(!Self.isEnabled(store: store, session: appModel.dragSession) || !appModel.zoom.canZoomOut) Button("Actual Size") { appModel.zoom.step(.actualSize) } .keyboardShortcut("0", modifiers: .command) .disabled(!Self.isEnabled(store: store, session: appModel.dragSession) || appModel.zoom.isActualSize) } /// Whether the rows apply at all — a board window in front, and no drag in flight. /// /// Extracted as a static function so the rule is assertable without driving a menu /// (`ShowCommentsCommand.isEnabled`, `SaveAsTemplateCommand.allowsSave`). It deliberately does /// *not* fold in the per-direction ladder ends: those are properties of the level, already stated /// on `BoardZoom`, and duplicating them here would be two answers to one question. static func isEnabled(store: BoardStore?, session: DragSession) -> Bool { store != nil && !session.isActive } } // MARK: - The move, and the one place it happens /// Which way a zoom command goes — the three rows' whole difference from one another. /// /// An enum rather than three methods so `BoardZoomStore.step(_:)` can be the single write path the /// menu rows *and* the toolbar buttons share, the way `BoardStore.setTrashVisible` is Show Trash's /// (03-board-ui.md ▸ Toolbar: a toolbar item is a menu command with a different face, never a second /// implementation of it). enum ZoomMove { case `in` case out case actualSize } extension BoardZoomStore { /// A zoom command's whole behaviour — move the level, animate the board under it, say the new /// level out loud. func step(_ move: ZoomMove) { // A user-initiated structural change, so it animates in the structural voice and goes instant // under Reduce Motion (03-board-ui.md § Motion: "everything the user does through the app … // lands in an animated transaction regardless of entry point"). // // Reduce Motion read from AppKit rather than from `@Environment` for `setTrashVisible`'s // reason: a menu command's content is built outside any rendered hierarchy, and a toolbar item // has no environment at all. withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) { switch move { case .in: zoomIn() case .out: zoomOut() case .actualSize: actualSize() } } // Announced from here rather than from each row, so the two toolbar buttons say the same // sentence as the menu (10-accessibility.md ▸ Text scaling). Unconditional even when the level // did not move: a user who pressed ⌘+ at the top rung is owed the answer "still 200%", and a // silent no-op is the one response that reads as a broken command. AccessibilityAnnouncer.post(AccessibilityPhrases.zoomLevel(percentLabel)) } }