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, `/.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 } }