Files
lanework/Kanban/UI/Board/CardHeroImage.swift
T
rzen ce92c24190 Hero image for cards — one of the card's own attachments, banded across its face
A card whose `hero:` names one of its own attachments draws that picture as a
banner across the full width of its plate, above the icon-and-title row,
aspect-fill cropped into a fixed 2.75 em band — 36pt at the standard body, and
em-scaled like every other figure the board draws, so it grows with the system
text size and with the board's zoom rather than shrinking against a title twice
its usual size. The figure sits deliberately under the 44pt a plain one-line
card is tall: a hero card should read as a card with a picture on it rather than
a picture with a caption, which is 03's standing rule that the title dominates.

The key's grammar is a **bare filename**, and that is what separates it from the
board background's `image` subkey rather than a nervousness about paths. A board
names a file anywhere under its root, so a path is that key's reading and where
it leads is the renderer's question. A card names one of the files it already
owns — the flat `attachments/` folder the app lists, relocates into, and carries
through every move, copy, trash and restore — so `hero: art/sketch.png` is not an
awkward spelling of a hero image, it is a value the key cannot mean. It therefore
has no reading at all: a value carrying a separator, or spelling `.`/`..`, or
empty, is malformed at the document layer, which renders it as absent and leaves
the coerce tier's trace, exactly as `width: 1.5` does. The bytes stay as written,
the resolver re-checks containment anyway, and the whole degrade family below
that — a name pointing at a missing file, an unreadable one, or one that is not
an image — ends the same way: no banner, no defect, nothing written.

That last promise is about *height* as much as about ink, so the band is given no
height at all until a picture has actually decoded. A card whose hero cannot be
drawn lays out identically to a card with no key, structurally rather than by a
branch somebody has to remember; the price is one settle per hero as a board
opens, and none after that. Everything else the face draws is attached outside
the new stack and is untouched by it — the accent stripe still runs the plate's
full leading edge across the band's corner, the selection and file-hover strokes
still ring the whole plate, the cut and drag dims still cover it, and the drop
model still registers the plate's real height, so a hero card is simply a taller
card the masonry already understands. The trash draws it too, by the one-face
rule.

Decoding is ImageIO's downsampling path off the main actor at a quarter of the
backdrop's pixel budget (`BoardBackdrop.decode` gained the limit as a parameter
rather than being copied), and the results live in one app-wide, deliberately
non-observable cache keyed on path plus the file's date and size. Non-observable
because a tracked write there would invalidate every hero face on the board,
which is the O(board) invalidation this view was rebuilt once already to shed;
each face holds its own picture in view state and seeds it from the cache, which
is also what lets the drag replica — whose preview builder is non-escaping and
cannot await anything — carry the band at the face's real height. Taking a stamp
twice from one URL value turned out to answer with the first read's date and size
however many times the bytes had changed, so `stamp(of:)` now drops its cached
resource values first; noticing a replacement is the only thing a stamp is for.

The face takes the resolved URL as a compared input rather than resolving it, for
selected-ness's reason one axis over: resolving needs the card's folder, which a
face does not know, and finding it from the snapshot would be a board walk per
face. The lane and the trash column each know their own container and compute it
once for the whole strip.

There is no in-app setter this version — the key is written by hand or by an
agent, which is why the guide bumps to v13 with a clause spelling the grammar out
beside the other card keys, and why `attachments/` gets the one-line pointer an
agent that has just written `![](attachments/x.png)` will need. "Set as Hero"
from the attachment row is future work, as is the card window and print, which
draw the same model and show no banner today.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 23:41:15 -04:00

