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
111 lines
5.8 KiB
Swift
111 lines
5.8 KiB
Swift
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))
|
|
}
|
|
}
|