Choose Image… lets the Style editor point a board at an arbitrary picture

A "Choose Image…" row joins "Other…" beside the background palette, live
only when the Style… popover is aimed at the board (background.image is a
board-root field — lanes and cards carry no such key to write). A standard,
image-restricted NSOpenPanel hands the pick to BoardStore.applyChosenBackground,
which copies the bytes into .backgrounds/ under the file's own name — Finder-
laddered on collision, overwritten in place on a repeat pick — points
background.image at the copy, and leaves background.color exactly as it was,
the same posture Paste as Board Background already carries. The existing
repoint tidy trims a superseded .backgrounds/ file automatically; nothing
about it needed to change.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 10:37:55 -04:00
parent dfc6057f0d
commit aa8c54fc7a
3 changed files with 520 additions and 4 deletions
+108
View File
@@ -2530,6 +2530,114 @@ public final class BoardStore: HealHost {
document.setBackgroundImage(name)
}
// MARK: - Chosen background
/// **Applies a user-picked picture as this board's backdrop** the Style editor's **Choose
/// Image** row (03-board-ui.md § Styling Controls; ruled 2026-08-09, reversing
/// BackgroundField.swift's original "there is no image picker and none is planned").
///
/// `applyPastedBackground` line for line the same two-writes-one-bracket ordering (picture
/// first, so a failure never leaves the board naming a file that is not there), the same
/// `.setBoardBackground` operation, the same swallowed failure, the same restyle step, the same
/// **colour-left-untouched** posture (an arbitrary picture off Finder carries no ground colour any
/// more than one off the pasteboard does `setBackgroundImage`'s per-subkey contract) with one
/// difference from every other producer:
///
/// ### The name is the picked file's own
///
/// The generator writes a synthetic `facets.png`; a paste has no name at all and mints "Pasted
/// Background"; **a Choose Image pick already has a name**, the one the user found it under in
/// Finder, and throwing it away in favour of another synthetic stem would make `.backgrounds/`
/// less legible about what it holds, not more. So `baseName` the caller's
/// `URL.lastPathComponent`, never opened by this method is what `boardImageName(base:
/// replacing:inRoot:)` ladders and echoes against, exactly as `facets.png` and `Pasted
/// Background.png` already are: a first pick of `sunset.jpg` lands at `.backgrounds/sunset.jpg`, a
/// second pick of a same-named file steps aside to `sunset 2.jpg` (`BoardWriter.freshName`,
/// re-picking the file this board already shows overwrites it in place, exactly as re-pasting
/// does), and every other rule `boardImageName` already enforces the foreign-file step-aside, the
/// legacy-reference-is-not-ours reading, the fast-repeat echo composes for free.
///
/// ### The bytes travel verbatim
///
/// Unlike a paste, which re-encodes an interchange bitmap to PNG because TIFF is not a format
/// anyone keeps a file in, a chosen file is *already* a file on the user's disk decoding and
/// re-encoding it here would cost fidelity (and, for an animated GIF, the animation) for a picture
/// that needed neither. `BoardBackdrop.decode`'s 3072px ceiling governs what the app *renders* on
/// window resize, off ImageIO's thumbnail path it is not a write-time re-encode, and this method
/// does not decode the picture at all: `data` is copied through `BoardWriter.writeBoardImage`
/// exactly as the caller read it.
///
/// ### The prior `.backgrounds/` file is trimmed on the same terms as every other producer
///
/// Once both writes land, `snapshot`'s prior image is trimmed when it names a file of ours other
/// than the one this write just landed on (`tidyReplacedBackgroundImage` `applyGeneratedBackground`'s
/// own note carries the full reasoning, including the echo-window staleness gate). Composed, not
/// reimplemented: this method calls the very same static helper every other producer calls.
///
/// ### The undo restores the field, not the bytes
///
/// `applyGeneratedBackground`'s own note, unchanged and for its reason: an undo across a re-pick of
/// the *same* file overwrites pixels nothing kept a copy of. A copy this gesture wrote and an undo
/// then walks away from is exactly what the open-time sweep exists to find (`tidyBackgroundImages`)
/// this method adds no heal of its own because none is needed.
///
/// - Parameter data: the picked file's bytes, read by the caller and handed over unchanged.
/// - Parameter baseName: the picked file's own name (`URL.lastPathComponent`) never a path;
/// `BoardWriter.writeBoardImage`'s own guard refuses anything else.
/// - Returns: whether bytes reached disk, which is the same question as "is an echo reload coming"
/// (`applyPastedBackground`'s own rule).
@discardableResult
public func applyChosenBackground(data: Data, baseName: String) -> Bool {
let root = rootURL
let priorImage = snapshot.backgroundImage
let target = boardImageName(base: baseName, replacing: priorImage.value, inRoot: root)
let backgroundsFolder = root.appendingPathComponent(
BoardBackdrop.backgroundsFolderName, isDirectory: true
)
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
try BoardWriter.writeBoardImage(
data: data, named: target.bareName, inRoot: backgroundsFolder, operation: .setBoardBackground
)
try BoardWriter.updateIndex(
inItemFolder: root, kind: .board, operation: .setBoardBackground
) { document in
document.setBackgroundImage(target.reference)
}
// The replace-in-place half of the orphan tidy (ruled 2026-08-09) see
// `applyGeneratedBackground`'s own note.
Self.tidyReplacedBackgroundImage(
priorImage: priorImage.value, newReference: target.reference, inFolder: backgroundsFolder
)
}
guard landed != nil else { return false }
generatedBackgroundEcho = (
name: target.reference, bareName: target.bareName, base: baseName, reloads: landedReloads
)
// restyle prior image (13-native-undo.md Rules). The board's own stack, never a window's:
// there is no card here to have a session. Only the image subkey is declared, exactly as the
// paste's own step declares it this gesture never writes a colour.
registerStep(
HistoryPhrase.name(.restyle, kind: .board),
undoExpects: [.present(root, .backgroundImage(target.reference))],
redoExpects: [.present(root, .backgroundImage(priorImage.value))]
) { _ in
try BoardWriter.updateIndex(
inItemFolder: root, kind: .board, operation: .setBoardBackground
) { document in
document.setBackgroundImage(priorImage.value)
}
} redo: { _ in
try BoardWriter.updateIndex(
inItemFolder: root, kind: .board, operation: .setBoardBackground
) { document in
document.setBackgroundImage(target.reference)
}
}
return true
}
// MARK: - Creation
/// Creates a lane at the board's right end File New Lane N (11-command-nexus.md).
+80 -4
View File
@@ -1,5 +1,6 @@
import AppKit
import SwiftUI
import UniformTypeIdentifiers
/// **The one style editor** "a background palette grid and a curated symbol grid presented from
/// three anchors" (03-board-ui.md § Styling Controls), plus the two small surfaces that stand
@@ -397,16 +398,27 @@ struct StyleEditorView: View {
// MARK: - Background
/// The palette wells and their leading None (03-board-ui.md § Styling Controls: "palette-only
/// in-app plus a leading **None** well that removes the `background` key"), and since
/// 2026-08-09 an **Other** row onto the system Colors panel.
/// in-app plus a leading **None** well that removes the `background` key"), an **Other** row
/// onto the system Colors panel (2026-08-09), and on the board target alone a **Choose
/// Image** row onto a standard, image-restricted `NSOpenPanel` (ruled 2026-08-09, reversing
/// BackgroundField.swift's original "there is no image picker and none is planned").
///
/// **"Palette-only in-app" is the sentence that changed**, and it changed before this card:
/// `ColorComboView` shipped an **Other** row of its own, so the card window's sidebar could
/// already write an arbitrary hex while the Style popover the *primary* styling surface
/// could not. This closes that gap rather than opening a new one. Seventeen wells at seven
/// columns is three rows where the old twelve made two, which is the other half of the same change.
///
/// **Choose Image is absent, not disabled, off the board.** `background.image` is a board-root
/// field alone a lane or a card has a colour well and nothing else to point an image subkey at
/// (`BoardModel.backgroundImage`; `Lane`/`Card` carry no such field) so an item-targeted editor
/// has no key this row could write, the same reasoning `showsSymbols`/`showsBackground` already
/// apply to their own anchors. `store.styleLevel(of: target) == .board` is the same predicate the
/// symbol section already computes for its own leading well's default, asked here for the row
/// instead.
private func backgroundSection(_ state: StyleFieldState, layout: StyleEditorLayout) -> some View {
VStack(alignment: .leading, spacing: layout.wellSpacing) {
let isBoardTarget = store.styleLevel(of: target) == .board
return VStack(alignment: .leading, spacing: layout.wellSpacing) {
sectionHeader("Background", current: backgroundCurrent(state), layout: layout)
StyleWellGrid(
wells: backgroundWells(state),
@@ -416,8 +428,13 @@ struct StyleEditorView: View {
StyleCommand.apply(background: change, to: target, in: store, recents: recents, on: undo)
}
)
HStack(spacing: 0) {
HStack(spacing: layout.wellSpacing) {
Spacer(minLength: 0)
if isBoardTarget {
Button("Choose Image…") { chooseBackgroundImage() }
.buttonStyle(.link)
.font(.caption)
}
Button("Other…") { openBackgroundPanel(state) }
.buttonStyle(.link)
.font(.caption)
@@ -445,6 +462,14 @@ struct StyleEditorView: View {
}
}
/// **Choose Image** opens the panel and, on a pick, hands the URL to `ChosenBoardBackground`,
/// the whole of this row's non-panel logic. Split out so a modal `NSOpenPanel.runModal()` is the
/// only thing this method does that a test cannot drive.
private func chooseBackgroundImage() {
guard let url = BoardBackgroundImagePanel.chooseImage() else { return }
ChosenBoardBackground.apply(from: url, to: store)
}
private func backgroundWells(_ state: StyleFieldState) -> [StyleWell] {
var wells = [StyleWell(id: 0, face: .noValue, label: "None", change: .remove, isSelected: state == .unset)]
for (index, color) in Palette.backgrounds.enumerated() {
@@ -565,6 +590,57 @@ struct StyleEditorView: View {
}
}
// MARK: - Choose Image
/// The panel behind **Choose Image** a single-selection, image-restricted `NSOpenPanel`
/// (03-board-ui.md § Styling Controls, ruled 2026-08-09) the same open-panel shape
/// `AttachmentPanel` (CardAttachments.swift) uses for attachments, narrowed to what `UTType.image`
/// claims.
///
/// **Restricted, unlike the attachments panel's "every file type"**: a background is a picture and
/// nothing else, so `allowedContentTypes` states that up front a filesystem provider that honours
/// it dims or refuses everything else before the panel ever returns. That is convenience, not the
/// boundary: `ChosenBoardBackground.apply(from:to:)` re-checks the pick regardless, because a drag
/// onto an open panel is not gated the same way a click on a listed row is.
@MainActor
enum BoardBackgroundImagePanel {
static func chooseImage() -> URL? {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.resolvesAliases = true
panel.allowedContentTypes = [.image]
panel.prompt = "Choose"
panel.message = "Choose an image for the board background."
guard panel.runModal() == .OK else { return nil }
return panel.urls.first
}
}
/// **Choose Image's whole non-panel logic** split out from `StyleEditorView.chooseBackgroundImage`
/// so the guard and the data flow are testable without driving a modal `NSOpenPanel`, the way
/// `PastedImage`'s pure classification is tested apart from `NSPasteboard`.
///
/// Three steps, none of them decoding the picture: the cheap guard first (`PastedImage.isImageName`
/// the panel's own filter, restated for a bypass such as a drag onto the panel), the sandboxed read
/// second (security-scoped exactly as `CardAttachments.add()` opens one), and
/// `BoardStore.applyChosenBackground` last, which owns the naming, the one write bracket, the orphan
/// tidy's composition and the undo step.
enum ChosenBoardBackground {
@discardableResult
@MainActor
static func apply(from url: URL, to store: BoardStore) -> Bool {
guard PastedImage.isImageName(url.lastPathComponent) else { return false }
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
guard let data = try? Data(contentsOf: url) else { return false }
return store.applyChosenBackground(data: data, baseName: url.lastPathComponent)
}
}
// MARK: - Current value
/// The current-value chip beside a section title: the one place an off-palette value is stated