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
228 lines
13 KiB
Swift
228 lines
13 KiB
Swift
import CoreGraphics
|
|
import ImageIO
|
|
import SwiftUI
|
|
import os
|
|
|
|
// MARK: - BoardBackdrop
|
|
|
|
/// **The board background's image half** (03-board-ui.md § Styling ▸ Capabilities; the `background`
|
|
/// mapping's `image` subkey, 01-storage-format.md § Frontmatter).
|
|
///
|
|
/// ### The path is relative, and it stays inside the board
|
|
///
|
|
/// `image: sunset.jpg` names a file in the board folder; `image: art/sunset.jpg` names one in a
|
|
/// subfolder of it. An absolute path, or any path that climbs out with `..`, resolves to **nothing**
|
|
/// — the same lenient degrade as an unrecognized colour, and for the same two reasons. A `.kanban`
|
|
/// folder is a document: it is what gets copied, zipped, synced and handed to somebody else, and a
|
|
/// background pointing at `/Users/someone/Pictures` would silently stop working the moment it left
|
|
/// this Mac. And the sandbox would refuse the read anyway — the board's own security-scoped access
|
|
/// is the only thing this app holds — so the rule the containment check states is the rule the
|
|
/// system would enforce one layer down, stated where it can be explained instead of failing.
|
|
///
|
|
/// The check is **lexical**, which is what makes it testable without a filesystem, and it is not the
|
|
/// security boundary: a symlink inside the board pointing anywhere at all still resolves here and is
|
|
/// still refused by the sandbox when the bytes are asked for. That is the correct division — 01's
|
|
/// "symlinks are never traversed" governs what the *loader* renders as items, and this reads bytes
|
|
/// nobody has an identity claim on.
|
|
///
|
|
/// ### Nothing here decides whether the file is any good
|
|
///
|
|
/// A path that resolves, a file that is missing, and a file that is not an image all end the same
|
|
/// way: no image, no banner, no defect, bytes untouched. There is no editing UI for the field at all
|
|
/// (Controls: "the raw file is the escape hatch"), so the one person who can be wrong about it is
|
|
/// the one person looking at the folder.
|
|
enum BoardBackdrop {
|
|
|
|
/// The longest edge, in pixels, the backdrop is ever decoded at.
|
|
///
|
|
/// Generous enough for a 6K display's short side and for the Retina backing of any window a
|
|
/// board is realistically shown in, and small enough that a 60-megapixel photo dropped in the
|
|
/// folder never becomes a 240 MB decode on a window resize. ImageIO does the reduction while it
|
|
/// reads (`decode`), so the full-size bitmap is never materialized at all.
|
|
static let maximumPixelSize = 3072
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-backdrop")
|
|
|
|
/// Where `path` lands inside `root`, or `nil` when it lands nowhere this board may read.
|
|
///
|
|
/// Standardized before the comparison so `art/../sunset.jpg` is recognized as the file it names
|
|
/// — the check is about where the path *ends up*, not how it is spelled. The trailing separator
|
|
/// on the root is what keeps a sibling board named `Boards/Work.kanban.backup` from passing a
|
|
/// prefix test against `Boards/Work.kanban`.
|
|
///
|
|
/// `~` is not expanded and is not special: a file honestly named `~notes.png` sitting in the
|
|
/// board folder resolves, because only the shell ever meant anything else by that character.
|
|
static func imageURL(named path: String, inBoardRoot root: URL) -> URL? {
|
|
// An absolute path has to be rejected before it is appended, not after: appending `/etc/x`
|
|
// to a root yields `<root>/etc/x`, which *passes* containment while naming a file the author
|
|
// plainly did not mean.
|
|
guard !path.isEmpty, !path.hasPrefix("/") else { return nil }
|
|
let root = root.standardizedFileURL
|
|
let candidate = root.appendingPathComponent(path).standardizedFileURL
|
|
guard candidate.path.hasPrefix(root.path + "/") else { return nil }
|
|
return candidate
|
|
}
|
|
|
|
/// This board's backdrop image, where it has a readable one to name.
|
|
static func imageURL(for board: BoardModel, root: URL) -> URL? {
|
|
guard let path = board.backgroundImage.value else { return nil }
|
|
return imageURL(named: path, inBoardRoot: root)
|
|
}
|
|
|
|
/// Whether this board paints a background of its own — **the window-chrome predicate**
|
|
/// (`BoardWindowHost`, `HostedWindowController.setExtendsContentUnderTitlebar`): a board with one
|
|
/// runs its content under a transparent title bar, and a board without one keeps the standard
|
|
/// chrome exactly as it has always looked.
|
|
///
|
|
/// It asks the *resolved* image URL rather than merely whether the key reads, so a path that
|
|
/// could never paint anything — absolute, or climbing out of the board — leaves the chrome alone
|
|
/// instead of producing a transparent title bar over the standard background. It does **not**
|
|
/// ask whether the file exists: that is a disk touch, this is read on every board render, and a
|
|
/// declared-but-missing image renders as the frosted strip alone — which is the honest picture of
|
|
/// a board that asked for a backdrop it has not got.
|
|
static func isCustom(_ board: BoardModel, root: URL) -> Bool {
|
|
Palette.color(for: board.background) != nil || imageURL(for: board, root: root) != nil
|
|
}
|
|
|
|
// MARK: Reading the bytes
|
|
|
|
/// The file's identity as far as reloading is concerned — modification date and size.
|
|
///
|
|
/// Both, because either alone is forgeable by an ordinary copy: a file replaced within the
|
|
/// timestamp's resolution keeps its date, and a re-export at the same instant rarely keeps its
|
|
/// byte count too. Missing values (a file that is not there) compare equal to each other, which
|
|
/// is what stops a board naming a missing image from re-decoding on every reload.
|
|
/// `Hashable` because a stamp is half of a cache key as well as a comparison: the card face's
|
|
/// hero cache files a decoded picture under "this path, as of these bytes" (`CardHeroCache`).
|
|
struct Stamp: Hashable, Sendable {
|
|
var modified: Date?
|
|
var size: Int?
|
|
}
|
|
|
|
static func stamp(of url: URL) -> Stamp {
|
|
// **The cached resource values are dropped first, and that is load-bearing.** A `URL` value
|
|
// memoizes what it was last told about the file behind it, so a stamp taken twice from *one*
|
|
// URL value answers with the first read's date and size however many times the bytes were
|
|
// replaced in between — and noticing exactly that is the only thing a stamp is for. A caller
|
|
// that happens to rebuild its URL each time was never affected; one that holds a URL and
|
|
// re-stats it (the card face's hero cache) would silently never see a change.
|
|
var url = url
|
|
url.removeAllCachedResourceValues()
|
|
let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
|
|
return Stamp(modified: values?.contentModificationDate, size: values?.fileSize)
|
|
}
|
|
|
|
/// Decodes the file at `url`, downsampled to `limit` pixels on its longest edge — or `nil` for
|
|
/// anything that is not a readable image.
|
|
///
|
|
/// **`limit` is the caller's, because "how big is big enough" is a question about the surface
|
|
/// being drawn.** A window-filling backdrop wants the default; a card face's hero band is two
|
|
/// orders smaller in area and passes its own (`CardHero.maximumPixelSize`), which is the whole
|
|
/// reason the parameter exists rather than a second copy of these four options.
|
|
///
|
|
/// **ImageIO's thumbnail path, not a full decode plus a resize**: `CGImageSourceCreateThumbnail
|
|
/// AtIndex` reads at a reduced scale, so the peak allocation is the *output* size rather than
|
|
/// the file's. `FromImageAlways` is what makes it a downsample rather than a lottery — without
|
|
/// it a JPEG carrying its own small embedded thumbnail would answer with that instead of the
|
|
/// picture. `WithTransform` applies the EXIF orientation, so a photo shot in portrait is not
|
|
/// laid on its side.
|
|
///
|
|
/// Never call this on the main actor; see `BoardBackdropImage`'s task.
|
|
static func decode(_ url: URL, limit: Int = maximumPixelSize) -> CGImage? {
|
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
|
|
let options: [CFString: Any] = [
|
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
kCGImageSourceShouldCacheImmediately: true,
|
|
kCGImageSourceThumbnailMaxPixelSize: limit,
|
|
]
|
|
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
|
|
logger.debug("image at \(url.lastPathComponent, privacy: .private) could not be decoded")
|
|
return nil
|
|
}
|
|
return image
|
|
}
|
|
}
|
|
|
|
// MARK: - BoardBackdropImage
|
|
|
|
/// The decoded backdrop, drawn to fill (03-board-ui.md § Styling ▸ Capabilities).
|
|
///
|
|
/// **Fill, cropped — never letterboxed and never stretched.** A background is a surface, so it
|
|
/// covers the window whatever its aspect ratio; the alternative would put bars of the underlying
|
|
/// colour along two edges and make the board look broken rather than styled.
|
|
///
|
|
/// ### The load is asynchronous, and that is the whole design of this view
|
|
///
|
|
/// A board is opened by double-clicking a folder, and the folder may contain a 60-megapixel
|
|
/// photograph. Decoding that on the main actor during a body evaluation is a visible hitch on open
|
|
/// and a worse one on every subsequent reload, so the work happens off it and the view simply has
|
|
/// nothing to draw until it lands — under the board's colour, which is already painted beneath.
|
|
///
|
|
/// The task is keyed on the URL and on the store's landed-reload count, which is the board's
|
|
/// FSEvents pulse: replacing `sunset.jpg` in Finder changes no *model* value, so the snapshot comes
|
|
/// back equal and `snapshotGeneration` deliberately does not move (`BoardStore.landedReloads`) —
|
|
/// keying on the generation would mean an edited image never reloaded. Every re-key costs one
|
|
/// `stat`; only a file that actually changed costs a decode.
|
|
struct BoardBackdropImage: View {
|
|
|
|
let url: URL
|
|
|
|
/// The board's landed-reload count — see the type's note. Not read from a store here because
|
|
/// this view has no other reason to hold one.
|
|
let reloads: Int
|
|
|
|
/// What is on screen, and what it was decoded from. One value rather than three `@State`s so a
|
|
/// URL, its stamp and its bitmap can never disagree about which file is being shown.
|
|
@State private var loaded: Loaded?
|
|
|
|
private struct Loaded {
|
|
let url: URL
|
|
let stamp: BoardBackdrop.Stamp
|
|
let image: CGImage
|
|
}
|
|
|
|
var body: some View {
|
|
// `Color.clear` establishes the frame the image fills and is what `clipped` trims against;
|
|
// the overlay is what overflows it. Decorative, because a board background is decoration in
|
|
// the precise sense 10-accessibility.md means — it carries no information VoiceOver could
|
|
// usefully say, and the ink rule keeps the text on it legible on its own.
|
|
Color.clear
|
|
.overlay {
|
|
if let loaded {
|
|
Image(decorative: loaded.image, scale: 1)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
}
|
|
}
|
|
.clipped()
|
|
.task(id: Key(url: url, reloads: reloads)) { await reload() }
|
|
}
|
|
|
|
/// The `.task` identity: the file, and the board's pulse.
|
|
private struct Key: Equatable {
|
|
let url: URL
|
|
let reloads: Int
|
|
}
|
|
|
|
/// Re-decodes when the bytes have changed, and only then.
|
|
///
|
|
/// `Task.detached` rather than a bare `await` on a `nonisolated` function, so the hop off this
|
|
/// view's actor is stated rather than inferred from whatever the language mode currently makes
|
|
/// of an async call. Cancellation is checked on the way back instead of forwarded into it: both
|
|
/// halves are short, and a stale bitmap assigned to a view that has gone away is the failure
|
|
/// worth preventing.
|
|
private func reload() async {
|
|
let url = url
|
|
let stamp = await Task.detached(priority: .utility) { BoardBackdrop.stamp(of: url) }.value
|
|
if let loaded, loaded.url == url, loaded.stamp == stamp { return }
|
|
guard !Task.isCancelled else { return }
|
|
|
|
let decoded = await Task.detached(priority: .userInitiated) { BoardBackdrop.decode(url) }.value
|
|
guard !Task.isCancelled else { return }
|
|
// A failure clears what was there: the file the board names is the file it shows, and
|
|
// holding the previous picture would make a broken path look like a working one.
|
|
loaded = decoded.map { Loaded(url: url, stamp: stamp, image: $0) }
|
|
}
|
|
}
|