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
147 lines
8.4 KiB
Swift
147 lines
8.4 KiB
Swift
import Foundation
|
|
|
|
/// **The write side of `background`** — the read side is `FrontmatterDocument.background` and
|
|
/// `.backgroundImage` (FrontmatterFields.swift).
|
|
///
|
|
/// `background` is a mapping and only a mapping (01-storage-format.md § Frontmatter, ruled
|
|
/// 2026-08-06), so the app writes one: `{color: "#112233", image: sunset.jpg}`. That one key holds
|
|
/// two independent values and the app has a control for exactly one of them — the colour grid
|
|
/// (03-board-ui.md § Styling ▸ Controls). There is no image picker and none is planned; the path is
|
|
/// hand-written, "the raw file is the escape hatch" applied one field over. So a style write edits
|
|
/// the **subkey, not the key**: choosing a well on a board that carries an image leaves the image
|
|
/// where it is, and the None well removes the colour alone.
|
|
///
|
|
/// A key that is *not* a mapping — a retired scalar somebody hand-wrote, a sequence — has no
|
|
/// subkeys to preserve and is simply replaced by the mapping the app writes. That is the
|
|
/// malformed-value-cleared posture `icon` already has ("choosing any well replaces it",
|
|
/// § Styling ▸ Controls), which is exactly right here: the reader could not make a colour of it
|
|
/// either.
|
|
///
|
|
/// ### Preservation is per subkey, not per byte
|
|
///
|
|
/// The document's surgical editor rewrites a key's whole value lines, and `FrontmatterValue` has no
|
|
/// mapping case to rewrite them with — the engine emits scalars and has never round-tripped a
|
|
/// collection. So the merged value is re-emitted as a flow mapping through the `.raw` escape, built
|
|
/// from the *parsed* subvalues: every other subkey survives as a value and in its original position,
|
|
/// while its spelling does not — a block mapping collapses to flow form, quoting is normalized, and
|
|
/// a subvalue's own inline comment is lost with the lines it sat on.
|
|
///
|
|
/// That is the narrowest place in the app where 01-storage-format.md's verbatim promise yields, and
|
|
/// it yields only on the one key the write was already rewriting: a board with no `background` key,
|
|
/// or one written as a plain scalar, takes exactly the path it always took. The alternative — a
|
|
/// mapping-aware span editor — is a great deal of machinery for a field with two subkeys, one of
|
|
/// which the app writes.
|
|
extension FrontmatterDocument {
|
|
|
|
/// Writes a lenient string style field — `title`, `background`, `icon` — or removes the key
|
|
/// when `value` is `nil`, which is what "before" means for a field that was not there and what
|
|
/// the None well leaves behind.
|
|
///
|
|
/// `title` and `icon` are a plain scalar `set`/`remove`, unchanged and unchangeable: their
|
|
/// values *are* strings.
|
|
///
|
|
/// **`background` is always written as a mapping**, whatever it held before. One already written
|
|
/// as one keeps it, with the `color` subkey replaced in place, appended when it was absent, or
|
|
/// dropped — every other subkey carried through either way. Anything else starts from no subkeys
|
|
/// at all, so a colour lands as `{color: "…"}` and a removal simply takes the key. A mapping the
|
|
/// removal empties takes the key with it too, because `background: {}` is a key that says nothing
|
|
/// and the removal's contract is that the field is gone.
|
|
///
|
|
/// Deliberately keyed on `background` rather than on "whatever is mapping-shaped": `icon` has no
|
|
/// subkey vocabulary at all, so a hand-written `icon: {a: 1}` — a malformed value the forward
|
|
/// write exists to clear — must be *replaced* by the chosen symbol, never merged into.
|
|
public mutating func setStyleValue(_ value: String?, for key: String) {
|
|
guard key == FrontmatterKeys.background else {
|
|
if let value {
|
|
set(key, to: .string(value))
|
|
} else {
|
|
remove(key)
|
|
}
|
|
return
|
|
}
|
|
// Only a mapping has subkeys worth carrying; every other shape — absent, the retired scalar,
|
|
// a sequence — starts empty and is replaced outright by what the app writes.
|
|
var existing: [YAMLValue.Pair] = []
|
|
if case let .mapping(pairs)? = self.value(for: key) { existing = pairs }
|
|
|
|
let merged = Self.merged(existing, subkey: FrontmatterKeys.Background.color, value: value)
|
|
if merged.isEmpty {
|
|
remove(key)
|
|
} else {
|
|
set(key, to: .raw(Self.flowMapping(merged)))
|
|
}
|
|
}
|
|
|
|
/// `pairs` with `subkey` set to `value`, or removed when it is `nil` — **in place**: a subkey
|
|
/// that was already there comes back at the index it occupied, so the author's own key order
|
|
/// survives a colour change. One that was not there is appended, which is the only position that
|
|
/// says nothing about what the author intended.
|
|
private static func merged(
|
|
_ pairs: [YAMLValue.Pair],
|
|
subkey: String,
|
|
value: String?
|
|
) -> [YAMLValue.Pair] {
|
|
var merged = pairs.filter { $0.key != .string(subkey) }
|
|
guard let value else { return merged }
|
|
let pair = YAMLValue.Pair(key: .string(subkey), value: .string(value))
|
|
guard let index = pairs.firstIndex(where: { $0.key == .string(subkey) }) else {
|
|
merged.append(pair)
|
|
return merged
|
|
}
|
|
// Every survivor ahead of the old occurrence kept its index, so the old index is still the
|
|
// right hole; the clamp is belt-and-braces against a shape the parser cannot actually produce
|
|
// (a nested duplicate key is `unparseableYAML`, so at most one pair was filtered out).
|
|
merged.insert(pair, at: min(index, merged.count))
|
|
return merged
|
|
}
|
|
|
|
/// The pairs as a single-line YAML flow mapping — the one form the span editor can write, since
|
|
/// it replaces a key's value with one line's worth of text.
|
|
private static func flowMapping(_ pairs: [YAMLValue.Pair]) -> String {
|
|
"{" + pairs.map { "\(flowKey($0.key)): \(flowText($0.value))" }.joined(separator: ", ") + "}"
|
|
}
|
|
|
|
/// A mapping key in flow context: plain when it is a bare word — a letter or `_` first, then
|
|
/// letters, digits, `-`, `_`, `.` — and emitted as a value otherwise.
|
|
///
|
|
/// The pretty case is the only one that occurs (`color`, `image`, an agent's own subkey) and is
|
|
/// worth keeping pretty: this text is read by hand. The fallback is what stops a key nobody
|
|
/// anticipated from breaking the collection it is written into.
|
|
private static func flowKey(_ value: YAMLValue) -> String {
|
|
guard case let .string(text) = value, let first = text.unicodeScalars.first,
|
|
CharacterSet.letters.contains(first) || first == "_",
|
|
text.unicodeScalars.allSatisfy({
|
|
CharacterSet.alphanumerics.contains($0) || $0 == "-" || $0 == "_" || $0 == "."
|
|
})
|
|
else { return flowText(value) }
|
|
return text
|
|
}
|
|
|
|
/// One value inside a flow collection.
|
|
///
|
|
/// **Strings are always double-quoted**, which is the rule that makes this safe without a YAML
|
|
/// emitter: `FrontmatterValue.emitScalar`'s round-trip check asks whether a value survives in
|
|
/// *block* context, and flow context ends a plain scalar at `,`, `]`, `}` and `: ` too — so a
|
|
/// colour or a path that round-trips fine on its own line could still break the mapping it is
|
|
/// written into. Quoting costs two characters on a hex that would not have needed them, and a
|
|
/// hex is what this almost always writes.
|
|
///
|
|
/// The scalar cases route through `FrontmatterValue` rather than re-deriving their text, so a
|
|
/// preserved subkey's number or timestamp is emitted by the same code that writes `order` and
|
|
/// `created`. Nested collections recurse, which keeps an unknown subkey holding a list from
|
|
/// being flattened into its `description`.
|
|
private static func flowText(_ value: YAMLValue) -> String {
|
|
switch value {
|
|
case .null: "null"
|
|
case let .bool(value): FrontmatterValue.bool(value).yamlText
|
|
case let .int(value): FrontmatterValue.int(value).yamlText
|
|
case let .double(value): FrontmatterValue.double(value).yamlText
|
|
case let .date(value): FrontmatterValue.date(value).yamlText
|
|
case let .string(value): FrontmatterValue.emitQuoted(value)
|
|
case let .sequence(values): "[" + values.map(flowText).joined(separator: ", ") + "]"
|
|
case let .mapping(pairs):
|
|
"{" + pairs.map { "\(flowKey($0.key)): \(flowText($0.value))" }.joined(separator: ", ") + "}"
|
|
}
|
|
}
|
|
}
|