The measured defect: LaneView's empty-space double-click was a second sequential .onTapGesture(count: 2) stacked over the single tap, and that recogniser held every click in the lane — its own empty space and every card face alike — hostage to the system double-click interval while it disambiguated (~475 ms click-to-selection on a hosted board). The fix is structural: the empty-space surfaces live on a background layer behind the masonry, so a card's click never shares a gesture path with a lane recogniser; one .onTapGesture branches on PointerClick.count (AppKit's own clickCount, read the way ClickModifier reads the keyboard) — first click selects, second creates, Finder's cadence with nothing to disambiguate; and the layer carries a load-bearing empty .onDrag, because without a drag source macOS holds primary clicks pending multi-click disambiguation (measured: never fires alone, ~90 ms with one present). A measured viewport floor makes each lane's blank space actually belong to the layer — a ScrollView proposes nothing along its scroll axis, so only an explicit minimum stretches the content — with the trash column as its twin, less the padding that sits inside its scroll content. The template chooser's stacked pair collapses to the same one-recogniser branch, and the attachment rows move their double-click to a simultaneous gesture (instant there, because the row's real .onDrag forces immediate delivery). PointerLatencyTests pins the recovery with synthetic pointer events on a hosted board. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
369 lines
16 KiB
Swift
369 lines
16 KiB
Swift
import AppKit
|
|
import Quartz
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
// MARK: - The section header
|
|
|
|
/// A stacked small-caps header over a sidebar section (05-card-window.md ▸ The attributes sidebar:
|
|
/// "Stacked sections under small-caps headers"), with room for one quiet trailing affordance.
|
|
///
|
|
/// Shared by all five sections rather than restated in each: the attachments section is the only one
|
|
/// with an accessory today, and the header's type, weight and rule have to stay identical across the
|
|
/// stack or the accessory would be the reason one section looks different from the rest.
|
|
struct CardSidebarSectionHeader<Accessory: View>: View {
|
|
|
|
let title: String
|
|
@ViewBuilder var accessory: Accessory
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 4) {
|
|
Text(title)
|
|
.font(.caption.weight(.semibold))
|
|
.textCase(.uppercase)
|
|
.foregroundStyle(.secondary)
|
|
Spacer(minLength: 0)
|
|
accessory
|
|
}
|
|
Divider()
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
extension CardSidebarSectionHeader where Accessory == EmptyView {
|
|
init(title: String) {
|
|
self.init(title: title) { EmptyView() }
|
|
}
|
|
}
|
|
|
|
// MARK: - CardAttachmentsSection
|
|
|
|
/// The sidebar's **Attachments** section (05-card-window.md ▸ Attachments).
|
|
///
|
|
/// ### What it shows, and why it does not look
|
|
///
|
|
/// Every top-level file of `attachments/`, in Finder order, **including files also embedded in the
|
|
/// body** — "the section is the card's complete file inventory, no reference-tracking magic; an
|
|
/// image appearing in both places is honest, not a bug". Subfolders are tolerated and not surfaced.
|
|
///
|
|
/// The list itself is `Card.attachments` off the snapshot, republished into `CardAttachments.names`
|
|
/// by the host. This view never lists a directory: the loader did that on the last reload, the board
|
|
/// face's chip reads the very same field, and a second listing here would be a surface able to
|
|
/// disagree with the first. Every write in this section is bracketed (`performWrite`), so the reload
|
|
/// that refreshes the list is app-mediated and arrives by itself.
|
|
///
|
|
/// ### Keyboard-native, which is what is new in the rewrite
|
|
///
|
|
/// "The section is focusable; arrows move between rows, **Space QuickLooks** the selected row,
|
|
/// Return opens it, ⌫ removes it" — the pathfinder's strip was pointer-only. The substrate is
|
|
/// SwiftUI's own focus system (`focusable` + `@FocusState` + `onKeyPress`) over one focusable
|
|
/// container, rather than an `NSTableView` or a row-per-focusable list: 05 says *the section* is
|
|
/// focusable, one focus stop is what a Tab user wants out of a five-section sidebar, and the rest of
|
|
/// this window is already SwiftUI. The board's keyboard grammar is a different window's and shares
|
|
/// nothing here.
|
|
struct CardAttachmentsSection: View {
|
|
|
|
let attachments: CardAttachments
|
|
let thumbnails: AttachmentThumbnailCache
|
|
|
|
/// Whether the section has the keyboard. Mirrored into `CardAttachments.isFocused` because File
|
|
/// ▸ Reveal in Finder's card-window scope turns on it.
|
|
@FocusState private var isFocused: Bool
|
|
|
|
@Environment(\.displayScale) private var displayScale
|
|
|
|
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
|
|
|
private var names: [String] { attachments.names }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: CardWindowMetrics.attachmentRowPadding(bodyPointSize: pointSize)) {
|
|
CardSidebarSectionHeader(title: "Attachments") { addAffordance }
|
|
|
|
if names.isEmpty {
|
|
emptyHint
|
|
} else {
|
|
rows
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.onChange(of: isFocused, initial: true) { _, focused in
|
|
attachments.isFocused = focused
|
|
}
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
/// The header's **quiet add affordance** — "a pointer twin of File ▸ Add Attachment…, no
|
|
/// separate behavior" (11-command-nexus.md), which is why it calls the same method the menu row
|
|
/// does rather than opening a panel of its own.
|
|
///
|
|
/// Disabled under the read-only lock, where the menu row is disabled too: 02-architecture.md's
|
|
/// every-entry-point predicate does not care which entry point.
|
|
private var addAffordance: some View {
|
|
Button {
|
|
attachments.add()
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(!AddAttachmentCommand.isEnabled(attachments))
|
|
.help("Add Attachment…")
|
|
.accessibilityLabel("Add Attachment")
|
|
}
|
|
|
|
// MARK: - Empty
|
|
|
|
/// "Empty, the section stays with a one-line hint (drop files, or File ▸ Add Attachment…,
|
|
/// ⇧⌘A)" — the section never disappears, because the drop surface it advertises is the whole
|
|
/// window and a hint the user cannot find teaches nothing.
|
|
private var emptyHint: some View {
|
|
Text("Drop files anywhere, or File ▸ Add Attachment… (⇧⌘A)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
// MARK: - Rows
|
|
|
|
private var rows: some View {
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
ForEach(names, id: \.self) { name in
|
|
row(name)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
// One focus stop for the whole list — 05's "the section is focusable".
|
|
.focusable()
|
|
.focused($isFocused)
|
|
.onKeyPress(.upArrow) { attachments.moveSelection(by: -1); return .handled }
|
|
.onKeyPress(.downArrow) { attachments.moveSelection(by: 1); return .handled }
|
|
.onKeyPress(.space) { quickLookSelected(); return .handled }
|
|
.onKeyPress(.return) { openSelected(); return .handled }
|
|
// ⌫, and only ⌫: 05 gives the section one destructive key and the board's own delete
|
|
// grammar is a different window's.
|
|
.onKeyPress(.delete) { removeSelected(); return .handled }
|
|
.accessibilityLabel("Attachments")
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func row(_ name: String) -> some View {
|
|
let url = attachments.url(for: name)
|
|
let isSelected = attachments.selected == name
|
|
|
|
AttachmentRow(
|
|
name: name,
|
|
url: url,
|
|
isSelected: isSelected,
|
|
isSectionFocused: isFocused,
|
|
thumbnails: thumbnails,
|
|
pointSize: pointSize,
|
|
displayScale: displayScale
|
|
)
|
|
.contentShape(Rectangle())
|
|
// Double-click opens, a single click selects (05 ▸ Attachments; the window's click grammar,
|
|
// where clicking selects and never edits). The two-count gesture rides `simultaneousGesture`
|
|
// rather than stacking as a second `.onTapGesture` — a sequential pair makes the single
|
|
// click wait out the double-click interval before selecting (`CardFaceView`'s arrangement;
|
|
// the 2026-08-06 latency fix). Simultaneity stays instant *here* because the row carries
|
|
// `.onDrag` below, which forces immediate click delivery — a surface without a drag source
|
|
// must branch one recogniser on `PointerClick.count` instead (`LaneView`'s empty space).
|
|
// The first click of a pair selects, the second opens; the open re-asserting focus and
|
|
// selection is idempotent.
|
|
.onTapGesture {
|
|
isFocused = true
|
|
attachments.selected = name
|
|
}
|
|
.simultaneousGesture(TapGesture(count: 2).onEnded {
|
|
isFocused = true
|
|
attachments.selected = name
|
|
attachments.open(name)
|
|
})
|
|
// **Rows drag out their file URL** (05 ▸ Attachments; 11-command-nexus.md ▸ Pointer-only
|
|
// affordances) — which is what makes drag-to-Finder and drag-into-another-app work with no
|
|
// export path of this app's own. An empty provider for a row whose file has gone refuses the
|
|
// drag rather than starting one that would resolve to nothing.
|
|
.onDrag {
|
|
guard let url else { return NSItemProvider() }
|
|
return NSItemProvider(contentsOf: url) ?? NSItemProvider()
|
|
}
|
|
.contextMenu { menu(for: name) }
|
|
}
|
|
|
|
/// The attachment row's context menu — **Open, Reveal in Finder, Remove** (11-command-nexus.md ▸
|
|
/// Context menus), twins of the focused section's grammar keys (Return / ⌫) and of File ▸ Reveal
|
|
/// in Finder in its attachments-focused context. No new store method, no parallel
|
|
/// implementation: every row here calls exactly what the keyboard calls.
|
|
///
|
|
/// It acts on **its own row**, not on the selection, which is what makes a right-click on an
|
|
/// unselected row unambiguous without a select-first dance.
|
|
@ViewBuilder
|
|
private func menu(for name: String) -> some View {
|
|
Button("Open") { attachments.open(name) }
|
|
Button("Reveal in Finder") { attachments.reveal(name) }
|
|
Divider()
|
|
Button("Remove") { attachments.remove(name) }
|
|
.disabled(!attachments.isEditable)
|
|
}
|
|
|
|
// MARK: - The grammar keys
|
|
|
|
private func openSelected() {
|
|
guard let selected = attachments.selected else { return }
|
|
attachments.open(selected)
|
|
}
|
|
|
|
private func removeSelected() {
|
|
guard let selected = attachments.selected else { return }
|
|
attachments.remove(selected)
|
|
}
|
|
|
|
/// **Space QuickLooks the selected row** (05 ▸ Attachments) — Finder's own key, and Finder's own
|
|
/// panel: `QLPreviewPanel` previews every row of the section with the selected one showing, so
|
|
/// the panel's ←/→ walk the card's attachments exactly as it walks a Finder selection.
|
|
private func quickLookSelected() {
|
|
guard let selected = attachments.selected,
|
|
let index = names.firstIndex(of: selected)
|
|
else { return }
|
|
AttachmentQuickLook.shared.toggle(
|
|
urls: names.compactMap { attachments.url(for: $0) },
|
|
at: index
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - One row
|
|
|
|
/// A compact row: **small QuickLook thumbnail (Finder-icon fallback) + middle-truncated filename**
|
|
/// (05-card-window.md ▸ Attachments).
|
|
///
|
|
/// Middle truncation rather than tail, because a filename's tail is its extension and a sidebar
|
|
/// 26 characters wide would otherwise turn every screenshot into `"Screen Shot 2026-07-2…"` — the
|
|
/// one part of the name that says what the file *is* is the part that would go.
|
|
private struct AttachmentRow: View {
|
|
|
|
let name: String
|
|
let url: URL?
|
|
let isSelected: Bool
|
|
let isSectionFocused: Bool
|
|
let thumbnails: AttachmentThumbnailCache
|
|
let pointSize: CGFloat
|
|
let displayScale: CGFloat
|
|
|
|
private var side: CGFloat { CardWindowMetrics.attachmentThumbnailSide(bodyPointSize: pointSize) }
|
|
private var padding: CGFloat { CardWindowMetrics.attachmentRowPadding(bodyPointSize: pointSize) }
|
|
|
|
private var slot: AttachmentThumbnailKey.Slot? {
|
|
url.map { AttachmentThumbnailKey.Slot(path: $0.path, side: side) }
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: padding) {
|
|
thumbnail
|
|
.frame(width: side, height: side)
|
|
Text(name)
|
|
.font(.callout)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, padding)
|
|
.padding(.vertical, padding / 2)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(selectionFill, in: RoundedRectangle(cornerRadius: 4, style: .continuous))
|
|
.foregroundStyle(isSelected && isSectionFocused ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
|
|
.help(name)
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityLabel(name)
|
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
|
.task(id: url?.path) {
|
|
guard let slot, let url else { return }
|
|
await thumbnails.load(slot, url: url, scale: displayScale)
|
|
}
|
|
}
|
|
|
|
/// The selection fill follows **focus**, the standard macOS list treatment: the accent colour
|
|
/// while the section has the keyboard, a quiet grey when it does not, so a selected row never
|
|
/// claims to be the thing the arrow keys are about to move.
|
|
private var selectionFill: AnyShapeStyle {
|
|
guard isSelected else { return AnyShapeStyle(.clear) }
|
|
return isSectionFocused ? AnyShapeStyle(.tint) : AnyShapeStyle(.quaternary)
|
|
}
|
|
|
|
/// The generated thumbnail once there is one, the file's Finder icon until then — and forever,
|
|
/// for anything QuickLook declines (05: "small QuickLook thumbnail (Finder-icon fallback)").
|
|
@ViewBuilder
|
|
private var thumbnail: some View {
|
|
if let slot, let image = thumbnails.thumbnail(for: slot) {
|
|
Image(decorative: image, scale: displayScale)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
} else if let url {
|
|
Image(nsImage: thumbnails.icon(forFileAt: url))
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
} else {
|
|
Image(systemName: "doc")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - QuickLook
|
|
|
|
/// The Space key's panel — `QLPreviewPanel`, the system's own, shared across every card window.
|
|
///
|
|
/// **One shared presenter, because there is one shared panel**: `QLPreviewPanel.shared()` is a
|
|
/// process-wide singleton, so a per-window data source would be a set of objects racing to be the
|
|
/// one it points at. Space in a second card window simply re-points the panel at that window's
|
|
/// files, which is also what Finder does across two windows.
|
|
///
|
|
/// The panel is driven by setting its data source directly rather than through the responder
|
|
/// chain's `acceptsPreviewPanelControl(_:)` dance: this app's key view at that moment is a SwiftUI
|
|
/// focusable, which is not an `NSResponder` we own, and the direct route is the one that does not
|
|
/// depend on where SwiftUI happens to put its hosting views.
|
|
@MainActor
|
|
final class AttachmentQuickLook: NSObject, QLPreviewPanelDataSource {
|
|
|
|
static let shared = AttachmentQuickLook()
|
|
|
|
private var items: [URL] = []
|
|
|
|
/// Space **toggles**, Finder's own behaviour: pressing it again on the row already showing puts
|
|
/// the panel away rather than re-opening it.
|
|
func toggle(urls: [URL], at index: Int) {
|
|
guard let panel = QLPreviewPanel.shared() else { return }
|
|
guard !urls.isEmpty, urls.indices.contains(index) else { return }
|
|
|
|
if panel.isVisible, items == urls, panel.currentPreviewItemIndex == index {
|
|
panel.orderOut(nil)
|
|
return
|
|
}
|
|
|
|
items = urls
|
|
panel.dataSource = self
|
|
panel.reloadData()
|
|
panel.currentPreviewItemIndex = index
|
|
panel.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
nonisolated func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int {
|
|
MainActor.assumeIsolated { items.count }
|
|
}
|
|
|
|
/// The bridge to `NSURL` happens **outside** the isolation hop on purpose: `any QLPreviewItem`
|
|
/// is not `Sendable`, so it may not be the thing `assumeIsolated` returns; `URL` is, so the
|
|
/// value that crosses is the plain one and the Objective-C cast is done here.
|
|
nonisolated func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! {
|
|
let url: URL? = MainActor.assumeIsolated {
|
|
items.indices.contains(index) ? items[index] : nil
|
|
}
|
|
guard let url else { return nil }
|
|
return url as NSURL
|
|
}
|
|
}
|