The board wears a picture — background becomes a mapping, and the window chrome follows it under a thin frost
background is {color:, image:} and only a mapping at every level; the board's image paints the full window under a transparent title bar, with a thin-material frost strip keeping the chrome legible and the standard accommodations intact.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
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.
|
||||
struct Stamp: Equatable, Sendable {
|
||||
var modified: Date?
|
||||
var size: Int?
|
||||
}
|
||||
|
||||
static func stamp(of url: URL) -> Stamp {
|
||||
let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
|
||||
return Stamp(modified: values?.contentModificationDate, size: values?.fileSize)
|
||||
}
|
||||
|
||||
/// Decodes the file at `url`, downsampled to `maximumPixelSize` on its longest edge — or `nil`
|
||||
/// for anything that is not a readable image.
|
||||
///
|
||||
/// **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) -> CGImage? {
|
||||
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize,
|
||||
]
|
||||
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
|
||||
logger.debug("board backdrop image 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) }
|
||||
}
|
||||
}
|
||||
@@ -499,10 +499,11 @@ struct BoardSearchBar: View {
|
||||
let store: BoardStore
|
||||
let presentation: BoardSearchPresentation
|
||||
|
||||
/// Reduce Transparency — **this bar is the board's one glass underlay** ("glass underlays go
|
||||
/// solid, wherever they appear", 10-accessibility.md; the design's own example, the card face
|
||||
/// carousel's page dots, died with the carousel). `.bar` is a material, so under the setting it
|
||||
/// becomes the opaque window background (`Accommodations.Underlay`).
|
||||
/// Reduce Transparency — this bar is one of the board's two glass underlays, beside the
|
||||
/// backdrop's title-bar frost ("glass underlays go solid, wherever they appear",
|
||||
/// 10-accessibility.md; the design's own example, the card face carousel's page dots, died with
|
||||
/// the carousel). `.bar` is a material, so under the setting it becomes the opaque window
|
||||
/// background (`Accommodations.Underlay`).
|
||||
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||||
|
||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||
|
||||
@@ -106,6 +106,11 @@ struct BoardView: View {
|
||||
/// Increase Contrast, for the marquee band's border below (10-accessibility.md; `Accommodations`).
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
/// Reduce Transparency, for the backdrop's title-bar frost — the one glass underlay this view
|
||||
/// draws (10-accessibility.md: "glass underlays go solid, wherever they appear";
|
||||
/// `Accommodations.frost`).
|
||||
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||||
|
||||
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
|
||||
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
|
||||
/// must go back to meaning create/rename rather than nothing at all.
|
||||
@@ -418,13 +423,42 @@ struct BoardView: View {
|
||||
/// is what the design asks for — and it is why the board is the level 10-accessibility.md binds
|
||||
/// its ≥ 4.5:1 rule to: text does sit on it.
|
||||
///
|
||||
/// ### Three layers, and the window is the frame
|
||||
///
|
||||
/// The colour is the underlay, the image draws over it, and a frosted strip sits on top of both
|
||||
/// under the title bar. All of it runs the **full height of the window** — `ignoresSafeArea`
|
||||
/// here is what the window's own `fullSizeContentView` flip is for (`HostedWindowController
|
||||
/// .setExtendsContentUnderTitlebar`, driven from `BoardWindowHost`), and the two only ever move
|
||||
/// together: a board with no background of its own draws none of this and keeps the standard
|
||||
/// chrome exactly as it has always looked.
|
||||
///
|
||||
/// The **colour is painted even while an image is loading**, and stays painted underneath it: a
|
||||
/// decode is asynchronous (`BoardBackdropImage`) and a window that flashed the system background
|
||||
/// on open would be the hitch that work exists to avoid. It is also what a failed or missing
|
||||
/// image degrades to, with nothing said about it.
|
||||
///
|
||||
/// The **frost** is the price of the extended chrome: the title bar's own material is gone, so
|
||||
/// the traffic lights, the board-name widget and the toolbar would otherwise sit directly on a
|
||||
/// saturated colour or a photograph. It is a glass underlay (`Accommodations.frost` — the
|
||||
/// ladder's thin weight, both ends tried and retired: `.bar` reads as barely-there over a
|
||||
/// busy image, `.regular` and up as a veil the backdrop shouldn't pay for) and takes the
|
||||
/// standard accommodation: solid
|
||||
/// under Reduce Transparency, "wherever they appear". Its geometry is `frostStrip`'s — full
|
||||
/// strength through the top safe-area inset, dissolving over a short tail below it — with the
|
||||
/// inset read from a `GeometryReader` that is itself inside the `ignoresSafeArea`: the proxy
|
||||
/// still reports the inset it was told to ignore, which is exactly the title-bar-plus-toolbar
|
||||
/// band and moves on its own when the toolbar's size class changes. Nothing here hit-tests, so
|
||||
/// the widget and the toolbar above it are untouched.
|
||||
///
|
||||
/// ### The contrast rule is the colour's, and the image is outside it
|
||||
///
|
||||
/// The rule is enforced from the *text* side rather than here, because this view paints the
|
||||
/// surface and draws none of the glyphs on it. Whatever colour lands below — a palette name or a
|
||||
/// hand-written hex, they reach the same place — has its text colour computed against the
|
||||
/// threshold by `BoardTextInk`, composited over the window background in the active appearance
|
||||
/// and recomputed on an appearance flip; the two subtrees that sit on this fill
|
||||
/// (`LaneView.header` and `TrashLaneView.header` — their plates are translucent washes the
|
||||
/// colour shows through, where every card carries its own opaque plate,
|
||||
/// surface and draws none of the glyphs on it. Whatever colour lands below — a palette name, a
|
||||
/// hand-written hex, or the `color` subkey of the mapping form, they reach the same place — has
|
||||
/// its text colour computed against the threshold by `BoardTextInk`, composited over the window
|
||||
/// background in the active appearance and recomputed on an appearance flip; the two subtrees
|
||||
/// that sit on this fill (`LaneView.header` and `TrashLaneView.header` — their plates are
|
||||
/// translucent washes the colour shows through, where every card carries its own opaque plate,
|
||||
/// `BoardSurface.cardPlate`) take the answer as a `\.colorScheme` override.
|
||||
///
|
||||
/// **One path, two verification stories** (`ContrastMath`): the twelve palette pairs are checked
|
||||
@@ -433,12 +467,69 @@ struct BoardView: View {
|
||||
/// cannot be settled by a table of colours alone); an arbitrary hex is checked only as it
|
||||
/// renders, because its value arrives from a file.
|
||||
///
|
||||
/// **An image makes no AA claim at all**, and the ink does not try to derive one from it. Ink
|
||||
/// still follows the `color` reading — the colour the author chose to sit under the picture, or
|
||||
/// the default when they chose none — which is the same bytes-from-a-file posture an arbitrary
|
||||
/// hex already has, one step further out: a photograph has no single luminance to threshold
|
||||
/// against, the field has no in-app control that could warn about one, and a per-pixel answer
|
||||
/// would change as the window resized. An author who lays text over a busy picture is doing what
|
||||
/// the raw file exists to let them do.
|
||||
///
|
||||
/// A value that resolves to nothing paints nothing, so the window keeps the standard background:
|
||||
/// the same lenient degrade as the other two levels, and the bytes stay as written.
|
||||
@ViewBuilder
|
||||
private var boardBackground: some View {
|
||||
if let color = Palette.color(for: store.snapshot.background) {
|
||||
color
|
||||
let color = Palette.color(for: store.snapshot.background)
|
||||
let image = BoardBackdrop.imageURL(for: store.snapshot, root: store.rootURL)
|
||||
if color != nil || image != nil {
|
||||
GeometryReader { proxy in
|
||||
ZStack(alignment: .top) {
|
||||
color
|
||||
if let image {
|
||||
BoardBackdropImage(url: image, reloads: store.landedReloads)
|
||||
}
|
||||
frostStrip(inset: proxy.safeAreaInsets.top)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
// A background is scenery: the strip's own empty-surface gestures — the click that
|
||||
// clears the selection, the rubber band — live in `backdrop`, one layer in, and would be
|
||||
// swallowed by anything here that answered a hit test.
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// The frost, full-strength through the title-bar band and dissolving over a short tail below
|
||||
/// it — a scroll-edge dissolve rather than a shelf. The chrome sits on an even material the
|
||||
/// whole way down, and the strip's bottom edge is nowhere in particular, so the backdrop reads
|
||||
/// as one surface the chrome floats over rather than a bar laid across a picture. The tail is a
|
||||
/// fraction of the band, so it scales with the toolbar's own height and only ever reaches into
|
||||
/// the strip's outer padding, not the lanes.
|
||||
///
|
||||
/// Under Reduce Transparency the fade goes with the glass: "solid" means an honest opaque bar
|
||||
/// with the standard chrome's own hard edge (`Accommodations.frost`), not a solid that thins
|
||||
/// out — a partially transparent solid would be the setting's own defeat.
|
||||
@ViewBuilder
|
||||
private func frostStrip(inset: CGFloat) -> some View {
|
||||
let underlay = Accommodations.frost(reduceTransparency: reduceTransparency)
|
||||
if underlay == .solid {
|
||||
Rectangle().fill(underlay.style).frame(height: inset)
|
||||
} else if inset > 0 {
|
||||
let tail = inset * 0.35
|
||||
Rectangle()
|
||||
.fill(underlay.style)
|
||||
.frame(height: inset + tail)
|
||||
.mask {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .black, location: 0),
|
||||
.init(color: .black, location: inset / (inset + tail)),
|
||||
.init(color: .clear, location: 1),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user