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:
+122
-16
@@ -31,6 +31,28 @@ private extension Array where Element == ToolbarItemSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// A zoom store on a scratch defaults domain — the level is app-wide and persisted, so a suite that
|
||||
/// used `.standard` would zoom the developer's own boards (`StyleRecents`' injection, for its reason).
|
||||
@MainActor
|
||||
private func makeZoom(level: CGFloat = BoardZoom.actualSize) -> BoardZoomStore {
|
||||
let name = "dev.rzen.indie.Kanban.toolbar-tests.\(UUID().uuidString)"
|
||||
let store = BoardZoomStore(defaults: UserDefaults(suiteName: name)!)
|
||||
store.setLevel(level)
|
||||
return store
|
||||
}
|
||||
|
||||
/// The board catalog, with the two collaborators every test here supplies the same way: a fresh zoom
|
||||
/// store and a drag session with nothing in flight.
|
||||
@MainActor
|
||||
private func boardSpecs(
|
||||
store: BoardStore,
|
||||
search: BoardSearchPresentation = BoardSearchPresentation(),
|
||||
zoom: BoardZoomStore? = nil,
|
||||
session: DragSession = DragSession()
|
||||
) -> [ToolbarItemSpec] {
|
||||
BoardToolbar.specs(store: store, search: search, zoom: zoom ?? makeZoom(), session: session)
|
||||
}
|
||||
|
||||
// MARK: - The vocabulary
|
||||
|
||||
/// **Toolbar item labels are menu titles minus a trailing ellipsis** (03-board-ui.md ▸ Toolbar) —
|
||||
@@ -86,18 +108,21 @@ struct BoardToolbarTests {
|
||||
#expect(BoardToolbar.defaultItems.filter { $0 != .flexibleSpace } == [.boardSearch])
|
||||
}
|
||||
|
||||
@Test("The catalog is 03's five commands plus the field — and the board popover is not in it")
|
||||
@Test("The catalog is 03's seven commands plus the field — and the board popover is not in it")
|
||||
func catalogIsTheDesignsInventory() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
|
||||
let specs = boardSpecs(store: store)
|
||||
|
||||
// "Catalog (available via Customize): New Card, New Lane, Undo, Redo …, Show Trash" — plus
|
||||
// the search field, which is a catalog item too (a user who removes it can put it back).
|
||||
// "Catalog (available via Customize): New Card, New Lane, Zoom In, Zoom Out …, Undo, Redo …,
|
||||
// Show Trash" — plus the search field, which is a catalog item too (a user who removes it can
|
||||
// put it back).
|
||||
#expect(specs.map(\.identifier) == [
|
||||
.boardNewCard,
|
||||
.boardNewLane,
|
||||
.boardZoomIn,
|
||||
.boardZoomOut,
|
||||
.boardUndo,
|
||||
.boardRedo,
|
||||
.boardShowTrash,
|
||||
@@ -106,7 +131,86 @@ struct BoardToolbarTests {
|
||||
// "The board popover deliberately has no toolbar item — the window-title widget is its
|
||||
// committed home, and a second entry would muddy it." Absence is a settlement, so it is
|
||||
// pinned by the exact-inventory assertion above and stated again here.
|
||||
#expect(specs.count == 6)
|
||||
#expect(specs.count == 8)
|
||||
}
|
||||
|
||||
/// The zoom pair is catalog-only — "the titlebar's default stays the search field alone"
|
||||
/// (03-board-ui.md ▸ Toolbar). Stated separately from the default-set test because this is the
|
||||
/// claim a future item is most likely to break by helpfully adding itself.
|
||||
@Test("Zoom In and Zoom Out are available but never default")
|
||||
func zoomIsCatalogOnly() {
|
||||
#expect(!BoardToolbar.defaultItems.contains(.boardZoomIn))
|
||||
#expect(!BoardToolbar.defaultItems.contains(.boardZoomOut))
|
||||
}
|
||||
|
||||
/// There is deliberately no Actual Size item: it is a menu row only. Two buttons are the whole
|
||||
/// of what a toolbar can usefully offer for a ladder with no readout — a third that resets is
|
||||
/// titlebar clutter for a chord.
|
||||
@Test("Actual Size has no toolbar item")
|
||||
func actualSizeIsMenuOnly() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
#expect(!boardSpecs(store: store).contains { $0.label == "Actual Size" })
|
||||
}
|
||||
|
||||
/// Each button mirrors its menu row's ladder end, which is the same predicate `ZoomCommands`
|
||||
/// disables on — one answer, two faces (03-board-ui.md ▸ Toolbar).
|
||||
@Test("The zoom buttons disable at their own end of the ladder")
|
||||
func zoomButtonsMirrorTheLadderEnds() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
// Every collaborator a spec's `isEnabled` interrogates lives in a local for the whole
|
||||
// test, exactly as `store` already does: the closures capture them weakly — production
|
||||
// hands them app-lived objects — so a temporary reads as uniformly disabled.
|
||||
let session = DragSession()
|
||||
let topZoom = makeZoom(level: BoardZoom.levels.last!)
|
||||
let top = boardSpecs(store: store, zoom: topZoom, session: session)
|
||||
#expect(try !#require(top.spec(.boardZoomIn)).isEnabled)
|
||||
#expect(try #require(top.spec(.boardZoomOut)).isEnabled)
|
||||
|
||||
let bottomZoom = makeZoom(level: BoardZoom.levels.first!)
|
||||
let bottom = boardSpecs(store: store, zoom: bottomZoom, session: session)
|
||||
#expect(try #require(bottom.spec(.boardZoomIn)).isEnabled)
|
||||
#expect(try !#require(bottom.spec(.boardZoomOut)).isEnabled)
|
||||
|
||||
let middleZoom = makeZoom()
|
||||
let middle = boardSpecs(store: store, zoom: middleZoom, session: session)
|
||||
#expect(try #require(middle.spec(.boardZoomIn)).isEnabled)
|
||||
#expect(try #require(middle.spec(.boardZoomOut)).isEnabled)
|
||||
}
|
||||
|
||||
/// Both are push buttons, and both go dead while a drag is in flight — the menu rows' guard,
|
||||
/// reached through the very same predicate (`ZoomCommands.isEnabled`).
|
||||
@Test("The zoom buttons are push buttons, and hold shut mid-drag")
|
||||
func zoomButtonsHoldShutMidDrag() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
// Held in locals for the reason `zoomButtonsMirrorTheLadderEnds` spells out — and the
|
||||
// dragging half asks with the same live zoom, so its disabled answer is the drag guard's
|
||||
// rather than a dead weak reference's.
|
||||
let zoom = makeZoom()
|
||||
let restingSession = DragSession()
|
||||
let resting = boardSpecs(store: store, zoom: zoom, session: restingSession)
|
||||
for identifier in [NSToolbarItem.Identifier.boardZoomIn, .boardZoomOut] {
|
||||
let spec = try #require(resting.spec(identifier))
|
||||
#expect(spec.isOn == nil, "\(spec.label) is a push button, not a toggle")
|
||||
#expect(spec.isEnabled)
|
||||
}
|
||||
|
||||
let session = DragSession()
|
||||
let folder = store.rootURL.appendingPathComponent(Ident.card1, isDirectory: true)
|
||||
session.beginCards([ItemID(rawValue: Ident.card1)], folders: [folder], heights: [44],
|
||||
container: .board, source: store)
|
||||
let dragging = boardSpecs(store: store, zoom: zoom, session: session)
|
||||
for identifier in [NSToolbarItem.Identifier.boardZoomIn, .boardZoomOut] {
|
||||
#expect(try !#require(dragging.spec(identifier)).isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Every label is its menu row's title, and Undo/Redo keep static ones")
|
||||
@@ -114,9 +218,11 @@ struct BoardToolbarTests {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
|
||||
let specs = boardSpecs(store: store)
|
||||
|
||||
#expect(specs.map(\.label) == ["New Card", "New Lane", "Undo", "Redo", "Show Trash", "Search"])
|
||||
#expect(specs.map(\.label) == [
|
||||
"New Card", "New Lane", "Zoom In", "Zoom Out", "Undo", "Redo", "Show Trash", "Search",
|
||||
])
|
||||
// The one exception 03 names: "the Undo/Redo toolbar items keep static labels —
|
||||
// NSUndoManager rewrites their menu titles dynamically ('Undo Move Card…'), which a toolbar
|
||||
// label doesn't track". They are also the two items with no action of their own: nil target,
|
||||
@@ -137,7 +243,7 @@ struct BoardToolbarTests {
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
for spec in BoardToolbar.specs(store: store, search: BoardSearchPresentation()) {
|
||||
for spec in boardSpecs(store: store) {
|
||||
guard let symbol = spec.symbol else { continue }
|
||||
#expect(ItemSymbol.exists(symbol), "\(spec.label) draws a symbol this OS does not have")
|
||||
}
|
||||
@@ -151,13 +257,13 @@ struct BoardToolbarTests {
|
||||
defer { empty.tearDown() }
|
||||
|
||||
let store = try BoardStore(rootURL: populated.root)
|
||||
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
|
||||
let specs = boardSpecs(store: store)
|
||||
let newCard = try #require(specs.spec(.boardNewCard))
|
||||
#expect(newCard.isEnabled)
|
||||
#expect(newCard.isOn == nil, "New Card is a push button, not a toggle")
|
||||
|
||||
let emptyStore = try BoardStore(rootURL: empty.root)
|
||||
let emptySpecs = BoardToolbar.specs(store: emptyStore, search: BoardSearchPresentation())
|
||||
let emptySpecs = boardSpecs(store: emptyStore)
|
||||
let disabled = try #require(emptySpecs.spec(.boardNewCard))
|
||||
#expect(!disabled.isEnabled, "the zero-lane board disables the row, so it disables the item")
|
||||
#expect(emptyStore.newCardTarget == nil, "one predicate, read by both")
|
||||
@@ -169,7 +275,7 @@ struct BoardToolbarTests {
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let search = BoardSearchPresentation()
|
||||
let controller = BoardToolbar.controller(store: store, search: search)
|
||||
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), session: DragSession())
|
||||
|
||||
// 03's three customization sentences: "right-click ▸ Customize Toolbar…, drag to rearrange,
|
||||
// system overflow and icon/text display options".
|
||||
@@ -181,7 +287,7 @@ struct BoardToolbarTests {
|
||||
#expect(allowed.contains(.flexibleSpace) && allowed.contains(.space), "the palette's spacers")
|
||||
#expect(controller.toolbarDefaultItemIdentifiers(controller.toolbar) == BoardToolbar.defaultItems)
|
||||
|
||||
for spec in BoardToolbar.specs(store: store, search: BoardSearchPresentation()) {
|
||||
for spec in boardSpecs(store: store) {
|
||||
let item = try #require(controller.toolbar(
|
||||
controller.toolbar,
|
||||
itemForItemIdentifier: spec.identifier,
|
||||
@@ -198,7 +304,7 @@ struct BoardToolbarTests {
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let search = BoardSearchPresentation()
|
||||
let controller = BoardToolbar.controller(store: store, search: search)
|
||||
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), session: DragSession())
|
||||
|
||||
#expect(search.focusField == nil, "nothing to focus until the item exists")
|
||||
|
||||
@@ -233,7 +339,7 @@ struct BoardToolbarTests {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation())
|
||||
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation(), zoom: makeZoom(), session: DragSession())
|
||||
|
||||
let item = try #require(controller.toolbar(
|
||||
controller.toolbar,
|
||||
@@ -267,7 +373,7 @@ struct BoardToolbarTests {
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let search = BoardSearchPresentation()
|
||||
let controller = BoardToolbar.controller(store: store, search: search)
|
||||
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), session: DragSession())
|
||||
|
||||
_ = controller.toolbar(
|
||||
controller.toolbar,
|
||||
@@ -290,7 +396,7 @@ struct BoardToolbarTests {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
|
||||
let specs = boardSpecs(store: store)
|
||||
let showTrash = try #require(specs.spec(.boardShowTrash))
|
||||
|
||||
#expect(showTrash.isOn == false, "hidden by default, like the menu row's checkmark")
|
||||
|
||||
Reference in New Issue
Block a user