265 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CoreGraphics
import SwiftUI
import os
// MARK: - CardHero
/// **The card face's hero image** (03-board-ui.md § Card face ▸ Hero image; the card's `hero` key,
/// 01-storage-format.md § Frontmatter) — `BoardBackdrop`'s opposite number one level down, and
/// deliberately its opposite in the one respect that matters.
///
/// ### The board names a path; a card names one of its own files
///
/// A board's backdrop takes a path relative to the board root, because a board is a folder and the
/// picture may sensibly live anywhere in it. A card's hero takes a **bare filename** and resolves it
/// inside that card's own `attachments/` folder, because that folder is already the answer to "where
/// do this card's files live": the app lists it, relocates loose files into it, and — the point — a
/// move, a copy, a trash and a restore all carry it with the card. A hero named that way survives
/// every one of those gestures with nothing to rewrite.
///
/// The reading refuses a path outright (`FrontmatterDocument.hero`), so by the time a name reaches
/// this file it is already separator-free. The containment check below is still made, and is not
/// redundant: a lenient reading must never be the only thing between a value and the filesystem, and
/// this is the layer that actually builds the URL.
///
/// ### Nothing here decides whether the file is any good
///
/// A name that resolves nowhere, a file that is missing, and a file that is not an image all end the
/// same way — **no banner, exactly as with no key** (the ruling's own words): no defect, no badge, no
/// write. The face's band exists only where a picture was actually decoded (`CardHeroImage`), which
/// is what makes that promise structural rather than a branch somebody has to remember.
enum CardHero {
/// The longest edge, in pixels, a hero is ever decoded at.
///
/// A third of the backdrop's, and for a band a third of a window's height that is generous: the
/// widest a card face gets is one lane at full window width, and this covers that at 2× Retina
/// backing with room to spare. Smaller matters here in a way it does not for a backdrop — a board
/// has one backdrop and may have hundreds of hero cards, so the figure is a per-card memory cost
/// as much as a decode cost.
static let maximumPixelSize = 1024
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "card-hero")
/// Where `name` lands inside `cardFolder`'s `attachments/`, or `nil` when it lands nowhere this
/// card may read.
///
/// The gate is the schema's own grammar restated against the filesystem: a name has to be
/// non-empty, carry no separator, and — after standardizing, which is what catches a `.` or `..`
/// that slipped through — resolve to a direct child of the attachments folder. `BoardBackdrop`'s
/// containment check is the model; this one is stricter by exactly one clause, because "inside
/// this folder" and "directly inside this folder" are different promises and only the second one
/// is what an attachment is.
static func imageURL(named name: String, inCardFolder cardFolder: URL) -> URL? {
guard !name.isEmpty, !name.contains("/") else { return nil }
let attachments = cardFolder
.appendingPathComponent(BoardWriter.attachmentsFolderName, isDirectory: true)
.standardizedFileURL
let candidate = attachments.appendingPathComponent(name).standardizedFileURL
guard candidate.deletingLastPathComponent().path == attachments.path else { return nil }
return candidate
}
/// This card's hero image, where it names one — `container` is the folder the card's own folder
/// sits in: its lane on the board side, `<root>/.trash/` on the trash side.
///
/// **The container comes from the caller because the face must not go looking for it.** A card
/// face knows its card and its container, not its path; resolving that path from the snapshot
/// would be a walk of the board per face, which is the O(board)-per-face shape
/// RENDER-INSTRUMENTATION.md exists to keep out of this view. The two callers are the lane and
/// the trash column, each of which already knows its own folder and computes it once for the
/// whole strip.
///
/// The key's absence is the first thing checked, so the overwhelmingly common card — no `hero`
/// at all — costs one optional read and builds no URLs.
static func imageURL(for card: Card, inContainer container: URL) -> URL? {
guard let name = card.hero.value else { return nil }
let folder = container.appendingPathComponent(card.id.rawValue, isDirectory: true)
guard let url = imageURL(named: name, inCardFolder: folder) else {
logger.debug("card hero names nothing readable")
return nil
}
return url
}
}
// MARK: - CardHeroCache
/// Decoded hero images, app-wide — **one decode per picture per version of it**, whoever draws it.
///
/// ### Why a shared cache rather than each face's own `@State`
///
/// `BoardBackdropImage` keeps its bitmap in view state, which is right for a view there is one of.
/// A hero face is drawn many times over: every card in every lane of every open board, plus the drag
/// replica, which is a *second*, separately-built rendition of the same face. View state cannot span
/// those, so each would decode the same file again, and a face rebuilt at a new identity — a filter
/// change, a lane move — would decode it once more.
///
/// ### Deliberately not `@Observable`
///
/// A tracked write here would invalidate every view that had read the dictionary, which is every hero
/// face on the board — the O(board) invalidation this codebase has already paid for once
/// (RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies). Instead the cache is inert
/// storage: a face reads it during a body pass, and the *face's own* `@State` is what redraws when
/// its picture lands. A face that misses simply draws no band until its task fills one in.
///
/// ### Freshness, and its one honest limit
///
/// The key carries the file's stamp, so a replaced file keys differently and the stale entry ages out
/// under the cap — `AttachmentThumbnailCache`'s posture, and there is deliberately no invalidation
/// path to keep honest. What the stamp cannot do on its own is *notice*: the stamp is read in a face's
/// task, and that task re-runs when the face's URL changes rather than when the board reloads, so a
/// hero file replaced in place under an open board refreshes on the next thing that rebuilds the face
/// rather than immediately. The alternative — keying the task on the board's reload pulse, as the
/// backdrop does — would invalidate every hero face on every filesystem event anywhere in the board,
/// which is the wrong trade at this multiplicity.
@MainActor
enum CardHeroCache {
/// **Which file, as of which bytes.** Not the drawn size: the decode is downsampled to one
/// figure (`CardHero.maximumPixelSize`) rather than to the band's own dimensions, so a zoom
/// change re-lays out and re-crops without costing a single decode.
struct Key: Hashable {
let path: String
let stamp: BoardBackdrop.Stamp
}
/// How many decoded heroes the app keeps. Sized for "the hero cards on screen across the open
/// boards" with room around it; a plain insertion-ordered drop rather than a recency policy,
/// `AttachmentThumbnailCache`'s choice for its reason — a board's access pattern is the cards it
/// is showing.
static let limit = 64
private static var images: [Key: CGImage] = [:]
/// Insertion order over `images`, for the cap.
private static var order: [Key] = []
/// This key's picture, or `nil` when it has not been decoded — the one dictionary read a render
/// is allowed to do.
static func image(for key: Key) -> CGImage? {
images[key]
}
/// The picture for this file as of the last time anyone stamped it, without touching the disk —
/// what a **synchronously drawn** rendition has to make do with (the drag replica: an
/// `.onDrag(_:preview:)` builder runs while the body does and cannot await a decode).
///
/// A miss draws no band, which is the same thing a face that has not loaded yet draws; it is not
/// a failure and there is nothing to report.
static func image(forFileAt url: URL) -> CGImage? {
guard let stamp = stamps[url.path] else { return nil }
return images[Key(path: url.path, stamp: stamp)]
}
/// The last stamp seen for each path — the bridge between a render, which may not `stat`, and the
/// task that did.
private static var stamps: [String: BoardBackdrop.Stamp] = [:]
/// Resolves this file's stamp and decodes it if that stamp has no picture — the whole of the
/// cache's write side, called from a face's `.task` and never from a body. Answers the picture
/// so the caller can hold it in its own state.
///
/// Both halves run off the main actor: the `stat` because a render is waiting on this task, and
/// the decode because it is ImageIO reading a file. Only `CGImage` — which is `Sendable` — comes
/// back.
static func load(_ url: URL) async -> CGImage? {
let stamp = await Task.detached(priority: .utility) { BoardBackdrop.stamp(of: url) }.value
stamps[url.path] = stamp
let key = Key(path: url.path, stamp: stamp)
if let cached = images[key] { return cached }
guard !Task.isCancelled else { return nil }
let limit = CardHero.maximumPixelSize
let decoded = await Task.detached(priority: .userInitiated) {
BoardBackdrop.decode(url, limit: limit)
}.value
guard let decoded else { return nil }
remember(decoded, for: key)
return decoded
}
private static func remember(_ image: CGImage, for key: Key) {
if images.updateValue(image, forKey: key) == nil {
order.append(key)
}
while order.count > limit {
images.removeValue(forKey: order.removeFirst())
}
}
/// Forgets everything — tests only, so one suite's fixtures cannot decide another's hits.
static func removeAll() {
images.removeAll()
order.removeAll()
stamps.removeAll()
}
}
// MARK: - CardHeroImage
/// The hero banner: the decoded picture, drawn to fill a band across the top of a card's plate
/// (03-board-ui.md § Card face ▸ Hero image).
///
/// **Fill, cropped — never letterboxed and never stretched**, `BoardBackdropImage`'s rule for its
/// reason: a band of the plate's colour down two edges would make a styled card look like a broken
/// one. The crop is centred, which is what an aspect-fill is; there is no focal point to choose from
/// and no key to write one in.
///
/// ### The band exists only when there is a picture
///
/// A card whose `hero` names a file that is missing, unreadable, or not an image "renders exactly as
/// with no key" (the ruling). That is a promise about *height*, not just about ink, and the only way
/// to keep it without a disk touch during layout is to give the band no height until a decode has
/// actually landed. So this view is zero-tall until then and grows in one step when the picture
/// arrives — one settle per hero as a board opens, and none afterwards, because the cache answers the
/// second and every later draw synchronously.
struct CardHeroImage: View {
let url: URL
/// The band's height (`BoardMetrics.cardHeroHeight`) — the face's figure rather than this view's,
/// because it is the face's rhythm the band belongs to.
let height: CGFloat
/// The plate's corner radius, which the band's **top** corners round to exactly, so the picture
/// reads as the card's own edge rather than a photograph laid over it.
let cornerRadius: CGFloat
/// This face's copy of the decoded picture. Seeded from the shared cache in the task's first,
/// synchronous step, so a face rebuilt for any reason at all — a filter change, a lane move, a
/// re-open — gets its banner back in one pass rather than flashing through no band.
@State private var image: CGImage?
var body: some View {
// `Color.clear` establishes the band and is what `clipShape` trims against; the overlay is
// what overflows it. Decorative in the precise sense 10-accessibility.md means: the face is
// one flattened element carrying its title and attachment count, and a picture adds nothing
// VoiceOver could usefully say (`CardFaceView`'s `.accessibilityElement(children: .ignore)`
// would drop a label here anyway — saying it is what keeps the band inert in the replica too).
Color.clear
.frame(height: image == nil ? 0 : height)
.overlay {
if let image {
Image(decorative: image, scale: 1)
.resizable()
.aspectRatio(contentMode: .fill)
}
}
.clipShape(UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius))
.allowsHitTesting(false)
.task(id: url) { await load() }
}
/// Fills the band from the cache, and from the disk when the cache has nothing for these bytes.
///
/// A failure clears what was there: the file the card names is the file it shows, and holding the
/// previous picture would make a hero that has been deleted look like one that still works.
private func load() async {
image = CardHeroCache.image(forFileAt: url)
let decoded = await CardHeroCache.load(url)
guard !Task.isCancelled else { return }
image = decoded
}
}