Files
lanework/Kanban/UI/Motion.swift
T
rzen 06ee59e24b Implement toolbar customization for both windows
NSToolbar through the existing HostedWindowController rather than
SwiftUI's toolbar — for reasons that are contract, not taste: 03's
transient-search clause is a decision over the toolbar's current
contents, which NSToolbar publishes and SwiftUI's API cannot answer;
Undo/Redo are the system's nil-target responder-chain actions so the
toolbar items validate exactly as the menu rows do (disabled on base
boards, alive in m8 unchanged); and the search item hosts the real
NSSearchField with explicit first-responder control. Customization is
all system furniture — Customize sheet, drag rearrange, display-mode
popup, overflow, autosaved per window kind. Board default: the search
field alone, trailing; catalog adds New Card, New Lane, Undo, Redo,
Show Trash, every action extracted from its menu command so no second
predicate exists. Card default: the Edit Body / Raw Source toggles and
Add Attachment, mirroring their commands' own predicates live via
observation tracking. Removing the search item keeps the promise —
⌘F surfaces the same field as a transient strip under the title bar,
persisting until the query clears, and an overflowed item that cannot
take the keyboard falls through to the strip too.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 12:57:42 -04:00

284 lines
16 KiB
Swift

import AppKit
import QuartzCore
import SwiftUI
/// The app's one animation surface — every curve, duration and appear/disappear transition it uses
/// is named here (03-board-ui.md § Motion: "The rewrite gives the vocabulary the one thing the
/// pathfinder lacked: a single named home (one animation-constants surface), so curves and durations
/// stop being per-site literals").
///
/// **The rule this type exists to enforce: no call site anywhere spells a duration, a spring, a
/// timing function, or a transition of its own.** A site that needs motion asks for it by *meaning*
/// — `Motion.laneResize`, `Motion.cardTransition` — and the meaning is defined once, here.
///
/// ### Two voices, semantically split
///
/// - **Structural** (snappy springs) is the positional voice: drag reflow (~0.18 s), drop commit,
/// paste, delete (~0.25 s), keyboard nudges, scroll-into-view, lane resize (~0.2 s). It is what
/// the board wears when *things move*.
/// - **Content reflow** (a smooth ~0.28 s spring) is the filtering voice: search filtering and
/// undo/redo restore, "deliberately paired so a restore reads like the search filter".
///
/// Named system presets only (`.snappy`, `.smooth`); 03 rules out hand-tuned spring parameters, so
/// there is not a `response:` or a `dampingFraction:` anywhere below.
///
/// ### Which changes animate at all
///
/// "User-initiated structural changes animate; foreign changes snap." Lanework's one-way flow means
/// a user's own action arrives back as a watcher reload like any other, so the split cannot live at
/// the call sites the pathfinder put it at — it lives at the *reload*, whose origin the store
/// already classifies. `reloadAnimates(origin:endsBracketedOperation:)` is that decision, kept pure
/// so it can be tested without a filesystem, and `BoardStore.land` is its one caller.
///
/// ### Reduce Motion
///
/// Every accessor comes in a Reduce Motion-aware form, because 10-accessibility.md makes the reduced
/// variants an obligation rather than an inheritance ("the pathfinder ships zero reduced variants").
/// The mapping is 10's own "crossfade or instant", applied by kind:
///
/// - an **animation** has no crossfade available — a reflow either eases or it does not — so its
/// reduced variant is `nil`, which `withAnimation` and `.animation(_:value:)` both read as
/// "apply instantly";
/// - a **transition** does, so its reduced variant is the opacity-only crossfade, with the scale
/// dropped.
///
/// Views read `@Environment(\.accessibilityReduceMotion)` and pass it in. Code with no environment
/// to read — the store's reload seam, the lane-resize session's window animation, a menu command's
/// action — reads `prefersReducedMotion` instead, which asks AppKit the same question.
enum Motion {
// MARK: - The numbers
/// The durations 03-board-ui.md § Motion fixes by name. Private: the whole point of this type is
/// that a duration is never read as a number except by the accessors below.
///
/// `structural` and `laneResize` are the same 0.2 s today and are still two entries, because 03
/// names them separately — one is the voice's general figure and the other is one operation's.
/// Collapsing them would make a later "lane resize is a touch slower than the rest" a change to
/// every structural site.
private enum Duration {
static let structural: TimeInterval = 0.2
static let dragReflow: TimeInterval = 0.18
static let delete: TimeInterval = 0.25
static let laneResize: TimeInterval = 0.2
static let contentReflow: TimeInterval = 0.28
}
/// "Appear/disappear is scale + fade (cards scale from ~0.8, lanes ~0.9, combined with opacity)."
private enum AppearScale {
static let card: CGFloat = 0.8
static let lane: CGFloat = 0.9
}
// MARK: - The structural voice
/// The general snappy spring — the voice every positional change wears unless 03 gives that
/// operation a figure of its own. The store's reload seam and the trash re-divide are its call
/// sites today.
static func structural(reduced: Bool) -> Animation? {
reduced ? nil : .snappy(duration: Duration.structural)
}
/// The drag's reflow-to-make-room — the siblings opening a slot under a drop proposal
/// (03 § Motion: "on the drag's **drop proposal** (the reflow-to-make-room above animates under
/// it, ~0.18 s)"). The lane reorder's sibling reflow is its call site today; m5's card drag joins
/// it, and the replica's own tracking deliberately never comes near it.
static func dragReflow(reduced: Bool) -> Animation? {
reduced ? nil : .snappy(duration: Duration.dragReflow)
}
/// Delete's slightly longer settle — the survivors' reflow as an item leaves.
///
/// **No call site yet, and that is a known gap rather than an oversight.** Delete lands through
/// the Writer and comes back as an app-mediated reload, and the reload seam cannot know *which*
/// operation echoed — see `reloadAnimates(origin:endsBracketedOperation:)`. It is named here
/// because 03 fixes the figure and because the alternative is a literal at whichever site first
/// needs it.
static func delete(reduced: Bool) -> Animation? {
reduced ? nil : .snappy(duration: Duration.delete)
}
/// The lane resize's snap tick — the SwiftUI half. The window's half is
/// `laneResizeWindowDuration`, and the two must stay on matching curves or the window edge and
/// the lanes to its right stop travelling as one (`LaneResizeSession`).
static func laneResize(reduced: Bool) -> Animation? {
reduced ? nil : .snappy(duration: Duration.laneResize)
}
/// The content-reflow voice: search filtering and undo/redo restore, "deliberately paired so a
/// restore reads like the search filter — leavers and arrivers run their transition, survivors
/// reflow under one gentle spring".
///
/// **The search filter is its call site** (`BoardView.laneStrip`), where it wraps a transaction
/// keyed on the query and nothing else — 03's own narrow key for this reflow. The leavers and
/// arrivers it talks about are `cardTransition`, already attached to every card slot and trash
/// row, so the two halves of the sentence are two modifiers rather than one bespoke animation.
///
// m7-undo: the restore is the other half of the pair. It arrives as a bracketed wholesale
// reload, so it reaches this voice through `reloadAnimation` rather than through a transaction
// of its own — see `reloadAnimates(origin:endsBracketedOperation:)`.
static func contentReflow(reduced: Bool) -> Animation? {
reduced ? nil : .smooth(duration: Duration.contentReflow)
}
// MARK: - Appear and disappear
/// The shape an appear/disappear transition takes — the *testable* half of `cardTransition` and
/// `laneTransition`.
///
/// It exists because `AnyTransition` is opaque and not `Equatable`: without this, "the reduced
/// variant is a crossfade" would be a claim no test could make, and the one thing
/// 10-accessibility.md actually commits to here is which variant a reduced-motion user gets.
enum Appearance: Equatable {
/// Scales up from `from` while fading in (and the reverse on the way out).
case scaleAndFade(from: CGFloat)
/// Slides in from `edge` while fading in, and leaves the way it came.
case slideAndFade(from: Edge)
/// Opacity only — 10-accessibility.md's "crossfade" variant.
case crossfade
var transition: AnyTransition {
switch self {
case let .scaleAndFade(scale): .scale(scale: scale).combined(with: .opacity)
case let .slideAndFade(edge): .move(edge: edge).combined(with: .opacity)
case .crossfade: .opacity
}
}
}
static func cardAppearance(reduced: Bool) -> Appearance {
reduced ? .crossfade : .scaleAndFade(from: AppearScale.card)
}
static func laneAppearance(reduced: Bool) -> Appearance {
reduced ? .crossfade : .scaleAndFade(from: AppearScale.lane)
}
/// A card arriving or leaving — a create, a delete, a Put Back, and the search filter's leavers
/// and arrivers ("Search-hiding rides the same structural transition — hiding is removal, not a
/// special fade"). The trash's rows wear it too: they are cards, and 10 requires the trash
/// animations to have a reduced variant like everything else.
static func cardTransition(reduced: Bool) -> AnyTransition {
cardAppearance(reduced: reduced).transition
}
/// A lane arriving or leaving — and the trash quasi-lane joining or leaving the width division,
/// which is lane-shaped and reads as one.
static func laneTransition(reduced: Bool) -> AnyTransition {
laneAppearance(reduced: reduced).transition
}
/// The transient search bar arriving and leaving — ⌘F's fallback when the search field has been
/// removed from the toolbar (03-board-ui.md ▸ Toolbar: "with the field removed from the toolbar,
/// invoking it surfaces the field transiently until the search clears").
///
/// It comes from the top edge because that is where the field lives when it is installed: the
/// bar is the toolbar's item arriving one row lower, not a new kind of surface.
static func transientSearchAppearance(reduced: Bool) -> Appearance {
reduced ? .crossfade : .slideAndFade(from: .top)
}
static func transientSearchTransition(reduced: Bool) -> AnyTransition {
transientSearchAppearance(reduced: reduced).transition
}
// MARK: - The AppKit face
/// The lane-resize window animation's duration, for `NSAnimationContext` — which takes a number
/// of seconds and not an `Animation`, and so cannot be handed `laneResize(reduced:)`.
///
/// No `reduced:` parameter, deliberately: the reduced variant of a window resize is **no
/// animation group at all** (`LaneResizeSession.tick`), not a zero-duration one. Core Animation
/// treats a zero duration as "use the transaction's default" often enough that trusting it to
/// mean *instant* would make 10-accessibility.md's lane-resize commitment a coin toss.
static var laneResizeWindowDuration: TimeInterval { Duration.laneResize }
/// The window animation's timing function. `.easeOut` is the closest AppKit curve to the snappy
/// spring the units tick on — the two are travelling the same distance in the same time, and a
/// visible disagreement between the window's right edge and the lanes inside it is exactly what
/// `LaneResizeSession`'s frozen standard exists to prevent.
static var laneResizeWindowTiming: CAMediaTimingFunction {
CAMediaTimingFunction(name: .easeOut)
}
/// Reduce Motion, asked of AppKit rather than of the SwiftUI environment.
///
/// For the three kinds of caller that have no environment to read: the store's reload seam (a
/// model type), `LaneResizeSession` (which animates an `NSWindow`), and the menu commands (whose
/// content is built outside any rendered hierarchy, so the environment's accessibility values are
/// not reliably populated there). It is the same system setting SwiftUI's
/// `accessibilityReduceMotion` reports, asked at the other end.
@MainActor
static var prefersReducedMotion: Bool {
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
}
// MARK: - Which reloads perform
/// **The two-voice split, as one pure decision**: does the snapshot this reload landed get
/// applied in an animated transaction, or does it simply appear?
///
/// 03-board-ui.md § Motion states the rule at the level of user intent — "User-initiated
/// structural changes animate; foreign changes snap" — and notes the pathfinder enforced it by
/// routing every user operation through animated store methods. Lanework cannot: the one-way
/// flow means a delete the user just asked for arrives back through the watcher exactly like an
/// agent's edit would. What the app has instead is the reload's **origin**, which the watcher
/// already classifies, and that is enough:
///
/// - `.appMediated` — the tail of an operation this app ran. The user did something and this is
/// it landing, so it performs.
/// - `.foreign` — an editor, an agent, `git` in a terminal. It snaps: "live-reload is the board
/// becoming what's on disk, not an event to perform."
/// - `.reconciling` — a sweep after a wake, an activation, or a missed-events flag. It makes no
/// claim to be anyone's gesture, so it snaps too; a board that animated every lane on wake
/// would be performing the *absence* of a change.
///
/// **A bracketed wholesale operation snaps whatever its origin.** Its reload is a pull-rebase, a
/// branch switch, a wholesale rewrite — the board can be a different tree afterwards, and
/// animating every lane and card out and back in would be theatre over a change the user asked
/// for in one gesture and 10-accessibility.md announces in one sentence ("Bracketed operations
/// announce once, at completion").
///
/// ### The merged-origin case, stated because it is not what it looks like
///
/// Origins coalesce by precedence (`reconciling > appMediated > foreign`, `WatchOrigin.merged`),
/// and that merge is **lossy on purpose**: a foreign edit landing inside an app-mediated span
/// does not downgrade it, so the store is handed `.appMediated` and cannot tell a pure echo from
/// a mixed one. The mixed window therefore animates. That is the honest reading rather than a
/// concession — the merged label is defined as "the strongest claim either half made", the app's
/// own operation *is* in that window, and it is the thing the user is waiting to see land. The
/// alternative would be to split deliveries to keep origins pure, which `FolderWatcher.schedule`
/// rejects for the coalescing it would cost.
///
// m7-undo: undo/redo restore is 03's "one deliberate crossover" — app-initiated, so it animates,
// but in the *content-reflow* voice rather than the structural one ("a git checkout, but ours").
// It arrives as a bracketed wholesale reload, so it lands on the `endsBracketedOperation` branch
// below: m7 gives this function a way to know the operation was a restore (an origin case, or a
// restore flag threaded through `performWholesale`) and returns `contentReflow` for it instead of
// snapping. Everything else on that branch keeps snapping.
static func reloadAnimates(origin: WatchOrigin, endsBracketedOperation: Bool) -> Bool {
guard !endsBracketedOperation else { return false }
switch origin {
case .appMediated: return true
case .foreign, .reconciling: return false
}
}
/// The thin wrapper `BoardStore.land` hands to `withAnimation`: the voice a landing snapshot is
/// applied in, or `nil` for the reloads that snap.
///
// m5-drag: the voice is the *general* structural spring for every app-mediated reload, because
// the reload seam knows an operation echoed but not which one — 03 gives delete 0.25 s and a
// drop commit its own dialect, and neither is reachable from an origin tag. The per-operation
// figures (`delete`, `dragReflow`) become reachable when the operations that own them run their
// own transactions around the gesture; this seam stays the floor under them.
//
// The search filter needed none of that and never reaches here: a query change is not a reload
// at all — it is transient state, so its transaction is wrapped where it happens
// (`BoardView.laneStrip`, `contentReflow`).
static func reloadAnimation(origin: WatchOrigin, endsBracketedOperation: Bool, reduced: Bool) -> Animation? {
guard reloadAnimates(origin: origin, endsBracketedOperation: endsBracketedOperation) else { return nil }
return structural(reduced: reduced)
}
}