diff --git a/DESIGN/05-card-window.md b/DESIGN/05-card-window.md index de315bd..2e94902 100644 --- a/DESIGN/05-card-window.md +++ b/DESIGN/05-card-window.md @@ -59,7 +59,7 @@ Stacked sections under small-caps headers, in this order; quiet rows, read-optim - Shows **every top-level file of `attachments/`** — including files also embedded in the body (settled: 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 but not surfaced (01-storage-format.md's attachments rules). - **Compact rows**: small QuickLook thumbnail (Finder-icon fallback) + middle-truncated filename, one row per file. The section header carries a quiet add affordance; empty, the section stays with a one-line hint (drop files, or File ▸ Add Attachment…, ⇧⌘A) — the drop surface remains the **whole window** (name collisions auto-rename, Finder-style — 01-storage-format.md). **Drop precedence is split by payload** (settled): file drops import as attachments anywhere in the window — Edit mode included, the text editor never intercepts a file drop; dragged *text* lands in the Edit editor at the caret within its bounds as ordinary insertion, and is inert elsewhere in the window. **One carve-out by hover target** (ruled 2026-07-29 — comment attachments are authorable): a file dropped **within the comment composer's bounds** imports to the draft's `attachments/`, and within an **inline comment edit session's bounds** to that comment's — the window-wide card default covers everywhere else (The comments column below). - Row interactions: double-click or Return opens; context menu Open / Reveal in Finder / **Set as Hero** or **Remove Hero** / Remove (moves to the **system** Trash, never hard-deletes — 03-board-ui.md's naming constraint keeps this distinct from board deletion); rows drag out their file URL. **The hero row is one slot with two words** (ruled 2026-08-09, 03-board-ui.md § Card face ▸ Hero image): "Set as Hero" on any image row that is not already the hero, "Remove Hero" on the one that is, and **nothing at all** on a row that can be neither — present-or-absent rather than greyed, because a disabled "Set as Hero" on a `.zip` would claim a capability that will never arrive (Remove, just below it, greys under the lock as before). Removing the hero removes the *key*, never the file. The row that is the hero says so to VoiceOver as its value. -- **⌘V pastes a picture onto this card** (04-interactions.md ▸ Clipboard's image-data branch): the whole-window drop's keyboard twin, landing in the same `attachments/` through the same import path. A focused text field — the body editor, the composer, an inline comment edit — consumes ⌘V natively and stays a text paste, which is the drop precedence's rule arriving on the other input. +- **⌘V pastes a picture onto this card** (04-interactions.md ▸ Clipboard's image-data branch): the whole-window drop's keyboard twin, landing in the same `attachments/` through the same import path. A focused text field — the body editor, the composer, an inline comment edit — consumes ⌘V natively and stays a text paste, which is the drop precedence's rule arriving on the other input. **The body editor yields a paste it cannot read** (ruled 2026-08-09, live-probed): the editor holds the keyboard from the moment the window opens, so "a focused editor wins ⌘V" must not mean "blocks what it cannot take" — a pasteboard with no flavor the editor accepts (the screenshot's image-only pasteboard, foremost) passes `paste:` to the responder behind it, where the window's attachment branch answers; any text flavor riding along keeps the paste in the editor exactly as before. The yield is by capability (`readablePasteboardTypes`), the drop rule's own shape — the editor declines file *drops* the same way. The composer and inline comment edits do not yield yet; theirs is a narrower surface and a follow-up call. - **Keyboard-native, new in the rewrite** (the pathfinder's strip was pointer-only): the section is focusable; arrows move between rows, **Space QuickLooks** the selected row, Return opens it, ⌫ removes it (same system-Trash semantics). ### Style diff --git a/Kanban/UI/Card/CardBodySurface.swift b/Kanban/UI/Card/CardBodySurface.swift index b07abfd..8a311a8 100644 --- a/Kanban/UI/Card/CardBodySurface.swift +++ b/Kanban/UI/Card/CardBodySurface.swift @@ -405,4 +405,60 @@ final class CardBodyTextView: NSTextView { ] return super.acceptableDragTypes.filter { !fileTypes.contains($0) } } + + // MARK: The paste yield + + /// **A pasteboard this editor cannot read is never the editor's either** — the drop rule above, + /// arrived at the keyboard (04-interactions.md ▸ Clipboard, the image-data branch; 05 ▸ + /// Attachments makes the *window* answer ⌘V with an attachment import). + /// + /// The focused-editor rule says a focused text surface wins ⌘V, and it still does: any pasteboard + /// carrying something this view can take — text, foremost — pastes into the text exactly as + /// before, image flavors riding beside it or not. But this view holds the keyboard from the + /// moment the window opens, and a *screenshot* pasteboard (image data, no text) is one it cannot + /// read at all: `NSTextView`'s own answer is a disabled menu row, which here means the window's + /// image branch sits one responder below the keyboard and can never be reached by it. "Wins" + /// must not mean "blocks what it cannot take" — so a paste this editor has no reading of is + /// passed to the responder behind it, and the standard validation walks the same path. + /// + /// The yield is by *capability*, not by content kind: `readablePasteboardTypes` is AppKit's own + /// statement of what this view would accept, so the expression cannot drift from the paste it + /// guards. In Preview the view is not editable and takes no paste, so there the window's branch + /// simply owns ⌘V outright. + + /// The pasteboard the yield reads — the general one in the app; tests hand in their own so the + /// suite never touches the machine's (`FakePasteboard`'s reason, one seam over). + var yieldPasteboard: NSPasteboard = .general + + /// Whether this editor itself would take the current pasteboard. + private var takesPasteboardAsText: Bool { + isEditable && yieldPasteboard.availableType(from: readablePasteboardTypes) != nil + } + + /// The responder behind this view that answers `paste:` — the card window's image branch when it + /// is armed (`cardWindowImagePaste`; SwiftUI's bridge responds only while the handler is + /// attached), and `nil` when nothing behind would take the paste either. + private var pasteYieldTarget: NSResponder? { + var responder = nextResponder + while let current = responder { + if current.responds(to: #selector(NSText.paste(_:))) { return current } + responder = current.nextResponder + } + return nil + } + + override func validateUserInterfaceItem(_ item: any NSValidatedUserInterfaceItem) -> Bool { + if item.action == #selector(NSText.paste(_:)), !takesPasteboardAsText { + return pasteYieldTarget != nil + } + return super.validateUserInterfaceItem(item) + } + + override func paste(_ sender: Any?) { + guard !takesPasteboardAsText, let target = pasteYieldTarget else { + super.paste(sender) + return + } + target.tryToPerform(#selector(NSText.paste(_:)), with: sender) + } } diff --git a/KanbanTests/PasteImageTests.swift b/KanbanTests/PasteImageTests.swift index aefa305..dc1725f 100644 --- a/KanbanTests/PasteImageTests.swift +++ b/KanbanTests/PasteImageTests.swift @@ -1,3 +1,4 @@ +import AppKit import CoreGraphics import Foundation import ImageIO @@ -769,3 +770,90 @@ struct PasteBoardBackgroundTests { == "Pasted Background.png") } } + +// MARK: - The body editor's paste yield + +/// The responder behind the editor in these tests — stands where the card window's +/// `cardWindowImagePaste` bridge stands in the app, and only counts. +@MainActor +private final class PasteCatcher: NSView { + var pastes = 0 + @objc func paste(_ sender: Any?) { pastes += 1 } +} + +/// **`CardBodyTextView` yields a paste it cannot read** (05-card-window.md ▸ Attachments, ruled +/// 2026-08-09) — the screenshot pasteboard reaches the window's attachment branch even while the +/// editor holds the keyboard, and a text paste never leaves the editor. +/// +/// The pasteboard is a private named one through the view's `yieldPasteboard` seam, so the suite +/// never reads the machine's — except through `super.paste`, which is AppKit's own and is exactly +/// why the text-path test asserts the catcher stayed silent rather than what landed in the view. +@MainActor +@Suite("Paste yield ▸ the body editor") +struct PasteYieldTests { + + private func makeEditor(behind catcher: PasteCatcher? = nil) -> (CardBodyTextView, NSPasteboard) { + let pasteboard = NSPasteboard(name: NSPasteboard.Name("test-yield-\(UUID().uuidString)")) + pasteboard.clearContents() + let editor = CardBodyTextView(frame: .zero) + editor.isRichText = false + editor.isEditable = true + editor.yieldPasteboard = pasteboard + catcher?.addSubview(editor) + return (editor, pasteboard) + } + + private var pasteItem: NSMenuItem { + NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "") + } + + @Test("An image-only pasteboard validates through the editor and forwards to the responder behind") + func imageOnlyYields() { + let catcher = PasteCatcher(frame: .zero) + let (editor, pasteboard) = makeEditor(behind: catcher) + defer { pasteboard.releaseGlobally() } + pasteboard.setData(encodedImage(.png), forType: .png) + + #expect(editor.validateUserInterfaceItem(pasteItem)) + editor.paste(nil) + #expect(catcher.pastes == 1) + } + + /// No expectation on `validateUserInterfaceItem` here: a readable pasteboard routes validation + /// to `super`, and `NSTextView`'s own answer reads the *machine's* general pasteboard — asserting + /// it would tie the test to whatever the host's clipboard happens to hold. The claim under test + /// is the routing: a text flavor means the paste is the editor's, so nothing is forwarded. + @Test("A text flavor keeps the paste in the editor — riding image or not") + func textStaysTheEditors() { + let catcher = PasteCatcher(frame: .zero) + let (editor, pasteboard) = makeEditor(behind: catcher) + defer { pasteboard.releaseGlobally() } + pasteboard.setString("plain words", forType: .string) + pasteboard.setData(encodedImage(.png), forType: .png) + + editor.paste(nil) + #expect(catcher.pastes == 0) + } + + @Test("Preview mode takes no paste at all, so the window's branch owns it outright") + func previewYieldsEverything() { + let catcher = PasteCatcher(frame: .zero) + let (editor, pasteboard) = makeEditor(behind: catcher) + defer { pasteboard.releaseGlobally() } + editor.isEditable = false + pasteboard.setData(encodedImage(.png), forType: .png) + + #expect(editor.validateUserInterfaceItem(pasteItem)) + editor.paste(nil) + #expect(catcher.pastes == 1) + } + + @Test("Nothing behind to take it means a disabled row, not a swallowed gesture") + func noTargetDisables() { + let (editor, pasteboard) = makeEditor() + defer { pasteboard.releaseGlobally() } + pasteboard.setData(encodedImage(.png), forType: .png) + + #expect(!editor.validateUserInterfaceItem(pasteItem)) + } +}