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
This commit is contained in:
@@ -177,6 +177,18 @@ enum AccessibilityPhrases {
|
||||
shown ? "Trash shown" : "Trash hidden"
|
||||
}
|
||||
|
||||
/// What View ▸ Zoom In / Zoom Out / Actual Size announces — "a zoom change announces its new
|
||||
/// level" (10-accessibility.md ▸ Text scaling).
|
||||
///
|
||||
/// `trashVisibility`'s rule exactly, and for its reason: the resulting *state* rather than the
|
||||
/// action, because three commands and two toolbar buttons all land on one ladder and what a user
|
||||
/// needs to hear is which rung they are on — not that something moved. It is also the only signal
|
||||
/// there is: nothing gains or loses focus, no element's label or value changes, and the whole
|
||||
/// effect is a redraw a VoiceOver user cannot see.
|
||||
static func zoomLevel(_ percent: String) -> String {
|
||||
"Zoom \(percent)"
|
||||
}
|
||||
|
||||
// MARK: - Live board announcements
|
||||
|
||||
/// "3 lanes", "1 lane" — `cardCount`'s twin, and the second half of the digest's plural folding.
|
||||
|
||||
@@ -64,6 +64,15 @@ final class LaneDropRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// The board's current ruler, written by `BoardView` from the zoom environment (03-board-ui.md
|
||||
/// ▸ Layout — zoom).
|
||||
///
|
||||
/// It lives here for `stripFrame`'s reason: the drop delegates run at event time, outside any
|
||||
/// body evaluation, so a number they need has to be *on* the registry rather than captured from a
|
||||
/// view's last render. The default is the system size, which is what an un-zoomed board and every
|
||||
/// test that never sets it get.
|
||||
var bodyPointSize: CGFloat = BoardMetrics.bodyPointSize
|
||||
|
||||
/// The height a card with no registered measurement is assumed to have — a lane whose faces have
|
||||
/// not laid out yet. Nominal rather than zero, so the resting rows still tile.
|
||||
///
|
||||
@@ -73,9 +82,12 @@ final class LaneDropRegistry {
|
||||
/// every un-measured slot boundary in the wrong place at a large system text size — and the drop
|
||||
/// model is forbidden from reading measured frames mid-flight (03-board-ui.md § Motion), so this
|
||||
/// guess is all it has until the lane lays out.
|
||||
@MainActor
|
||||
static var nominalCardHeight: CGFloat {
|
||||
BoardMetrics.nominalCardHeight(bodyPointSize: BoardMetrics.bodyPointSize)
|
||||
///
|
||||
/// An instance property rather than a static for exactly that reason extended to zoom: the guess
|
||||
/// has to be made on the ruler the board is *currently* drawing with, and the level is a property
|
||||
/// of the window this registry belongs to.
|
||||
var nominalCardHeight: CGFloat {
|
||||
BoardMetrics.nominalCardHeight(bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The board strip's own frame in the window's SwiftUI global space — the origin the strip
|
||||
@@ -246,7 +258,7 @@ final class RestingLayoutCache {
|
||||
heights.reserveCapacity(lane.cards.count)
|
||||
for card in lane.cards where !hidden.contains(card.id) {
|
||||
cardIDs.append(card.id)
|
||||
heights.append(registry.heights[card.id] ?? LaneDropRegistry.nominalCardHeight)
|
||||
heights.append(registry.heights[card.id] ?? registry.nominalCardHeight)
|
||||
}
|
||||
|
||||
let layout = LaneRestingLayout(cardIDs: cardIDs, heights: heights)
|
||||
@@ -449,7 +461,7 @@ struct BoardDropContext {
|
||||
heights: resting.heights,
|
||||
// The run's footprint at the landing spot: the first dragged card's frozen height, which
|
||||
// is the trigger rect the cursor is over (the rest stack below it).
|
||||
draggedHeight: session.cardHeights.first ?? LaneDropRegistry.nominalCardHeight,
|
||||
draggedHeight: session.cardHeights.first ?? registry.nominalCardHeight,
|
||||
current: session.laneProposal(onBoardRooted: store.rootKey, laneID: laneID)
|
||||
)
|
||||
guard let slot else { return } // a dead region: hold
|
||||
@@ -661,7 +673,7 @@ struct BoardDropContext {
|
||||
}
|
||||
|
||||
let rendered = lane.cards
|
||||
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
|
||||
let heights = rendered.map { registry.heights[$0.id] ?? registry.nominalCardHeight }
|
||||
let count = FinderDrop.shadowCount(info.itemProviders(for: [.fileURL]))
|
||||
|
||||
let landing = FileDropZones.landing(
|
||||
@@ -669,7 +681,7 @@ struct BoardDropContext {
|
||||
headerBottom: registry.headers[laneID]?.maxY,
|
||||
placement: grid.placement,
|
||||
heights: heights,
|
||||
nominalHeight: LaneDropRegistry.nominalCardHeight,
|
||||
nominalHeight: registry.nominalCardHeight,
|
||||
current: session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: laneID)?.index
|
||||
)
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ extension NSToolbarItem.Identifier {
|
||||
static let boardUndo = Self("board.undo")
|
||||
static let boardRedo = Self("board.redo")
|
||||
static let boardShowTrash = Self("board.showTrash")
|
||||
static let boardZoomIn = Self("board.zoomIn")
|
||||
static let boardZoomOut = Self("board.zoomOut")
|
||||
}
|
||||
|
||||
// MARK: - The board window's toolbar
|
||||
@@ -20,8 +22,8 @@ extension NSToolbarItem.Identifier {
|
||||
/// "**Board window default: the search field, nothing else** — trailing, the one default item; the
|
||||
/// titlebar stays clean." The flexible space ahead of it is what "trailing" means to `NSToolbar`.
|
||||
///
|
||||
/// "**Catalog** (available via Customize): New Card, New Lane, Undo, Redo …, Show Trash (toggle
|
||||
/// state matching the View menu checkmark)." Every one of them is the *same command* as its menu row
|
||||
/// "**Catalog** (available via Customize): New Card, New Lane, Zoom In, Zoom Out …, Undo, Redo …,
|
||||
/// Show Trash (toggle state matching the View menu checkmark)." Every one of them is the *same command* as its menu row
|
||||
/// — the predicates below are the rows' own (`BoardStore.newCardTarget`, `acceptsBoardMutations`),
|
||||
/// and the two actions with consequences call the rows' own functions (`beginNewCard`,
|
||||
/// `setTrashVisible`) rather than restating them. That is what makes "toolbars are pure enhancement"
|
||||
@@ -55,7 +57,21 @@ enum BoardToolbar {
|
||||
/// "The search field, nothing else — trailing, the one default item."
|
||||
static let defaultItems: [NSToolbarItem.Identifier] = [.flexibleSpace, .boardSearch]
|
||||
|
||||
static func specs(store: BoardStore, search: BoardSearchPresentation) -> [ToolbarItemSpec] {
|
||||
/// - Parameters:
|
||||
/// - zoom: the app-wide zoom level, in the catalog order the palette shows. It is not the
|
||||
/// store's, unlike every other predicate here, because the level is not a board's
|
||||
/// (03-board-ui.md ▸ Layout — zoom) — and it must be `@Observable` rather than read from
|
||||
/// `UserDefaults` at build time, since `WindowToolbarController.trackValidationState` re-arms
|
||||
/// observation over each spec's `isEnabled` and a plain scalar would leave Zoom In looking live
|
||||
/// at the top rung.
|
||||
/// - session: the app's drag session, for the same guard the menu rows carry
|
||||
/// (`ZoomCommands.isEnabled`).
|
||||
static func specs(
|
||||
store: BoardStore,
|
||||
search: BoardSearchPresentation,
|
||||
zoom: BoardZoomStore,
|
||||
session: DragSession
|
||||
) -> [ToolbarItemSpec] {
|
||||
[
|
||||
.mirroring(
|
||||
menuTitle: "New Card",
|
||||
@@ -75,6 +91,34 @@ enum BoardToolbar {
|
||||
perform: { [weak store] in store?.createLane() }
|
||||
)
|
||||
),
|
||||
// The zoom pair — plain buttons, because that is what the menu rows are. There is no
|
||||
// percentage readout and no popup: a control that *displays* the level would need a
|
||||
// custom view and a new `ToolbarItemSpec.Behavior` case, and the level already has a
|
||||
// spoken voice (`AccessibilityPhrases.zoomLevel`) and a visible one (the board itself).
|
||||
.mirroring(
|
||||
menuTitle: "Zoom In",
|
||||
identifier: .boardZoomIn,
|
||||
symbol: "plus.magnifyingglass",
|
||||
behavior: .button(
|
||||
isEnabled: { [weak store, weak zoom, weak session] in
|
||||
guard let zoom, let session else { return false }
|
||||
return ZoomCommands.isEnabled(store: store, session: session) && zoom.canZoomIn
|
||||
},
|
||||
perform: { [weak zoom] in zoom?.step(.in) }
|
||||
)
|
||||
),
|
||||
.mirroring(
|
||||
menuTitle: "Zoom Out",
|
||||
identifier: .boardZoomOut,
|
||||
symbol: "minus.magnifyingglass",
|
||||
behavior: .button(
|
||||
isEnabled: { [weak store, weak zoom, weak session] in
|
||||
guard let zoom, let session else { return false }
|
||||
return ZoomCommands.isEnabled(store: store, session: session) && zoom.canZoomOut
|
||||
},
|
||||
perform: { [weak zoom] in zoom?.step(.out) }
|
||||
)
|
||||
),
|
||||
.staticLabel(
|
||||
"Undo",
|
||||
identifier: .boardUndo,
|
||||
@@ -132,10 +176,15 @@ enum BoardToolbar {
|
||||
/// The window's toolbar, wired to tell the search presentation where its field currently lives —
|
||||
/// which is the whole input to ⌘F's transient fallback (03: "with the field removed from the
|
||||
/// toolbar, invoking it surfaces the field transiently until the search clears").
|
||||
static func controller(store: BoardStore, search: BoardSearchPresentation) -> WindowToolbarController {
|
||||
static func controller(
|
||||
store: BoardStore,
|
||||
search: BoardSearchPresentation,
|
||||
zoom: BoardZoomStore,
|
||||
session: DragSession
|
||||
) -> WindowToolbarController {
|
||||
let controller = WindowToolbarController(
|
||||
identifier: identifier,
|
||||
specs: specs(store: store, search: search),
|
||||
specs: specs(store: store, search: search, zoom: zoom, session: session),
|
||||
defaults: defaultItems
|
||||
)
|
||||
// Centered against the window, not a flexible-space sandwich (03 ▸ Toolbar's placement
|
||||
|
||||
@@ -111,6 +111,11 @@ struct BoardView: View {
|
||||
/// `Accommodations.frost`).
|
||||
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||||
|
||||
/// **The board's ruler** (03-board-ui.md ▸ Layout — zoom; `BoardZoom`). Injected on this view by
|
||||
/// `BoardWindowHost` and read all the way down the strip; every `BoardMetrics` figure below takes
|
||||
/// `zoom.bodyPointSize` rather than the system's, which is the whole of what View ▸ Zoom In does.
|
||||
@Environment(\.boardZoom) private var zoom
|
||||
|
||||
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
|
||||
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
|
||||
/// must go back to meaning create/rename rather than nothing at all.
|
||||
@@ -124,7 +129,7 @@ struct BoardView: View {
|
||||
/// size widens the gap and therefore *narrows* every lane, since the window's width still divides
|
||||
/// across `units + 1` gaps. The strip never grows and never scrolls; the lanes compress, "the
|
||||
/// degenerate case accepted, not floored" (03-board-ui.md § Layout — full visibility).
|
||||
private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: BoardMetrics.bodyPointSize) }
|
||||
private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: zoom.bodyPointSize) }
|
||||
|
||||
var body: some View {
|
||||
// The strip's own body count (`BoardRenderMetrics`) — DEBUG only, and the discriminator
|
||||
@@ -160,6 +165,22 @@ struct BoardView: View {
|
||||
// rather than into `@State` so the drop delegates read it live at event time rather than
|
||||
// as of the last body evaluation.
|
||||
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { laneDrops.stripFrame = $0 }
|
||||
// The board's ruler, into the registry beside the strip's frame and for its reason: the
|
||||
// drop delegates run at event time, outside any body, and `nominalCardHeight` — the
|
||||
// stand-in they tile un-measured rows with — has to be computed on the zoom the board is
|
||||
// actually drawing at (03-board-ui.md ▸ Layout — zoom). `initial: true` because the first
|
||||
// render is already a level, not a change.
|
||||
.onChange(of: zoom.bodyPointSize, initial: true) {
|
||||
laneDrops.bodyPointSize = zoom.bodyPointSize
|
||||
// And the resting layouts built on the old ruler go with it. `RestingLayoutCache`'s
|
||||
// entry key is snapshot generation, heights generation, board root and hidden set —
|
||||
// deliberately not the point size, because until zoom existed the point size could
|
||||
// not move. This is what keeps that key honest rather than adding a fifth term to it:
|
||||
// a level change is rare, a cache miss costs one rebuild, and `ZoomCommands` already
|
||||
// holds the rows shut while a drag is in flight, so in practice there is nothing
|
||||
// standing here to clear.
|
||||
dropContext.session.restingLayouts.clear()
|
||||
}
|
||||
// **The strip's drop target** — the backdrop, the gaps, the outer margin, and the trash
|
||||
// column's footprint, which is never a landing spot of its own (04-interactions.md ▸ The
|
||||
// trash) and so simply falls through to here. It accepts *every* session type — ours and
|
||||
@@ -324,7 +345,7 @@ struct BoardView: View {
|
||||
// One of the drag's N contiguous shadows, at the exact width the arriving lane
|
||||
// will occupy — its units measured against *this* strip's standard, which is
|
||||
// what makes the drop land precisely where the shadow shows.
|
||||
DragShadow(cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: BoardMetrics.bodyPointSize))
|
||||
DragShadow(cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: zoom.bodyPointSize))
|
||||
.frame(width: LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing))
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
@@ -568,7 +589,7 @@ struct BoardView: View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if resizing {
|
||||
DragShadow(
|
||||
cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: BoardMetrics.bodyPointSize),
|
||||
cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: zoom.bodyPointSize),
|
||||
dashed: false
|
||||
)
|
||||
.frame(width: slotWidth)
|
||||
|
||||
@@ -146,10 +146,16 @@ struct CardFaceView: View, Equatable {
|
||||
/// `Accommodations`, which owns what "increased" does to a stroke.
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
/// The board's ruler (03-board-ui.md ▸ Layout — zoom; `BoardZoom`). **The environment is what
|
||||
/// makes zoom reach a card face at all**: this view is `.equatable()`, and the gate above compares
|
||||
/// nothing that moves with the level — but it does not compare environment values either, because
|
||||
/// SwiftUI invalidates on those itself. A level threaded any other way would be swallowed here.
|
||||
@Environment(\.boardZoom) private var zoom
|
||||
|
||||
/// The live body metric — every figure this face lays out on is a multiple of it
|
||||
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule). Read here rather than
|
||||
/// passed in, which is `CardAttachmentsSection`'s pattern on the card-window side.
|
||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||
private var pointSize: CGFloat { zoom.bodyPointSize }
|
||||
|
||||
/// The plate's corner radius — shared with the accent stripe, which rounds its left corners to
|
||||
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
|
||||
@@ -400,7 +406,7 @@ struct CardFaceView: View, Equatable {
|
||||
// The dragged items' sizes, frozen at drag start — the pickup transition scales the
|
||||
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
||||
// span-cap (03-board-ui.md § Motion).
|
||||
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
|
||||
heights: ordered.map { drops.registry.heights[$0] ?? drops.registry.nominalCardHeight },
|
||||
container: .board,
|
||||
source: store
|
||||
)
|
||||
@@ -447,7 +453,7 @@ struct CardFaceView: View, Equatable {
|
||||
drops.session.beginCards(
|
||||
rows.map(\.id),
|
||||
folders: payload.folders,
|
||||
heights: rows.map { drops.registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight },
|
||||
heights: rows.map { drops.registry.heights[$0.id] ?? drops.registry.nominalCardHeight },
|
||||
container: .trash,
|
||||
source: store,
|
||||
mixesKinds: mixesKinds
|
||||
@@ -492,7 +498,7 @@ struct CardFaceView: View, Equatable {
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.body)
|
||||
.boardFont(.body)
|
||||
.lineLimit(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
attachmentsIndicator
|
||||
@@ -512,7 +518,7 @@ struct CardFaceView: View, Equatable {
|
||||
)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(BoardSurface.cardPlate))
|
||||
.overlay(alignment: .leading) { accentStripe }
|
||||
.dragReplicaShadow()
|
||||
.dragReplicaShadow(zoom: zoom)
|
||||
}
|
||||
|
||||
// MARK: - Context menus
|
||||
@@ -682,10 +688,10 @@ struct CardFaceView: View, Equatable {
|
||||
if case let .board(openCard) = role { openCard(id) }
|
||||
}
|
||||
)
|
||||
.font(.body)
|
||||
.boardFont(.body)
|
||||
} else {
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.body)
|
||||
.boardFont(.body)
|
||||
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
||||
.lineLimit(4)
|
||||
}
|
||||
@@ -715,7 +721,7 @@ struct CardFaceView: View, Equatable {
|
||||
private var attachmentsIndicator: some View {
|
||||
if !card.attachments.isEmpty {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.caption)
|
||||
.boardFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
@@ -50,22 +50,47 @@ enum DragAutoScrollMath {
|
||||
|
||||
/// How far above the visible area the pointer may sit and still drive it — enough to cover the
|
||||
/// lane's header, which is where a drag naturally goes to scroll up.
|
||||
static let reachAbove: CGFloat = 48
|
||||
///
|
||||
/// **Font-derived, unlike the three figures above it, and the split is the point.** `band`,
|
||||
/// `minSpeed` and `maxSpeed` describe the *cursor's* relationship to an edge — how near counts as
|
||||
/// near, how fast the content should answer — and none of that changes because the board is drawn
|
||||
/// larger; they stay fixed for `LaneResizeSession.reentry`'s reason. The three reaches describe
|
||||
/// **where the lane's furniture is**, and that furniture moves: this one is specified as "enough
|
||||
/// to cover the lane's header", and a header at 200% zoom is twice as tall as the 48 points that
|
||||
/// covered it at 13pt (03-board-ui.md ▸ Layout — zoom).
|
||||
///
|
||||
/// 3.7 em: 48 points at the standard body size, which is what the reach has always been.
|
||||
static func reachAbove(bodyPointSize: CGFloat) -> CGFloat {
|
||||
BoardMetrics.em(3.7, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The same below, covering the lane's bottom padding.
|
||||
static let reachBelow: CGFloat = 24
|
||||
/// The same below, covering the lane's bottom padding — 1.85 em, the standard size's 24 points.
|
||||
static func reachBelow(bodyPointSize: CGFloat) -> CGFloat {
|
||||
BoardMetrics.em(1.85, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The sideways reach — kept under half the distance between two lanes' scroll areas so only
|
||||
/// one lane ever engages.
|
||||
static let reachSide: CGFloat = 12
|
||||
///
|
||||
/// **It is the inter-lane gap, named as itself** rather than as a coincidentally equal number:
|
||||
/// the distance between two lanes' scroll areas is the gap plus a plate padding on each side
|
||||
/// (0.9 + 2 × 0.45 em), so half of it is exactly `stripGap`. Writing it that way is what keeps
|
||||
/// the sentence above true at every zoom level instead of only at 13pt, where 12 happened to be
|
||||
/// the answer.
|
||||
static func reachSide(bodyPointSize: CGFloat) -> CGFloat {
|
||||
BoardMetrics.stripGap(bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The region — in the visible area's own coordinates, `(0, 0)` at its top-left — a pointer
|
||||
/// must be in to drive this scroller at all.
|
||||
static func engagementRect(viewport: CGSize) -> CGRect {
|
||||
CGRect(x: -reachSide,
|
||||
y: -reachAbove,
|
||||
width: viewport.width + reachSide * 2,
|
||||
height: viewport.height + reachAbove + reachBelow)
|
||||
static func engagementRect(viewport: CGSize, bodyPointSize: CGFloat) -> CGRect {
|
||||
let above = reachAbove(bodyPointSize: bodyPointSize)
|
||||
let below = reachBelow(bodyPointSize: bodyPointSize)
|
||||
let side = reachSide(bodyPointSize: bodyPointSize)
|
||||
return CGRect(x: -side,
|
||||
y: -above,
|
||||
width: viewport.width + side * 2,
|
||||
height: viewport.height + above + below)
|
||||
}
|
||||
|
||||
/// Signed scroll velocity in points/second for a pointer at `position` along an axis whose
|
||||
|
||||
@@ -40,6 +40,20 @@ final class DragAutoScroller {
|
||||
/// Invoked after every scroll step. Re-resolves the drop proposal; see the type's note.
|
||||
fileprivate var didScroll: (() -> Void)?
|
||||
|
||||
/// The board's ruler, for the engagement rect's three reaches — they are distances to the lane's
|
||||
/// own furniture, which moves with the zoom level (`DragAutoScrollMath.reachAbove`;
|
||||
/// 03-board-ui.md ▸ Layout — zoom).
|
||||
///
|
||||
/// Set by the lane at the head of each drag rather than observed, because that is exactly when it
|
||||
/// can change: zoom is inert while a session is in flight (`ZoomCommands.isEnabled`), so one read
|
||||
/// per drag is one read per level.
|
||||
///
|
||||
/// Optional, and resolved at use rather than defaulted in `init`, because the initialiser is
|
||||
/// `nonisolated` (the tick-source seam is constructed off the main actor in tests) and the system
|
||||
/// body size is a main-actor read. Un-set means "nobody has told me", which resolves to the
|
||||
/// system's ruler — an un-zoomed board's answer.
|
||||
var bodyPointSize: CGFloat?
|
||||
|
||||
/// Where frames come from. One implementation ships (`DisplayLinkTickSource`); the seam exists
|
||||
/// because a `CADisplayLink` needs a screen and a run loop and so cannot tick in a test bundle
|
||||
/// (`DragAutoScrollDriverTests`).
|
||||
@@ -81,7 +95,9 @@ final class DragAutoScroller {
|
||||
let inWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation)
|
||||
let inClip = clip.convert(inWindow, from: nil)
|
||||
let pointer = CGPoint(x: inClip.x - visible.minX, y: inClip.y - visible.minY)
|
||||
guard DragAutoScrollMath.engagementRect(viewport: visible.size).contains(pointer) else { return }
|
||||
let ruler = bodyPointSize ?? BoardMetrics.bodyPointSize
|
||||
guard DragAutoScrollMath.engagementRect(viewport: visible.size, bodyPointSize: ruler)
|
||||
.contains(pointer) else { return }
|
||||
|
||||
let velocity = DragAutoScrollMath.velocity(pointer: pointer, viewport: visible.size)
|
||||
guard velocity.dx != 0 || velocity.dy != 0 else { return }
|
||||
|
||||
@@ -57,15 +57,19 @@ struct DragCountBadge: View {
|
||||
|
||||
let count: Int
|
||||
|
||||
/// The board's ruler (`BoardZoom`) — the badge sits over the shadow of a card drawn at this zoom,
|
||||
/// so it has to be sized on the same one.
|
||||
@Environment(\.boardZoom) private var zoom
|
||||
|
||||
/// The badge is a disc around a numeral, so every figure in it follows the numeral's font
|
||||
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule) — at the standard body size
|
||||
/// they are the 6, 3 and 10 points it has always drawn.
|
||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||
private var pointSize: CGFloat { zoom.bodyPointSize }
|
||||
|
||||
var body: some View {
|
||||
if count > 1 {
|
||||
Text("\(count)")
|
||||
.font(.caption2.bold())
|
||||
.boardFont(.caption2, weight: .bold)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, BoardMetrics.em(0.45, bodyPointSize: pointSize))
|
||||
@@ -94,7 +98,19 @@ enum DragReplicaStyle {
|
||||
extension View {
|
||||
/// The pickup lift's shadow (`DragReplicaStyle`), for a drag replica's face — shared by the
|
||||
/// card, lane, and trashed-lane replicas so the figures are spelled once.
|
||||
func dragReplicaShadow() -> some View {
|
||||
shadow(color: DragReplicaStyle.shadowColor, radius: DragReplicaStyle.shadowRadius, y: DragReplicaStyle.shadowY)
|
||||
///
|
||||
/// **It re-asserts the board's zoom, and that is not decoration.** A `.onDrag(_:preview:)`
|
||||
/// preview is hosted for the drag *image*, outside the strip's view hierarchy, so the environment
|
||||
/// the replica's own `.boardFont(_:)` modifiers resolve against is not reliably the board's. The
|
||||
/// replica must be drawn on the same ruler as the face it was lifted from — otherwise the image
|
||||
/// under the cursor is a card the board does not contain, which is the exact failure
|
||||
/// `BoardMetrics.cardReplicaWidth(measured:)` exists to prevent on the width axis. Passing the
|
||||
/// level in explicitly closes the type axis the same way.
|
||||
///
|
||||
/// It rides on this modifier rather than being a fourth line in each replica for the reason the
|
||||
/// shadow does: three replicas, one rule, spelled once.
|
||||
func dragReplicaShadow(zoom: BoardZoomContext) -> some View {
|
||||
environment(\.boardZoom, zoom)
|
||||
.shadow(color: DragReplicaStyle.shadowColor, radius: DragReplicaStyle.shadowRadius, y: DragReplicaStyle.shadowY)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,14 +38,18 @@ struct LaneResizeHandle: View {
|
||||
/// cursor.
|
||||
@State private var cursorPushed = false
|
||||
|
||||
/// The board's ruler (`BoardZoom`) — the gap this strip is proportioned against moves with the
|
||||
/// level, so the grab target has to move with it too or it drifts off the gap it lives in.
|
||||
@Environment(\.boardZoom) private var zoom
|
||||
|
||||
/// The grab strip's width and its rightward shift — both font-derived, because the inter-lane
|
||||
/// gap they are proportioned against is (`BoardMetrics.stripGap`, 10-accessibility.md's
|
||||
/// full-relative-scaling rule). At the standard body size they are the 12pt and 8pt the strip
|
||||
/// has always used: with the strip trailing-aligned, +8 leaves 4pt over the lane and hangs 8pt
|
||||
/// into the gap (clear of the lane's own scrollbar).
|
||||
private var handleWidth: CGFloat { BoardMetrics.resizeHandleWidth(bodyPointSize: BoardMetrics.bodyPointSize) }
|
||||
private var handleWidth: CGFloat { BoardMetrics.resizeHandleWidth(bodyPointSize: zoom.bodyPointSize) }
|
||||
|
||||
private var overhang: CGFloat { BoardMetrics.resizeHandleOverhang(bodyPointSize: BoardMetrics.bodyPointSize) }
|
||||
private var overhang: CGFloat { BoardMetrics.resizeHandleOverhang(bodyPointSize: zoom.bodyPointSize) }
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
|
||||
@@ -94,9 +94,14 @@ struct LaneView: View, Equatable {
|
||||
/// against the colours of the appearance the window is now in.
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
/// The board's ruler (03-board-ui.md ▸ Layout — zoom; `BoardZoom`), injected on the strip by
|
||||
/// `BoardWindowHost`. An `@Environment` read rather than a value passed down deliberately: this
|
||||
/// view is `.equatable()`, and environment values are the one input the gate cannot suppress.
|
||||
@Environment(\.boardZoom) private var zoom
|
||||
|
||||
/// The live body metric — every figure this lane lays out on is a multiple of it
|
||||
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule).
|
||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule), at the board's zoom.
|
||||
private var pointSize: CGFloat { zoom.bodyPointSize }
|
||||
|
||||
/// Spacing between cards, and between the interior columns.
|
||||
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
|
||||
@@ -476,10 +481,10 @@ struct LaneView: View, Equatable {
|
||||
// and open[s] the card window", and only a card has one to open).
|
||||
onCommitAndOpen: { store.commitRename() }
|
||||
)
|
||||
.font(.headline)
|
||||
.boardFont(.headline)
|
||||
} else {
|
||||
Text(lane.title.value ?? "Untitled")
|
||||
.font(.headline)
|
||||
.boardFont(.headline)
|
||||
.foregroundStyle(lane.title.value == nil ? .secondary : .primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
@@ -502,7 +507,7 @@ struct LaneView: View, Equatable {
|
||||
/// body renders (see `renderedCards`).
|
||||
private var countBadge: some View {
|
||||
Text("\(renderedCards.count)")
|
||||
.font(.caption)
|
||||
.boardFont(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize))
|
||||
@@ -640,7 +645,7 @@ struct LaneView: View, Equatable {
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.body)
|
||||
.boardFont(.body)
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
@@ -663,7 +668,7 @@ struct LaneView: View, Equatable {
|
||||
.background(lanePlate)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
.dragReplicaShadow()
|
||||
.dragReplicaShadow(zoom: zoom)
|
||||
}
|
||||
|
||||
/// The replica's size — **the lane's own**, floored for a lane that has not measured itself yet
|
||||
@@ -841,6 +846,10 @@ struct LaneView: View, Equatable {
|
||||
// engagement rect simply scrolls nothing.
|
||||
.task(id: drops.session.isDraggingCards) {
|
||||
guard drops.session.isDraggingCards else { return }
|
||||
// The engagement rect's reaches are distances to this lane's own furniture, so they are
|
||||
// measured on the board's ruler (03-board-ui.md ▸ Layout — zoom). Read once at the head
|
||||
// of the drag, which is once per level: zoom is inert while a session is in flight.
|
||||
autoScroller.bodyPointSize = pointSize
|
||||
await autoScroller.run()
|
||||
}
|
||||
}
|
||||
@@ -867,7 +876,7 @@ struct LaneView: View, Equatable {
|
||||
if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: lane.id) {
|
||||
return ShadowRun(
|
||||
position: proposal.index,
|
||||
heights: Array(repeating: LaneDropRegistry.nominalCardHeight, count: proposal.count)
|
||||
heights: Array(repeating: drops.registry.nominalCardHeight, count: proposal.count)
|
||||
)
|
||||
}
|
||||
return nil
|
||||
@@ -1162,9 +1171,13 @@ private struct NewCardStubView: View {
|
||||
/// Increase Contrast, for the editor well's stroke below (10-accessibility.md; `Accommodations`).
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
/// The board's ruler (`BoardZoom`) — the same environment the real face reads, so the placeholder
|
||||
/// and the card that replaces it are drawn at one zoom.
|
||||
@Environment(\.boardZoom) private var zoom
|
||||
|
||||
/// The live body metric — the same one the real face reads, which is what makes "the numbers are
|
||||
/// the same numbers rather than equal ones" survive the move to font-derived metrics.
|
||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||
private var pointSize: CGFloat { zoom.bodyPointSize }
|
||||
|
||||
var body: some View {
|
||||
switch phase {
|
||||
@@ -1189,7 +1202,7 @@ private struct NewCardStubView: View {
|
||||
if let id = commit() { openCard(id) }
|
||||
}
|
||||
)
|
||||
.font(.body)
|
||||
.boardFont(.body)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
.background(
|
||||
@@ -1221,7 +1234,7 @@ private struct NewCardStubView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
Text(store.transient.newCardPlaceholder?.draftTitle ?? "")
|
||||
.font(.body)
|
||||
.boardFont(.body)
|
||||
.lineLimit(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,11 @@ struct TrashLaneRowView: View {
|
||||
/// selected card wear the same ring at the same strength.
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||
/// The board's ruler (`BoardZoom`) — `CardFaceView`'s rule again, so a trash row and a card face
|
||||
/// are drawn on one scale.
|
||||
@Environment(\.boardZoom) private var zoom
|
||||
|
||||
private var pointSize: CGFloat { zoom.bodyPointSize }
|
||||
|
||||
/// The card plate's radius: the rows sit in one column and a row with a different corner would
|
||||
/// read as a different *kind of surface* rather than as a different kind of row. What
|
||||
@@ -95,7 +99,7 @@ struct TrashLaneRowView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
Text(lane.title.value ?? AccessibilityPhrases.untitled)
|
||||
.font(.body)
|
||||
.boardFont(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
@@ -159,7 +163,7 @@ struct TrashLaneRowView: View {
|
||||
/// furniture, and this row is deliberately not one.
|
||||
private var heldCount: some View {
|
||||
Text(AccessibilityPhrases.cardCount(lane.heldCards))
|
||||
.font(.caption)
|
||||
.boardFont(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.tertiary)
|
||||
// Folded into the flattened element's label above, like the card face's chips.
|
||||
@@ -264,12 +268,12 @@ struct TrashLaneRowView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
Text(lane.title.value ?? AccessibilityPhrases.untitled)
|
||||
.font(.body)
|
||||
.boardFont(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(AccessibilityPhrases.cardCount(lane.heldCards))
|
||||
.font(.caption)
|
||||
.boardFont(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
@@ -279,7 +283,7 @@ struct TrashLaneRowView: View {
|
||||
alignment: .leading
|
||||
)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.tertiary))
|
||||
.dragReplicaShadow()
|
||||
.dragReplicaShadow(zoom: zoom)
|
||||
}
|
||||
|
||||
// MARK: - The row's two rows of menu
|
||||
|
||||
@@ -113,10 +113,14 @@ struct TrashLaneView: View {
|
||||
/// light/dark flip recomputes the header's ink (`LaneView.colorScheme`'s twin, for its reason).
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
/// The board's ruler (`BoardZoom`) — the same one the live lanes read, since the trash column has
|
||||
/// to stay their sibling at every zoom level as well as at every text size.
|
||||
@Environment(\.boardZoom) private var zoom
|
||||
|
||||
/// The live body metric — this column's geometry is `LaneView`'s, derived from the same font
|
||||
/// (`BoardMetrics`), because these are the same cards in a column that must read as their
|
||||
/// sibling.
|
||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||
private var pointSize: CGFloat { zoom.bodyPointSize }
|
||||
|
||||
/// The lane plate's corner radius — matched to `LaneView`'s so the column reads as a sibling of
|
||||
/// the lanes rather than as a different kind of object.
|
||||
@@ -235,7 +239,7 @@ struct TrashLaneView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
Text("Trash")
|
||||
.font(.headline)
|
||||
.boardFont(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
@@ -292,7 +296,7 @@ struct TrashLaneView: View {
|
||||
/// the difference between a card and a lane's freight actually matters.
|
||||
private var countBadge: some View {
|
||||
Text("\(renderedRows.count)")
|
||||
.font(.caption)
|
||||
.boardFont(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize))
|
||||
@@ -360,7 +364,7 @@ struct TrashLaneView: View {
|
||||
// being proposed have no face here yet to be measured.
|
||||
DragShadow(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: LaneDropRegistry.nominalCardHeight)
|
||||
.frame(height: drops.registry.nominalCardHeight)
|
||||
}
|
||||
}
|
||||
// A slot is a row in this column, so it arrives and leaves in the card's dialect
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user