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
457 lines
21 KiB
Swift
457 lines
21 KiB
Swift
import AppKit
|
|
import Observation
|
|
import SwiftUI
|
|
|
|
// MARK: - TrashLaneView
|
|
|
|
/// The trash quasi-lane: the trailing, visually distinct column tombstoned items live in
|
|
/// (03-board-ui.md § Trash).
|
|
///
|
|
/// ### A pure view, and a quasi-lane
|
|
///
|
|
/// **Nothing here moves anything on disk.** Tombstoned items keep their `deleted:` key and stay
|
|
/// exactly where they are; this column is a rendering of `TrashModel.entries(of:)` and nothing more.
|
|
/// It is a *quasi*-lane because it wears a lane's shape while sharing none of a lane's machinery:
|
|
///
|
|
/// - it spans a **fixed one width unit** — no `width` frontmatter, no stepper, no resize handle, and
|
|
/// the edge drag never reaches it (`LaneLayoutMath.totalUnits(of:trashUnits:)` supplies the unit,
|
|
/// so there is no `Lane` value for any of those to act on);
|
|
/// - it is **not draggable and not reorderable** — the header carries no gesture, and it is absent
|
|
/// from the drop proposal's slot list by construction, since `BoardView` builds that from the
|
|
/// snapshot's live lanes;
|
|
/// - it is **never a drop target** — "no move or paste ever targets the trash" (04-interactions.md ▸
|
|
/// The trash), so nothing here declares an `onDrop` at all and a session over the column falls
|
|
/// through to the strip's own target, where a cursor over no lane simply holds the proposal. That
|
|
/// covers **Finder file drops** too, which "on tombstoned cards are inert" (▸ The trash): a file
|
|
/// session over this column resolves to no lane, so no row highlights and a release refuses;
|
|
/// - it has **no new-card button**: nothing is created in the trash.
|
|
///
|
|
/// ### No editing in the trash
|
|
///
|
|
/// "Tombstoned cards don't open — double-click does nothing beyond selection; Put Back or drag out
|
|
/// first." There is deliberately no double-tap recogniser, no rename editor, and no Style… row: the
|
|
/// trash is for restoring or purging, not working.
|
|
///
|
|
/// ### What is still a later card's
|
|
///
|
|
/// **⌘C copy-out** is still owed. The **search filter** ("shown, it participates in the filter like
|
|
/// any lane") arrived with m5 and is one line — see `entries`, which every other surface here reads
|
|
/// through. The *pointer* grammar is here: a row's click runs the same `SelectionGrammar` the
|
|
/// board does, and the column's empty space rubber-bands on the trashed side. So is the drop, with
|
|
/// the target lane highlighted and the source row dimmed in place; the drag's replica is not. The
|
|
/// **keyboard** reaches the column entirely through the frames the rows register — arrow walks in
|
|
/// and out, ⇧-arrows inert at both the liveness and the kind boundary — so nothing in this file
|
|
/// implements it beyond keeping every row drawn and registered (see `rows`).
|
|
struct TrashLaneView: View {
|
|
|
|
let store: BoardStore
|
|
|
|
/// The window's purge-alert host, threaded down rather than read from the focus system: a
|
|
/// context menu's content is built in its own host, where a `@FocusedValue` is not reliably the
|
|
/// board window's, and the row's Delete Immediately must raise the *same* alert the menu bar's
|
|
/// does.
|
|
let confirmations: TrashConfirmations
|
|
|
|
/// The board window's drop machinery — a row's drag is an ordinary card session on the
|
|
/// **trashed** side (`DragSession`, 04-interactions.md ▸ The trash).
|
|
let drops: BoardDropContext
|
|
|
|
/// The strip's rubber band. The column's empty space is its third surface, on the **trashed**
|
|
/// side — "a rubber-band stays on the side of the boundary it started on" (04-interactions.md ▸
|
|
/// The trash) — and every row registers its frame into the same registry.
|
|
let marquee: MarqueeControl
|
|
|
|
/// Reduce Motion, for the row transition below — 10-accessibility.md names the trash
|
|
/// specifically ("and trash animations all get reduced variants").
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
/// 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.
|
|
private let cornerRadius: CGFloat = 10
|
|
|
|
private let rowSpacing: CGFloat = 6
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
header
|
|
rows
|
|
}
|
|
.background(
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.fill(.quaternary.opacity(0.35))
|
|
)
|
|
}
|
|
|
|
/// The rows the column shows.
|
|
///
|
|
/// **The shown trash "participates in the filter like any lane"** (03-board-ui.md § Trash), so
|
|
/// the search predicate narrows this collection exactly as it narrows `LaneView.renderedCards`
|
|
/// — card rows and lane rows alike, each by its own title and body (`SearchFilter`) — and the
|
|
/// count badge follows for free, because it reads this same value. Hidden, the column renders
|
|
/// nothing and registers nothing, so "hidden trash is invisible to search" needs no code at all.
|
|
private var entries: [TrashEntry] {
|
|
let filter = store.searchFilter
|
|
return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) }
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
/// Dimmed and hatched, with the trash symbol, the stable "Trash" title and a count badge
|
|
/// (03-board-ui.md § Trash ▸ Rendering).
|
|
///
|
|
/// The hatching is what makes the column read as *not a lane* at a glance — the design asks for
|
|
/// "visually distinct", and a lane's header is the surface this must not be mistaken for. It
|
|
/// carries no gesture at all: no selection (the quasi-lane "is never selectable as a lane"), no
|
|
/// reorder drag, no context menu.
|
|
private var header: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Image(systemName: "trash")
|
|
.foregroundStyle(.secondary)
|
|
.imageScale(.medium)
|
|
Text("Trash")
|
|
.font(.headline)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
countBadge
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 8)
|
|
.background {
|
|
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)
|
|
.fill(.quaternary.opacity(0.5))
|
|
.overlay {
|
|
DiagonalHatch()
|
|
.stroke(.quaternary, lineWidth: 1)
|
|
.clipShape(UnevenRoundedRectangle(
|
|
topLeadingRadius: cornerRadius,
|
|
topTrailingRadius: cornerRadius
|
|
))
|
|
}
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
|
|
/// The entry count — the same collection the body renders, so the badge cannot disagree with
|
|
/// what is on screen (`LaneView.countBadge`'s rule, and it is why m5's filter needs no second
|
|
/// change here).
|
|
private var countBadge: some View {
|
|
Text("\(entries.count)")
|
|
.font(.caption)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 1)
|
|
.background(Capsule().fill(.quaternary))
|
|
}
|
|
|
|
// MARK: - Rows
|
|
|
|
/// The rows, scrollable, with the navigation head kept in view.
|
|
///
|
|
/// **"Selection scrolls into view"** (04-interactions.md ▸ Grammar), watching the head rather
|
|
/// than the whole selection so exactly one column responds to any one arrow — `LaneView`'s rule,
|
|
/// on the trash side.
|
|
private var rows: some View {
|
|
ScrollViewReader { proxy in
|
|
scrollableRows
|
|
.onChange(of: store.transient.selectionHead) { _, head in
|
|
guard let head, entries.contains(where: { $0.id == head }) else { return }
|
|
proxy.scrollTo(head)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var scrollableRows: some View {
|
|
ScrollView(.vertical) {
|
|
// **A plain `VStack`, deliberately not lazy.** Every row must keep its drawn frame
|
|
// registered in `MarqueeTargetRegistry` — the rubber band sweeps those frames and the
|
|
// arrows navigate by them (`NavigationMath`) — and a lazy stack only builds the rows it
|
|
// has scrolled to, so an unbuilt row is invisible to both. The constraint is affordable
|
|
// because a trash is small: it holds one board's tombstones, and Empty Trash… exists.
|
|
VStack(alignment: .leading, spacing: rowSpacing) {
|
|
ForEach(entries) { entry in
|
|
TrashEntryRow(
|
|
store: store,
|
|
entry: entry,
|
|
confirmations: confirmations,
|
|
drops: drops,
|
|
registry: marquee.registry
|
|
)
|
|
// A row is a tombstoned item, so it arrives and leaves in the card's dialect —
|
|
// a delete files one in, a Put Back or a purge takes one out, and both halves of
|
|
// that pair should read alike from either side of the strip. The transaction is
|
|
// the reload's, like the lanes' (`Motion.reloadAnimates`).
|
|
.transition(Motion.cardTransition(reduced: reduceMotion))
|
|
// The scroll target — `LaneView`'s rule, and outermost for its reason.
|
|
.id(entry.id)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.padding(6)
|
|
.contentShape(Rectangle())
|
|
// The band's trash-side surface. It only ever arms from the column's empty space — a
|
|
// drag begun on a row is that row's drag-out — and the begin guard makes that geometric
|
|
// rather than a matter of gesture priority (`MarqueeControl`).
|
|
.simultaneousGesture(marquee.gesture(side: .trashed))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - The hatch
|
|
|
|
/// Diagonal hatching for the trash header — the "dimmed/hatched" treatment 03-board-ui.md asks for,
|
|
/// drawn rather than imaged so it takes whatever width the division gives the column.
|
|
///
|
|
/// The lines start a full header-height to the left of the leading edge so the first stroke reaches
|
|
/// the top-left corner instead of beginning partway across.
|
|
private struct DiagonalHatch: Shape {
|
|
var spacing: CGFloat = 7
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
var path = Path()
|
|
guard spacing > 0, rect.height > 0 else { return path }
|
|
var x = rect.minX - rect.height
|
|
while x < rect.maxX {
|
|
path.move(to: CGPoint(x: x, y: rect.maxY))
|
|
path.addLine(to: CGPoint(x: x + rect.height, y: rect.minY))
|
|
x += spacing
|
|
}
|
|
return path
|
|
}
|
|
}
|
|
|
|
// MARK: - Rows
|
|
|
|
/// One trash row: a compact, dimmed plate carrying the item's symbol and title — and, for a lane
|
|
/// entry, the count of cards Put Back would return with it.
|
|
///
|
|
/// **Face-like but not a card face.** It shares the plate, the symbol and the title, and it
|
|
/// deliberately shares none of the face's *editing* affordances: no rename editor, no Open, no
|
|
/// Style…, no attachment carousel. That is 03-board-ui.md's no-editing-in-the-trash rule expressed
|
|
/// as an absence rather than as a pile of `disabled` modifiers.
|
|
private struct TrashEntryRow: View {
|
|
|
|
let store: BoardStore
|
|
let entry: TrashEntry
|
|
let confirmations: TrashConfirmations
|
|
let drops: BoardDropContext
|
|
|
|
/// Where the rubber band looks up what it is sweeping — the card face's rule, on the trashed
|
|
/// side (`View.marqueeTarget`).
|
|
let registry: MarqueeTargetRegistry
|
|
|
|
private let cornerRadius: CGFloat = 6
|
|
|
|
/// **Lane entries are not draggable** (03-board-ui.md § Trash: "a lane entry is not draggable —
|
|
/// its entry is a compact row, not the lane; its move-out is Put Back"), so the drag half is
|
|
/// simply *absent* for them rather than refused — no session, no image, no snap-back. A click
|
|
/// still selects either way.
|
|
@ViewBuilder
|
|
var body: some View {
|
|
if entry.isLaneEntry {
|
|
plate
|
|
} else {
|
|
plate.onDrag(startRowDrag, preview: { dragReplica })
|
|
}
|
|
}
|
|
|
|
private var plate: some View {
|
|
rowFace
|
|
// The row being dragged out dims in place — the source stays visible in the trash,
|
|
// because a restore is not a removal until the write lands.
|
|
.opacity(drops.session.isDragging(entry.id) ? ClipboardTreatment.dimmedOpacity : 1)
|
|
// The deferred cut wears the same dim wherever it lands, so the treatment is stated for
|
|
// every surface a `pendingCut` could name rather than for two of the three. In practice
|
|
// it never fires here: ⌘X is disabled on tombstoned selections (04-interactions.md ▸ The
|
|
// trash), and a pending cut is homogeneous by liveness — a reload that tombstones a cut
|
|
// card *ejects* it from the set rather than moving it to the other side.
|
|
.cutTreatment(of: entry.id, in: store)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { select() }
|
|
.marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry)
|
|
.contextMenu { menu }
|
|
}
|
|
|
|
/// The plate's *appearance*, with none of its behaviour — no gesture, no context menu, and
|
|
/// crucially no marquee registration.
|
|
///
|
|
/// The split exists for the drag replica, which renders this and nothing else: a drag image is a
|
|
/// snapshot, and one built out of the live plate would re-register this row's frame from inside
|
|
/// the preview's own geometry and then *deregister* it when the image went away, quietly
|
|
/// stealing the row from the rubber band and the arrow keys.
|
|
private var rowFace: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Image(systemName: ItemSymbol.name(entry.icon, fallback: symbolFallback))
|
|
.foregroundStyle(.secondary)
|
|
.imageScale(.small)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(entry.title ?? "Untitled")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
if case let .lane(_, returning) = entry {
|
|
// "N cards" — what Put Back brings back with the lane, not how many folders sit
|
|
// inside it (`TrashModel.entries`' returning-count rule).
|
|
Text("\(returning) card\(returning == 1 ? "" : "s")")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 6)
|
|
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary.opacity(0.6)))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
|
|
)
|
|
}
|
|
|
|
private var symbolFallback: String {
|
|
entry.isLaneEntry ? ItemSymbol.lane : ItemSymbol.card
|
|
}
|
|
|
|
// MARK: - Selection
|
|
|
|
private var isSelected: Bool {
|
|
store.selection.liveness == .trashed && store.selection.ids.contains(entry.id)
|
|
}
|
|
|
|
/// A click selects this row on the **trashed** side, through the same grammar the board's
|
|
/// surfaces use — plain replaces, ⌘ toggles, ⇧ ranges (`SelectionGrammar`).
|
|
///
|
|
/// The row's kind travels with the click, and that is what keeps the trash's second homogeneity
|
|
/// axis true: a ⌘-click across the card/lane-entry boundary replaces rather than mixing, and a
|
|
/// ⇧-range walks only its own kind's rows (04-interactions.md ▸ The trash). No `togglesOnRepeat`
|
|
/// — click-again-to-unselect is the lane's behaviour, not a row's.
|
|
///
|
|
/// **A double click is two of these and nothing more**: no editor, no card window, no timer.
|
|
private func select() {
|
|
store.click(
|
|
SelectionTarget(id: entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed),
|
|
modifier: .current
|
|
)
|
|
}
|
|
|
|
// MARK: - Drag out
|
|
|
|
/// Begins the row's drag out of the trash — an ordinary **card session on the trashed side**,
|
|
/// which is the whole of what makes it a restore rather than a move (04-interactions.md ▸ The
|
|
/// trash; `DragLocality.operation`).
|
|
///
|
|
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
|
|
/// destination lane's masonry, so "restores it at the drop position" is the same arithmetic every
|
|
/// other card drop uses. What a release *means* differs by locality and modifier, and that lives
|
|
/// in one place (`BoardDropContext.commitDrop`): within the board a restore, ⌥ a live copy-out,
|
|
/// across boards a live copy with ⌘ forcing the true restore-move.
|
|
///
|
|
/// **Multi-drag carries the whole trashed selection**, in the trash's own sorted order — the
|
|
/// order the rows are drawn in, which is the only relative order a set of tombstones has.
|
|
///
|
|
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
|
/// mutating gesture.
|
|
private func startRowDrag() -> NSItemProvider {
|
|
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
|
let selection = store.selection
|
|
let ids: Set<ItemID> = selection.liveness == .trashed
|
|
&& selection.ids.contains(entry.id)
|
|
&& selection.ids.count > 1
|
|
? selection.ids
|
|
: [entry.id]
|
|
|
|
// Card entries only: a lane entry cannot be dragged at all, so one caught up in a mixed
|
|
// selection is simply not carried. (The selection is homogeneous by kind anyway — this is
|
|
// belt over braces.)
|
|
let rows: [(id: ItemID, laneID: ItemID, title: String?)] = TrashModel.entries(of: store.snapshot)
|
|
.compactMap { candidate in
|
|
guard ids.contains(candidate.id), case let .card(card, laneID) = candidate else { return nil }
|
|
return (card.id, laneID, card.title.value)
|
|
}
|
|
guard !rows.isEmpty else { return NSItemProvider() }
|
|
|
|
let root = store.rootURL
|
|
let payload = DragPayload(
|
|
boardRoot: root,
|
|
kind: .cards,
|
|
side: .trashed,
|
|
items: rows.map {
|
|
DragPayload.Item(
|
|
id: $0.id.rawValue,
|
|
folder: TrashModel.ItemPath(laneID: $0.laneID, cardID: $0.id).folder(under: root).path,
|
|
title: $0.title
|
|
)
|
|
}
|
|
)
|
|
drops.session.beginCards(
|
|
rows.map(\.id),
|
|
folders: payload.folders,
|
|
heights: rows.map { _ in LaneDropRegistry.nominalCardHeight },
|
|
side: .trashed,
|
|
source: store
|
|
)
|
|
return payload.itemProvider()
|
|
}
|
|
|
|
/// The image under the cursor: the row as it is drawn, fanned with a count badge for a
|
|
/// multi-drag — the card replica's treatment, at a trash row's size.
|
|
private var dragReplica: some View {
|
|
let count = store.selection.liveness == .trashed && store.selection.ids.contains(entry.id)
|
|
? max(1, store.selection.ids.count)
|
|
: 1
|
|
return ZStack {
|
|
if count > 2 { rowFace.offset(x: 10, y: 10).opacity(0.45) }
|
|
if count > 1 { rowFace.offset(x: 5, y: 5).opacity(0.7) }
|
|
rowFace
|
|
}
|
|
.frame(width: 200)
|
|
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
|
.padding(12)
|
|
}
|
|
|
|
// MARK: - The trash entry's context menu
|
|
|
|
/// Put Back, Delete Immediately, Reveal in Finder — the three rows 11-command-nexus.md gives a
|
|
/// trash entry, and no others.
|
|
///
|
|
/// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays
|
|
/// enabled on tombstoned selections", read-only lock included — inspecting a folder before a
|
|
/// purge is exactly the errand it exists for.
|
|
@ViewBuilder
|
|
private var menu: some View {
|
|
Button("Put Back") {
|
|
store.putBack(targetIDs)
|
|
}
|
|
.disabled(!store.acceptsBoardMutations)
|
|
|
|
Button("Delete Immediately") {
|
|
confirmations.requestPurge(of: targetIDs, in: store)
|
|
}
|
|
.disabled(!store.acceptsBoardMutations)
|
|
|
|
Divider()
|
|
|
|
Button("Reveal in Finder") {
|
|
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
|
|
}
|
|
}
|
|
|
|
/// What this row's menu acts on: the whole selection when this row is part of it, else this row
|
|
/// alone — standard macOS context-menu targeting, and the same rule the card face and the lane
|
|
/// header apply to Style…. Right-clicking something outside the selection acts on what was
|
|
/// clicked, which is also what keeps a cross-kind menu from ever acting on a mixed set.
|
|
private var targetIDs: Set<ItemID> {
|
|
guard store.selection.liveness == .trashed, store.selection.ids.contains(entry.id) else {
|
|
return [entry.id]
|
|
}
|
|
return store.selection.ids
|
|
}
|
|
|
|
private var targetFolders: [URL] {
|
|
TrashModel.paths(of: targetIDs, on: .trashed, in: store.snapshot)
|
|
.map { $0.folder(under: store.rootURL) }
|
|
}
|
|
}
|