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 `` 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
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user