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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user