A picture off the pasteboard becomes a card's file, its hero, or the board's backdrop
⌘V grows an image-data branch, below the app's own clipboard format and refused outright while a file URL is on the pasteboard: a screenshot or a browser's Copy Image lands as "Pasted Image.png" in the anchor card's attachments/, through the very import path Finder file drops and ⇧⌘A take — one bracket, one Finder-style collision ladder, one set of banners, and the same silence a drop's arrival has. A card window's ⌘V pastes onto its own card; a focused text field still wins the selector natively. A file-shaped flavor travels byte for byte, PNG preferred when several are offered; TIFF and BMP are re-encoded to PNG, being interchange encodings rather than files anyone wants in a folder. The hero key gets the setter it was born owing: "Set as Hero" on any image row of the attachment list, "Remove Hero" on the row that holds it, with menu-bar twins so the context entry is nobody's only home. It writes as a restyle — one key, one bracket, one invertible step on the window's own stack — and replaces rather than refusing, because a card has one hero and the row that has it says Remove instead. Edit ▸ Paste as Board Background is the same payload's other destination, taking the existing background.image convention at its word: the picture into the board folder as "Pasted Background.png", the colour subkey untouched, the generator's overwrite-our-own-name rule inherited and its echo memo taught to tell the two producers apart. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -48,6 +48,15 @@ public final class CardAttachments {
|
||||
/// stabler identity than an index across a reload that inserted a file above it.
|
||||
public var selected: String?
|
||||
|
||||
/// **The file this card's `hero` key names**, republished from the snapshot exactly as `names`
|
||||
/// is (`Card.hero`) — `nil` for a card with no hero, and for one whose key is malformed, which is
|
||||
/// the same "no band" the face renders (03-board-ui.md § Card face ▸ Hero image).
|
||||
///
|
||||
/// Here rather than derived in the section for `names`' reason: the sidebar must not be a second
|
||||
/// reading of the card able to disagree with the board face's. It drives one thing only — which
|
||||
/// of the two hero rows a row's context menu offers.
|
||||
public var hero: String?
|
||||
|
||||
/// Whether the section currently holds keyboard focus. Read by File ▸ Reveal in Finder, whose
|
||||
/// card-window scope is "the card's folder — the selected attachment's file instead when the
|
||||
/// attachments section is focused" (11-command-nexus.md).
|
||||
@@ -62,6 +71,11 @@ public final class CardAttachments {
|
||||
/// .removeAttachment(named:fromCard:)`.
|
||||
public var removeFile: ((String) -> Void)?
|
||||
|
||||
/// Points the card's `hero` key at a file, or removes it — filled by the host with `BoardStore
|
||||
/// .setHero(_:onCard:on:)`, this window's undo stack attached, so the step lands on the stack the
|
||||
/// gesture was issued on (13-native-undo.md ▸ Rules ▸ two levels).
|
||||
public var setHeroFile: ((String?) -> Void)?
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Derived
|
||||
@@ -105,6 +119,58 @@ public final class CardAttachments {
|
||||
removeFile?(name)
|
||||
}
|
||||
|
||||
/// **Set as Hero** — the row's file becomes the card's banner picture (05 ▸ Attachments, ruled
|
||||
/// 2026-08-09; 03-board-ui.md § Card face ▸ Hero image).
|
||||
public func setAsHero(_ name: String) {
|
||||
guard Self.canSetHero(name, hero: hero, names: names, isEditable: isEditable) else { return }
|
||||
setHeroFile?(name)
|
||||
}
|
||||
|
||||
/// **Remove Hero** — the key goes, the file stays. Removing the *hero* is not removing the
|
||||
/// attachment: the picture is still one of the card's files and is still in the list, which is
|
||||
/// what keeps this row distinct from the Remove one sitting below it.
|
||||
public func removeHero() {
|
||||
guard isEditable, hero != nil else { return }
|
||||
setHeroFile?(nil)
|
||||
}
|
||||
|
||||
// MARK: - The hero rows' rules
|
||||
|
||||
/// Whether a row offers **Set as Hero** — the row is an image, the section can write, the file is
|
||||
/// actually in the listing, and the card's hero is not already this very file.
|
||||
///
|
||||
/// **Image-type only** (`PastedImage.isImageName`), because the key means a picture: offering the
|
||||
/// row on a `.zip` would let a user set a hero that can never draw, and 03's structural degrade
|
||||
/// would leave them with a key and no band and nothing to explain it.
|
||||
///
|
||||
/// **Absent, not disabled, on the current hero's row**: that row shows Remove Hero instead, which
|
||||
/// is the same slot saying the true thing. Everywhere else "Set as Hero" *replaces* whatever hero
|
||||
/// the card had — one hero per card, and a Remove-then-Set dance would be ceremony (see
|
||||
/// `BoardStore.setHero(_:onCard:on:)`).
|
||||
///
|
||||
/// A pure static for `moved`/`settle`'s reason: the menu's two branches become lines of test
|
||||
/// rather than a context menu somebody has to open.
|
||||
public nonisolated static func canSetHero(
|
||||
_ name: String,
|
||||
hero: String?,
|
||||
names: [String],
|
||||
isEditable: Bool
|
||||
) -> Bool {
|
||||
guard isEditable, names.contains(name), hero != name else { return false }
|
||||
return PastedImage.isImageName(name)
|
||||
}
|
||||
|
||||
/// Whether a row offers **Remove Hero** — it is the card's current hero, and the section can
|
||||
/// write. The image test is deliberately *not* repeated: a hero somebody hand-wrote to a
|
||||
/// non-image file is exactly the state this row exists to get out of.
|
||||
public nonisolated static func canRemoveHero(
|
||||
_ name: String,
|
||||
hero: String?,
|
||||
isEditable: Bool
|
||||
) -> Bool {
|
||||
isEditable && hero == name
|
||||
}
|
||||
|
||||
// MARK: - Row actions that are not writes
|
||||
|
||||
/// Double-click, Return, and the context menu's Open: the file's default app (05 ▸ Attachments).
|
||||
@@ -238,6 +304,78 @@ struct AddAttachmentCommand: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File ▸ Set as Hero / Remove Hero
|
||||
|
||||
/// **The attachment row's hero pair, as menu rows** — card window only, acting on the attachments
|
||||
/// section's *selected* row (11-command-nexus.md; 05-card-window.md ▸ Attachments; 03-board-ui.md
|
||||
/// § Card face ▸ Hero image).
|
||||
///
|
||||
/// ### Why the menu rows exist at all
|
||||
///
|
||||
/// 11's context-menu contract: "Every entry is a twin of a menu command, a fixed grammar key, or a
|
||||
/// configuration control — **no function's only home**". The row's pointer path is the context menu;
|
||||
/// these are its required twins, and they are also what makes the gesture keyboard-reachable in a
|
||||
/// section 05 went out of its way to make keyboard-native.
|
||||
///
|
||||
/// ### Two rows, not one row with two titles
|
||||
///
|
||||
/// **Titles are API** (04-interactions.md ▸ Configurable bindings) — a user's custom binding is
|
||||
/// stored against the title — so a single row that renamed itself would silently drop that binding
|
||||
/// every time the selection moved. Two rows is `Collapse Lane`/`Expand Lane`'s answer to the same
|
||||
/// shape, and for the same reason. In the *context* menu the two share one slot, because a context
|
||||
/// menu is built fresh per row and carries no bindings.
|
||||
///
|
||||
/// The subject is the **selected** row rather than a row under a pointer, which is what a menu-bar
|
||||
/// item can address at all — File ▸ Reveal in Finder's card-window scope reads the same selection.
|
||||
struct SetAsHeroCommand: View {
|
||||
|
||||
@FocusedValue(\.cardAttachments) private var attachments
|
||||
|
||||
/// The row's validation as a value a test can hold — `AddAttachmentCommand.isEnabled`'s shape,
|
||||
/// for its reason. It is `CardAttachments.canSetHero` applied to the selected row, so the menu
|
||||
/// row and the context row can never disagree about what "an image that is not already the hero"
|
||||
/// means.
|
||||
static func isEnabled(_ attachments: CardAttachments?) -> Bool {
|
||||
guard let attachments, let selected = attachments.selected else { return false }
|
||||
return CardAttachments.canSetHero(
|
||||
selected,
|
||||
hero: attachments.hero,
|
||||
names: attachments.names,
|
||||
isEditable: attachments.isEditable
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button("Set as Hero") {
|
||||
guard let attachments, let selected = attachments.selected else { return }
|
||||
attachments.setAsHero(selected)
|
||||
}
|
||||
.disabled(!Self.isEnabled(attachments))
|
||||
}
|
||||
}
|
||||
|
||||
/// Set as Hero's other direction — see it for why the pair is two rows.
|
||||
struct RemoveHeroCommand: View {
|
||||
|
||||
@FocusedValue(\.cardAttachments) private var attachments
|
||||
|
||||
static func isEnabled(_ attachments: CardAttachments?) -> Bool {
|
||||
guard let attachments, let selected = attachments.selected else { return false }
|
||||
return CardAttachments.canRemoveHero(
|
||||
selected,
|
||||
hero: attachments.hero,
|
||||
isEditable: attachments.isEditable
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button("Remove Hero") {
|
||||
attachments?.removeHero()
|
||||
}
|
||||
.disabled(!Self.isEnabled(attachments))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The focused value
|
||||
|
||||
/// The focused card window's attachments section, beside `FocusedValues.cardBody` — see
|
||||
|
||||
@@ -159,6 +159,7 @@ struct CardAttachmentsSection: View {
|
||||
name: name,
|
||||
url: url,
|
||||
isSelected: isSelected,
|
||||
isHero: attachments.hero == name,
|
||||
isSectionFocused: isFocused,
|
||||
thumbnails: thumbnails,
|
||||
pointSize: pointSize,
|
||||
@@ -194,17 +195,48 @@ struct CardAttachmentsSection: View {
|
||||
.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.
|
||||
/// The attachment row's context menu — **Open, Reveal in Finder, the hero row, 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.
|
||||
///
|
||||
/// ### The hero row is one slot with two words
|
||||
///
|
||||
/// **"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 — a non-image file, or any row while
|
||||
/// the board is locked (03-board-ui.md § Card face ▸ Hero image; the rules themselves are
|
||||
/// `CardAttachments.canSetHero`/`canRemoveHero`, so the menu and the tests read one answer).
|
||||
///
|
||||
/// **Present-or-absent rather than enabled-or-disabled**, which is the opposite of Remove just
|
||||
/// below it — and the difference is what the row would *mean* greyed out. A disabled Remove says
|
||||
/// "this file cannot be removed right now", which is true and useful under the lock. A disabled
|
||||
/// "Set as Hero" on a `.zip` would say "this file could be the hero, but not now", which is not
|
||||
/// true and never will be. The lock case follows the row rather than splitting it: a menu whose
|
||||
/// hero slot appears and disappears by file type and *also* greys by lock would be two rules
|
||||
/// where the section has one.
|
||||
///
|
||||
/// It sits between the read-only pair and Remove, on the divider grammar the Board menu uses
|
||||
/// (`KanbanApp`): reads first, then the edit-shaped rows, with the destructive one last.
|
||||
@ViewBuilder
|
||||
private func menu(for name: String) -> some View {
|
||||
Button("Open") { attachments.open(name) }
|
||||
Button("Reveal in Finder") { attachments.reveal(name) }
|
||||
|
||||
if CardAttachments.canSetHero(
|
||||
name, hero: attachments.hero, names: names, isEditable: attachments.isEditable
|
||||
) {
|
||||
Divider()
|
||||
Button("Set as Hero") { attachments.setAsHero(name) }
|
||||
} else if CardAttachments.canRemoveHero(
|
||||
name, hero: attachments.hero, isEditable: attachments.isEditable
|
||||
) {
|
||||
Divider()
|
||||
Button("Remove Hero") { attachments.removeHero() }
|
||||
}
|
||||
|
||||
Divider()
|
||||
Button("Remove") { attachments.remove(name) }
|
||||
.disabled(!attachments.isEditable)
|
||||
@@ -249,6 +281,9 @@ private struct AttachmentRow: View {
|
||||
let name: String
|
||||
let url: URL?
|
||||
let isSelected: Bool
|
||||
/// Whether this row's file is the card's hero — spoken, and nothing more: the row draws no badge
|
||||
/// of its own, because the banner *is* the sighted signal and it is right there on the card face.
|
||||
let isHero: Bool
|
||||
let isSectionFocused: Bool
|
||||
let thumbnails: AttachmentThumbnailCache
|
||||
let pointSize: CGFloat
|
||||
@@ -279,6 +314,7 @@ private struct AttachmentRow: View {
|
||||
.help(name)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(name)
|
||||
.accessibilityValue(isHero ? AccessibilityPhrases.heroAttachment : "")
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
.task(id: url?.path) {
|
||||
guard let slot, let url else { return }
|
||||
|
||||
Reference in New Issue
Block a user