The board's live title+body filter per 04-interactions.md § Search: - SearchFilter — a pure value folding the query once (case- and diacritic-insensitive substring, locale-stable); title OR body matches, attachment filenames never searched; only the literal empty string is inactive. - One universe: the filter threads through SelectionGrammar's order lists as a defaulted parameter, so ranges, Select All, arrow navigation, the marquee, drop zones, count badges, and the shown trash all read the same filtered set by construction; lanes are deliberately never filtered out (an emptied lane keeps its slot with a 0 badge). Hidden cards leave the selection through the existing constrain primitive, run on every query change and as the last line of the reload resolve; the delete successor is filtered so ⌫ never selects a hidden neighbour. - The field: an NSSearchField-backed toolbar item (the toolbar's sole default item); Edit ▸ Find ⌘F focuses it through a focused-value presentation; stock field-editor dispatch — Return swallowed, Tab is the keep-filter path to the board, board commands stay enabled except the caret-chord pair, now one shared caretChordsYield expression. - Escape is staged: clear the non-empty query (focus stays), hand an empty field back to the board, clear an active search from board focus — before Escape's clear-selection meaning. - Creating a card clears the search (the placeholder funnel); a rename deliberately gets no carve-out; filter reflow rides the content spring keyed narrowly on the query. 903 unit tests (24 new). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
210 lines
8.9 KiB
Swift
210 lines
8.9 KiB
Swift
import SwiftUI
|
|
|
|
/// The scene graph (02-architecture.md § Windows, § Launch and window lifecycle).
|
|
///
|
|
/// ### Four scenes, and why each is the kind it is
|
|
///
|
|
/// - **Welcome** is a `Window`: there is one of it, ever, and `openWindow(id:)` focuses the existing
|
|
/// one rather than making a second.
|
|
/// - **The restore bootstrap** is a `Window` too, and a deliberate oddity — see
|
|
/// `RestoreBootstrapView` for why launch-time work has to wear a window at all.
|
|
/// - **Boards** and **cards** are `WindowGroup(for:)`s, because their identity is a *value*: opening
|
|
/// with a ref that already has a window focuses it, which is how "one board window per root" and
|
|
/// "at most one card window per card (reopen focuses)" are enforced by the scene rather than by
|
|
/// bookkeeping.
|
|
///
|
|
/// ### Restoration is the registry's, not the system's
|
|
///
|
|
/// Both groups declare `.restorationBehavior(.disabled)`. The app already knows which boards were
|
|
/// open — the registry's open-now flags, which survive a crash and reopen in `lastOpened` order —
|
|
/// and letting AppKit *also* restore windows would produce duplicates, and worse, card windows
|
|
/// restored behind boards that never opened. One mechanism, and it is the one that can explain
|
|
/// itself when a board has moved or gone.
|
|
///
|
|
/// ### Which window appears at launch
|
|
///
|
|
/// Exactly one of welcome and the bootstrap, decided once in `init` and never re-derived: the
|
|
/// preference is read before any scene exists, and `restorables()` costs a bookmark resolution per
|
|
/// known board — a computed property here would pay that on every scene-graph evaluation.
|
|
@main
|
|
struct KanbanApp: App {
|
|
|
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
|
|
|
@State private var appModel: AppModel
|
|
|
|
/// Whether this launch restores boards: the preference is on **and** there is something flagged
|
|
/// to restore. Welcome "appears only when nothing restores".
|
|
private let shouldRestoreAtLaunch: Bool
|
|
|
|
init() {
|
|
let model = AppModel()
|
|
_appModel = State(initialValue: model)
|
|
shouldRestoreAtLaunch = AppPreferences.restoreOpenBoardsAtLaunch
|
|
&& !model.boardRegistry.restorables().isEmpty
|
|
// The delegate is constructed by the adaptor before this runs, so this is the one place the
|
|
// app's model and its AppKit half meet.
|
|
appDelegate.appModel = model
|
|
}
|
|
|
|
var body: some Scene {
|
|
Window("Welcome to Lanework", id: WindowID.welcome) {
|
|
WelcomeView()
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
.defaultLaunchBehavior(shouldRestoreAtLaunch ? .suppressed : .automatic)
|
|
.restorationBehavior(.disabled)
|
|
// "Welcome: resizable, no title bar (background drag)" (03-board-ui.md § Welcome screen &
|
|
// templates). `.contentMinSize` rather than `.contentSize`, because the view states a
|
|
// *minimum* and the recents list is meant to take whatever space the user gives it; the
|
|
// hidden title bar is why `WelcomeView` carries a `WindowDragGesture`.
|
|
.windowResizability(.contentMinSize)
|
|
.defaultSize(width: 820, height: 500)
|
|
.windowStyle(.hiddenTitleBar)
|
|
// Its automatic Window-menu item is replaced by the explicit command below, so the title is
|
|
// the one 11-command-nexus.md names rather than whatever the scene happens to be called.
|
|
.commandsRemoved()
|
|
|
|
// The template chooser (09-templates.md), reached from File ▸ New Board… ⌥⌘N and from
|
|
// welcome's own button. A `Window` for welcome's reason — there is one of it, ever — and
|
|
// suppressed at launch, since ⌥⌘N is the only thing that ever asks for it.
|
|
Window("New Board", id: WindowID.templateChooser) {
|
|
TemplateChooserView()
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
.defaultLaunchBehavior(.suppressed)
|
|
.restorationBehavior(.disabled)
|
|
.windowResizability(.contentSize)
|
|
.commandsRemoved()
|
|
|
|
Window("", id: WindowID.restoreBootstrap) {
|
|
RestoreBootstrapView()
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
.defaultLaunchBehavior(shouldRestoreAtLaunch ? .presented : .suppressed)
|
|
.restorationBehavior(.disabled)
|
|
.windowStyle(.plain)
|
|
.defaultSize(width: 1, height: 1)
|
|
.commandsRemoved()
|
|
|
|
WindowGroup(id: WindowID.board, for: BoardWindowRef.self) { $ref in
|
|
if let ref {
|
|
BoardWindowHost(ref: ref)
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
}
|
|
.restorationBehavior(.disabled)
|
|
.defaultLaunchBehavior(.suppressed)
|
|
.commands { menuCommands }
|
|
|
|
WindowGroup(id: WindowID.card, for: CardWindowRef.self) { $ref in
|
|
if let ref {
|
|
CardWindowHost(ref: ref)
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
}
|
|
.restorationBehavior(.disabled)
|
|
.defaultLaunchBehavior(.suppressed)
|
|
|
|
Settings {
|
|
SettingsView()
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
}
|
|
|
|
/// The menu items the app has built so far.
|
|
///
|
|
/// **The titles are API** (04-interactions.md ▸ Configurable bindings): macOS's App Shortcuts
|
|
/// mechanism remaps menu items *by title*, so these strings are the keys a user's custom binding
|
|
/// is stored under. They are spelled exactly as 11-command-nexus.md inventories them, and
|
|
/// changing one silently breaks every remap of it.
|
|
@CommandsBuilder
|
|
private var menuCommands: some Commands {
|
|
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, New Board…,
|
|
// Open…, Open Recent ▸, Board Info, Duplicate, (Save as Template, still owed — 09),
|
|
// Reveal in Finder, then the trash trio and Empty Trash…. The board-scoped ones validate
|
|
// against the frontmost board through the focus system, so each is simply absent-of-effect
|
|
// when no board is in front; New Board… and Open Recent are available everywhere, including
|
|
// with no window at all.
|
|
CommandGroup(after: .newItem) {
|
|
BoardCreationCommands()
|
|
NewBoardCommand(appModel: appModel)
|
|
|
|
Divider()
|
|
|
|
Button("Open…") {
|
|
appModel.presentOpenPanel()
|
|
}
|
|
.keyboardShortcut("o", modifiers: .command)
|
|
|
|
OpenRecentMenu(appModel: appModel)
|
|
|
|
Divider()
|
|
|
|
BoardInfoCommand()
|
|
|
|
Divider()
|
|
|
|
DuplicateBoardCommand(appModel: appModel)
|
|
RevealInFinderCommand()
|
|
|
|
Divider()
|
|
|
|
TrashCommands()
|
|
}
|
|
|
|
// The Edit menu's one row of ours: Find (⌘F), placed after the standard Cut/Copy/Paste/
|
|
// Select All group, which is where macOS puts Find. Undo/Redo and the clipboard items are
|
|
// the system's and the board answers them as a responder (`ClipboardCommands.swift`) — a
|
|
// second item sharing one of those titles is what titles-are-API forbids.
|
|
//
|
|
// m6-card-window: Find Next / Find Previous (⌘G/⇧⌘G) join here, scoped to the card window's
|
|
// find bar and "disabled in the board window — board search is a live filter, not a cursor"
|
|
// (11-command-nexus.md). They wait for the window that owns them rather than shipping as two
|
|
// permanently disabled rows.
|
|
CommandGroup(after: .pasteboard) {
|
|
FindCommand()
|
|
}
|
|
|
|
// The View menu. `CommandGroupPlacement.toolbar` *is* View — the menu the toolbar's own
|
|
// items live in — which is where 11-command-nexus.md files Show Trash, alongside the card
|
|
// window's Edit Body / Raw Source / History still to come.
|
|
CommandGroup(after: .toolbar) {
|
|
ShowTrashCommand()
|
|
}
|
|
|
|
// The Board menu (11-command-nexus.md), complete and in its inventoried row order — Open
|
|
// Card, Rename, Style…, the card moves, the lane moves, the width pair. Its items act on the
|
|
// frontmost board window, which they reach through the focus system rather than through the
|
|
// app model — see `BoardCommands.swift`, which also owns their validation.
|
|
CommandMenu("Board") {
|
|
OpenCardCommand()
|
|
BoardRenameCommand()
|
|
BoardStyleCommand()
|
|
|
|
Divider()
|
|
|
|
MoveCardCommands()
|
|
MoveLaneCommands()
|
|
|
|
Divider()
|
|
|
|
LaneWidthCommands()
|
|
}
|
|
|
|
CommandGroup(after: .windowList) {
|
|
// No default chord — "— (no default)" in the Nexus is deliberate, not a gap; it remaps
|
|
// like any other item.
|
|
Button("Welcome to Lanework") {
|
|
appModel.showWelcome()
|
|
}
|
|
}
|
|
}
|
|
}
|