The Background tab fills in — facets rendered to order, eight hues in a carousel
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -1136,6 +1136,13 @@ public final class BannerCenter {
|
||||
if let title { "Couldn't update '\(title)' to the current format" } else { "Couldn't update an item to the current format" }
|
||||
case let .style(title):
|
||||
if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
|
||||
case .setBoardBackground:
|
||||
// **"generate", because that is the button they pressed**, and no title because there is
|
||||
// one board and they are looking at it. It deliberately says nothing about the *file* —
|
||||
// the picture and the colour under it land in one bracket, and a user who has never seen
|
||||
// the PNG has no model of a half-written one; what failed, as far as they are concerned,
|
||||
// is that the board still looks the way it did.
|
||||
"Couldn't generate this board's background"
|
||||
case let .resize(title):
|
||||
if let title { "Couldn't resize '\(title)'" } else { "Couldn't resize the item" }
|
||||
case let .rename(title):
|
||||
|
||||
@@ -297,6 +297,16 @@ public final class BoardStore: HealHost {
|
||||
/// refreshes nothing — the pre-skip behaviour of `snapshotGeneration` exactly, kept exactly.
|
||||
public private(set) var landedReloads: Int = 0
|
||||
|
||||
/// **The generated background this store wrote, and the reload count it was written at** — the
|
||||
/// reroll's echo (`generatedBackgroundName(replacing:inRoot:)`, which is the only reader and
|
||||
/// carries the whole reasoning).
|
||||
///
|
||||
/// `@ObservationIgnored` because nothing renders it: it is bookkeeping about a file name, and a
|
||||
/// view that redrew when it changed would be redrawing for the write it is already going to be
|
||||
/// told about by the reload.
|
||||
@ObservationIgnored
|
||||
var generatedBackgroundEcho: (name: String, reloads: Int)?
|
||||
|
||||
/// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless
|
||||
/// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always
|
||||
/// describe the tree currently on screen.
|
||||
@@ -1944,6 +1954,140 @@ public final class BoardStore: HealHost {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generated background
|
||||
|
||||
/// **Applies a generated background to this board** — the picture into the board folder and the
|
||||
/// `background` mapping pointed at it, in one bracket (03-board-ui.md § Styling ▸ Capabilities;
|
||||
/// DESIGN/explorations/board-backgrounds.md; `FacetsGenerator`).
|
||||
///
|
||||
/// **The pixels are the caller's**, and that is the isolation contract: rendering a 3072 px mesh
|
||||
/// and PNG-encoding it is tens of milliseconds of pure computation, so it belongs on a detached
|
||||
/// task, and `FacetsGenerator` is `Sendable` and main-actor-free precisely so it can go there.
|
||||
/// What arrives here is finished `Data`. This method is synchronous for `applyStyle`'s reason: the
|
||||
/// write rides one `performWrite` bracket, which suspends the watcher — a suspension that must not
|
||||
/// span an `await`.
|
||||
///
|
||||
/// ### One bracket, two files
|
||||
///
|
||||
/// The image lands first and the frontmatter second, so a failure to write the picture never
|
||||
/// leaves the board naming one that is not there. The reverse order would; the two are not atomic
|
||||
/// together, and this is the ordering that makes the non-atomic half harmless. Both are inside the
|
||||
/// same bracket, so the churn rounds back as one app-mediated reload and mints one commit on git
|
||||
/// boards — the style batch's rule, one gesture one commit.
|
||||
///
|
||||
/// ### The name is chosen, not minted
|
||||
///
|
||||
/// Regenerating is the common gesture — the user rerolls until they like it — so a board must not
|
||||
/// accumulate a PNG per roll. The board's own generated file is therefore **overwritten in place**
|
||||
/// whenever `background.image` already names it, and the Finder ladder is used only when the name
|
||||
/// belongs to somebody else (`BoardWriter.freshName`): a hand-placed `facets.png` in the board
|
||||
/// folder is the user's file and is never written through.
|
||||
///
|
||||
/// ### The undo restores the fields, not the bytes
|
||||
///
|
||||
/// Stated plainly because it is the one place in the app where an inverse is not a full return:
|
||||
/// ⌘Z puts `background.image` and `background.color` back to what they said, and if this gesture
|
||||
/// **overwrote** a previous generation's PNG, those pixels are gone — nothing in the app kept a
|
||||
/// copy. The consequence is confined to regenerating over the app's own output (the image name is
|
||||
/// unchanged, so the fields come back pointing at a file whose contents are the new picture); an
|
||||
/// undo of the *first* generation removes the subkey and the board looks exactly as it did. Every
|
||||
/// alternative — a temp copy, a versioned name — buys byte-perfect undo of a picture nobody asked
|
||||
/// to keep at the price of litter in a folder the user owns.
|
||||
///
|
||||
/// Failure is `performWrite`'s: the banner is posted before the rethrow, which is swallowed here
|
||||
/// like every other gesture with no second thing to do about it.
|
||||
///
|
||||
/// - Parameter png: the encoded image, already rendered (`FacetsGenerator.pngData`).
|
||||
/// - Parameter colorHex: the ground colour of that render (`FacetsRecipe.primaryColorHex`) —
|
||||
/// written as `background.color` so the underlay, and a board copied without its picture,
|
||||
/// degrade to the image's own average rather than to nothing.
|
||||
/// - Returns: whether bytes reached disk, which is the same question as "is an echo reload
|
||||
/// coming" (`setLaneWidth`'s rule). Discardable: the popover has nothing to do with the answer.
|
||||
@discardableResult
|
||||
public func applyGeneratedBackground(png: Data, colorHex: String) -> Bool {
|
||||
let root = rootURL
|
||||
let priorImage = snapshot.backgroundImage
|
||||
let priorColor = snapshot.background
|
||||
let name = generatedBackgroundName(replacing: priorImage.value, inRoot: root)
|
||||
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.writeBoardImage(
|
||||
data: png, named: name, inRoot: root, operation: .setBoardBackground
|
||||
)
|
||||
// `kind: .board` for the one subject whose position nothing can infer — the board root
|
||||
// (`BoardWriter.updateIndex`'s on-touch backfill), exactly as `applyStyle` passes it.
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: root, kind: .board, operation: .setBoardBackground
|
||||
) { document in
|
||||
Self.pointBackground(at: name, color: colorHex, in: &document)
|
||||
}
|
||||
}
|
||||
guard landed != nil else { return false }
|
||||
generatedBackgroundEcho = (name: name, reloads: landedReloads)
|
||||
|
||||
// restyle → prior style (13-native-undo.md ▸ Rules). The board's own stack, never a window's:
|
||||
// there is no card here to have a session.
|
||||
registerStep(
|
||||
HistoryPhrase.name(.restyle, kind: .board),
|
||||
undoExpects: [.present(root, .background(colorHex), .backgroundImage(name))],
|
||||
redoExpects: [.present(root, .background(priorColor.value), .backgroundImage(priorImage.value))]
|
||||
) { _ in
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: root, kind: .board, operation: .setBoardBackground
|
||||
) { document in
|
||||
// A malformed prior reads as a removal on both subkeys, which is `restore(_:to:in:)`'s
|
||||
// own rule and the one the redo expectation above is written against.
|
||||
document.setBackgroundImage(priorImage.value)
|
||||
Self.restore(priorColor, to: FrontmatterKeys.background, in: &document)
|
||||
}
|
||||
} redo: { _ in
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: root, kind: .board, operation: .setBoardBackground
|
||||
) { document in
|
||||
Self.pointBackground(at: name, color: colorHex, in: &document)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// The name a generated background is written under: **ours to overwrite**, or the next free one.
|
||||
///
|
||||
/// `current` is what `background.image` says now. When that is already the generated name the
|
||||
/// file is this board's own output and is replaced in place — including when it has been deleted
|
||||
/// from the folder by hand, which is a board whose backdrop is broken and is exactly what
|
||||
/// regenerating fixes. Otherwise the Finder ladder decides, which yields the plain name when
|
||||
/// nothing holds it and `facets 2.png` when something does.
|
||||
///
|
||||
/// ### The reroll's echo
|
||||
///
|
||||
/// The snapshot is by construction one reload behind every write the app makes (the one-way
|
||||
/// flow), and rerolling is a gesture people repeat *fast* — faster than FSEvents rounds a write
|
||||
/// back. Read from the snapshot alone, the second roll would see no `background.image` yet, find
|
||||
/// its own first roll's file sitting on the name, and step aside to `facets 2.png`: a folder full
|
||||
/// of abandoned pictures, which is the exact outcome the fixed name exists to prevent.
|
||||
///
|
||||
/// So a name this store wrote **since the last landed reload** counts as ours. The gate is the
|
||||
/// reload count rather than a timer or a flag, because it is the honest statement of the problem:
|
||||
/// while it has not moved, the snapshot *cannot* know about the write, so the store's own memory
|
||||
/// is the better authority. Once a reload lands, the snapshot's `background.image` takes over and
|
||||
/// this memory stops being consulted — including when a hand edit pointed the board somewhere
|
||||
/// else in the meantime.
|
||||
private func generatedBackgroundName(replacing current: String?, inRoot root: URL) -> String {
|
||||
if current == FacetsGenerator.fileName { return FacetsGenerator.fileName }
|
||||
if let echo = generatedBackgroundEcho, echo.reloads == landedReloads { return echo.name }
|
||||
return BoardWriter.freshName(for: FacetsGenerator.fileName, in: root)
|
||||
}
|
||||
|
||||
/// Both subkeys, written into the mapping rather than over it (BackgroundField.swift) — spelled
|
||||
/// once so the gesture and its redo cannot drift apart on the order they land in.
|
||||
///
|
||||
/// Colour first, so a board that had no `background` key at all comes out spelled the way
|
||||
/// 01-storage-format.md § Frontmatter writes it: `{color: …, image: …}`.
|
||||
private static func pointBackground(at name: String, color: String, in document: inout FrontmatterDocument) {
|
||||
document.setStyleValue(color, for: FrontmatterKeys.background)
|
||||
document.setBackgroundImage(name)
|
||||
}
|
||||
|
||||
// MARK: - Creation
|
||||
|
||||
/// Creates a lane at the board's right end — File ▸ New Lane ⇧⌘N (11-command-nexus.md).
|
||||
|
||||
Reference in New Issue
Block a user