Build the styling system and shared style editor
One style-editor component, anchor-agnostic: a background grid (None well plus the 12 palette colors) and a curated symbol grid (the pathfinder's five-dozen set, leading well removing the icon key for the level default), selection-aware across cards, lanes, and the board itself. Batch edits compute per-dimension state — uniform, mixed (no well selected), or an off-palette value labeled verbatim outside the grids — and choosing a well applies to the whole target set as one write bracket, skipping no-ops per field. The popover tracks its target set live per the freshly ratified rule: targets re-resolve by UUID on every reload, a vanished target leaves the set, an emptied set dismisses the editor, and nothing ever silently retargets to the board. Anchors landing now: Board > Style (Opt-Cmd-S) and the card/lane context menus, which also carry the quick-style recents row (app-wide, persisted, capped at six, None never recorded) and the lane's width control twinning the menu chords. The styling system's other two renders arrive with it: a lane's background paints the C7 top-edge band, the board's paints the window content background — malformed values paint nothing and stay byte-identical on disk. 31 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -645,6 +645,153 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Styling
|
||||
|
||||
/// One item a style gesture is about to act on: where its `index.md` is, and what the two styled
|
||||
/// keys currently say there.
|
||||
///
|
||||
/// The editor reads these for its per-dimension current-value display (`StyleFieldState.resolve`)
|
||||
/// and `applyStyle` reads the *same* values to decide what is a no-op, so the display and the
|
||||
/// write can never disagree about what is already on disk. `id` is `nil` for the board root,
|
||||
/// which has no `ItemID` by design (see `ItemID`'s doc comment).
|
||||
public struct StyleSubject: Sendable, Equatable {
|
||||
public let id: ItemID?
|
||||
public let folder: URL
|
||||
public let background: FieldValue<String>
|
||||
public let icon: FieldValue<String>
|
||||
}
|
||||
|
||||
/// The live items `target` names, in display order — lanes left to right, each lane's cards top
|
||||
/// to bottom.
|
||||
///
|
||||
/// **Vanished targets are simply absent**, ancestor walk included: a tombstoned card, a card
|
||||
/// under a tombstoned lane, and an id that names nothing all contribute no subject, which is the
|
||||
/// same silent skip `commitRename` gives a vanished rename target — "nothing is ever written into
|
||||
/// a vanished folder". A style editor whose set has emptied dismisses (`StyleEditorSession`), so
|
||||
/// an empty result is a frame's worth of nothing to show rather than a state to handle.
|
||||
public func styleSubjects(of target: StyleTarget) -> [StyleSubject] {
|
||||
switch target {
|
||||
case .board:
|
||||
return [StyleSubject(
|
||||
id: nil,
|
||||
folder: rootURL,
|
||||
background: snapshot.background,
|
||||
icon: snapshot.icon
|
||||
)]
|
||||
|
||||
case let .items(ids):
|
||||
var subjects: [StyleSubject] = []
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
let laneFolder = rootURL.appendingPathComponent(lane.id.rawValue)
|
||||
if ids.contains(lane.id) {
|
||||
subjects.append(StyleSubject(
|
||||
id: lane.id,
|
||||
folder: laneFolder,
|
||||
background: lane.background,
|
||||
icon: lane.icon
|
||||
))
|
||||
}
|
||||
for card in lane.cards where !card.isDeleted && ids.contains(card.id) {
|
||||
subjects.append(StyleSubject(
|
||||
id: card.id,
|
||||
folder: laneFolder.appendingPathComponent(card.id.rawValue),
|
||||
background: card.background,
|
||||
icon: card.icon
|
||||
))
|
||||
}
|
||||
}
|
||||
return subjects
|
||||
}
|
||||
}
|
||||
|
||||
/// Which level `target` sits at — the editor's symbol grid needs it for its leading well, "the
|
||||
/// level's default symbol" (03-board-ui.md § Styling ▸ Controls).
|
||||
///
|
||||
/// A set naming any card is a card set: 04-interactions.md's cards-XOR-lanes rule means a live
|
||||
/// selection never mixes the two, so the branch below is a total answer rather than a policy —
|
||||
/// and if a mixed set ever reached here, `doc.text` is the level whose default would actually be
|
||||
/// removed by the leading well.
|
||||
public func styleLevel(of target: StyleTarget) -> StyleLevel {
|
||||
switch target {
|
||||
case .board:
|
||||
return .board
|
||||
case let .items(ids):
|
||||
let namesACard = snapshot.lanes.contains { lane in
|
||||
!lane.isDeleted && lane.cards.contains { !$0.isDeleted && ids.contains($0.id) }
|
||||
}
|
||||
return namesACard ? .card : .lane
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a style gesture — the **one** commit point every anchor shares (03-board-ui.md §
|
||||
/// Styling ▸ Controls: "One component, one behavior, three anchors"), and the quick-style recents
|
||||
/// row with them.
|
||||
///
|
||||
/// **One bracket, whatever the target set's size.** "Choosing a well applies to the whole
|
||||
/// selection — one gesture, one commit on git boards" (§ Controls), so every target's `index.md`
|
||||
/// is rewritten inside a single `performWrite`: the churn rounds back as one app-mediated reload,
|
||||
/// and the auto-committer (m7) sees one operation rather than N.
|
||||
///
|
||||
/// **No-ops are skipped per dimension and per target** — `setLaneWidth`'s rule, for its reason: a
|
||||
/// well clicked twice, or a batch where half the cards are already that colour, must not stamp
|
||||
/// `modified` or mint a commit on the items that were already right. A dimension whose value is
|
||||
/// already what the gesture asks contributes nothing; a target both of whose dimensions are
|
||||
/// no-ops is dropped entirely; and a gesture that changes nothing anywhere never opens the
|
||||
/// bracket at all.
|
||||
///
|
||||
/// **`iconColor` is not a parameter, and that is the design**: it is "resolved — schema yes,
|
||||
/// control no" (§ Capabilities). The field renders when hand-written and the app offers no
|
||||
/// control for it, so there is nothing here to pass.
|
||||
///
|
||||
/// 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. A batch that fails partway leaves the
|
||||
/// targets written before it written — the Writer is "atomic per filesystem operation, not per
|
||||
/// gesture" — and the reload shows the true state, which is the honest one.
|
||||
public func applyStyle(to target: StyleTarget, background: StyleChange = .keep, icon: StyleChange = .keep) {
|
||||
let edits: [(folder: URL, background: StyleChange, icon: StyleChange)] = styleSubjects(of: target)
|
||||
.compactMap { subject in
|
||||
let background = Self.effective(background, against: subject.background)
|
||||
let icon = Self.effective(icon, against: subject.icon)
|
||||
guard background != .keep || icon != .keep else { return nil }
|
||||
return (folder: subject.folder, background: background, icon: icon)
|
||||
}
|
||||
guard !edits.isEmpty else { return }
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for edit in edits {
|
||||
// `.style(title: nil)`: `updateIndex` enriches it off the document it reads, so a
|
||||
// failure names the item by the title it still has (see `WriteOperation.style`).
|
||||
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
|
||||
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
|
||||
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `change` narrowed against what is already on disk: `.keep` when it would write what is
|
||||
/// already there.
|
||||
///
|
||||
/// The comparison is against the **valid** reading, not the written text: a malformed value —
|
||||
/// `background: [a, b]`, a sequence where a scalar belongs — is never equal to a palette name, so
|
||||
/// choosing a well always replaces it, which is what "choosing any well replaces it" (§ Controls)
|
||||
/// promises about a value the app could not read.
|
||||
nonisolated static func effective(_ change: StyleChange, against field: FieldValue<String>) -> StyleChange {
|
||||
switch change {
|
||||
case .keep: .keep
|
||||
case let .set(value): field.value == value ? .keep : .set(value)
|
||||
case .remove: field.isMissing ? .keep : .remove
|
||||
}
|
||||
}
|
||||
|
||||
private static func apply(_ change: StyleChange, to key: String, in document: inout FrontmatterDocument) {
|
||||
switch change {
|
||||
case .keep: break
|
||||
case let .set(value): document.set(key, to: .string(value))
|
||||
case .remove: document.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Creation
|
||||
|
||||
/// Creates a lane at the board's right end — File ▸ New Lane ⇧⌘N (11-command-nexus.md).
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import Foundation
|
||||
|
||||
/// The styling system's vocabulary — what a style gesture *is*, before any view or any write exists
|
||||
/// (03-board-ui.md § Styling). Three small value types, deliberately kept out of both the view layer
|
||||
/// and the Writer: the editor builds gestures out of them, `BoardStore.applyStyle` turns them into
|
||||
/// files, and every rule that can be stated without a screen or a disk is stated here where a test
|
||||
/// can reach it.
|
||||
|
||||
// MARK: - What a gesture asks of one key
|
||||
|
||||
/// What one style gesture asks of one frontmatter key.
|
||||
///
|
||||
/// **Three cases, because "leave it alone" and "there is no value" are different instructions.** A
|
||||
/// style editor always speaks about both dimensions at once — a click on a background well says
|
||||
/// nothing about the symbol — so `keep` is what the untouched dimension carries, and `remove` is
|
||||
/// what the two leading wells carry: "the None well … removes the `background` key" and the symbol
|
||||
/// grid's "leading well is the level's default symbol and removes the `icon` key" (03 § Styling ▸
|
||||
/// Controls). Writing `background: ""` instead would be a real (if blank) value and the exact
|
||||
/// mistake the remove-at-default family — the empty rename, the width landing on 1 — exists to
|
||||
/// avoid.
|
||||
///
|
||||
/// `set` carries the string that goes to frontmatter verbatim: a kebab-case palette name from the
|
||||
/// grid, or an SF Symbol name. Hex never arrives here from the app ("custom hex is not pickable
|
||||
/// in-app but stays fully honored from disk"), though nothing in the type forbids it.
|
||||
public enum StyleChange: Sendable, Equatable {
|
||||
case keep
|
||||
case set(String)
|
||||
case remove
|
||||
}
|
||||
|
||||
// MARK: - What a gesture is aimed at
|
||||
|
||||
/// What a style edit acts on — "one component, one behavior, three anchors" needs exactly one
|
||||
/// vocabulary word for "who is being styled" (03-board-ui.md § Styling ▸ Controls).
|
||||
///
|
||||
/// **A set of items, or the board — never both, and never a mix of levels in spirit.** The editor is
|
||||
/// "selection-aware: it styles the selected cards or lane, and with nothing selected, the board", and
|
||||
/// 04-interactions.md's cards-XOR-lanes rule means a live selection is homogeneous by kind. Nothing
|
||||
/// here enforces that (a `Set<ItemID>` cannot), because the enforcement belongs to the selection
|
||||
/// model; what this type *does* guarantee is that a board target and an item target are structurally
|
||||
/// different things, which is what makes "it never silently retargets to the board" (§ Controls) a
|
||||
/// property of the code rather than a discipline.
|
||||
public enum StyleTarget: Sendable, Equatable {
|
||||
case board
|
||||
case items(Set<ItemID>)
|
||||
}
|
||||
|
||||
/// Which level a target sits at — the one thing the editor needs beyond the values themselves,
|
||||
/// because the symbol grid's leading well is "the level's default symbol" (03 § Styling ▸ Controls)
|
||||
/// and the three defaults differ (§ Capabilities).
|
||||
public enum StyleLevel: Sendable, Equatable {
|
||||
case board
|
||||
case lane
|
||||
case card
|
||||
}
|
||||
|
||||
// MARK: - The current value across a target set
|
||||
|
||||
/// What one style dimension currently reads across a whole target set — the editor's per-dimension
|
||||
/// display, and the whole of 03-board-ui.md § Styling ▸ Controls' batch rule: "a multi-selection
|
||||
/// shows per-dimension mixed state (no well selected, '—' where a value would read)".
|
||||
///
|
||||
/// Three states rather than an optional plus a flag, because the three mean genuinely different
|
||||
/// things to the grid: `unset` selects the leading well (None / the level default), `uniform`
|
||||
/// selects the matching well — or, when the value is off-palette, labels itself verbatim outside the
|
||||
/// grid — and `mixed` selects nothing at all.
|
||||
///
|
||||
/// **Deliberately a pure function of the fields.** It takes `FieldValue`s rather than a store so the
|
||||
/// interesting rules — what counts as agreement, what a malformed value reads as — are testable
|
||||
/// without a board on disk.
|
||||
public enum StyleFieldState: Sendable, Equatable {
|
||||
/// No target carries the key. The leading well is the current value.
|
||||
case unset
|
||||
/// Every target carries the same value, written exactly this way.
|
||||
case uniform(String)
|
||||
/// The targets disagree. Nothing is selected; the display reads "—".
|
||||
case mixed
|
||||
|
||||
/// The state of one dimension over `fields`, one per target, in any order.
|
||||
///
|
||||
/// An **empty** list is `unset` rather than a fourth case: a target set with no live members is
|
||||
/// about to dismiss the popover anyway (`StyleEditorSession.resolved(against:)`), and a frame
|
||||
/// rendered with nothing selected is the honest thing to show in the meantime.
|
||||
public static func resolve(_ fields: [FieldValue<String>]) -> StyleFieldState {
|
||||
var iterator = fields.makeIterator()
|
||||
guard let first = iterator.next() else { return .unset }
|
||||
let firstText = written(first)
|
||||
while let next = iterator.next() {
|
||||
guard written(next) == firstText else { return .mixed }
|
||||
}
|
||||
guard let firstText else { return .unset }
|
||||
return .uniform(firstText)
|
||||
}
|
||||
|
||||
/// The value **as written on disk**, or `nil` when the key is absent.
|
||||
///
|
||||
/// A malformed field — a sequence where a scalar belongs — reads as its raw text rather than as
|
||||
/// nothing, which is what makes the off-palette display honest: "off-palette values display
|
||||
/// leniently … labeled verbatim, outside the grids" (03 § Styling ▸ Controls) is about *what the
|
||||
/// author wrote*, and a value the parser could not make a string of is exactly the case where
|
||||
/// showing the bytes matters most. Agreement is judged on this same text, so two targets whose
|
||||
/// `background` is malformed in the same way read as uniform — they do carry the same value.
|
||||
public static func written(_ field: FieldValue<String>) -> String? {
|
||||
switch field {
|
||||
case .missing: nil
|
||||
case let .valid(value): value
|
||||
case let .malformed(raw): raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The open Style… popover
|
||||
|
||||
/// The Style… popover's live target — 03-board-ui.md § Styling ▸ Controls' settled lifecycle
|
||||
/// sentence, as a value: "The Style… popover tracks its target set live and dismisses when it
|
||||
/// empties … its target is the selection, re-resolved across reloads by 02-architecture.md's UUID
|
||||
/// rule — a member that vanishes or flips liveness leaves the set and the mixed-state display
|
||||
/// recomputes; a set emptied by a foreign reload dismisses the popover … it never silently retargets
|
||||
/// to the board".
|
||||
///
|
||||
/// **Held in `TransientBoardState`, not in a view**, and that is the whole design of it: the rule is
|
||||
/// a *reload* rule, and the container that re-grounds the selection, the drag, the pending cut and
|
||||
/// the rename editor on every applied snapshot is the one place a reload rule can be written once.
|
||||
/// The sibling it most resembles is `RenameEditor` — an item-referencing editor that a vanish
|
||||
/// discards — which is exactly the analogy the design draws ("the inline-rename discard applied
|
||||
/// here").
|
||||
///
|
||||
/// The two embedded anchors (the card window's Style section, the board popover's styling area) need
|
||||
/// none of this and get none: "the card sidebar dismisses with its card's window, and the board
|
||||
/// popover's target is the board itself".
|
||||
public struct StyleEditorSession: Sendable, Equatable {
|
||||
|
||||
/// Who is being styled. `let`, because a session never changes target — a session whose targets
|
||||
/// all vanished is *gone*, not re-aimed.
|
||||
public let target: StyleTarget
|
||||
|
||||
public init(target: StyleTarget) {
|
||||
self.target = target
|
||||
}
|
||||
|
||||
/// This session re-grounded on a freshly applied snapshot, or `nil` when it has nothing left to
|
||||
/// style — which the presenting anchor reads as "dismiss".
|
||||
///
|
||||
/// - **Items** are constrained through `ItemReferenceSet`, so liveness is **effective —
|
||||
/// ancestor-walked** for free: a card under a lane an agent just tombstoned renders nowhere and
|
||||
/// therefore leaves the set, exactly as it leaves the selection.
|
||||
/// - **The board** never dismisses. It cannot vanish from its own snapshot, and a board target
|
||||
/// has no membership to lose ("a board-targeted editor has no vanish case").
|
||||
///
|
||||
/// Nothing here can turn an item target into a board target: emptiness returns `nil`, and the
|
||||
/// only way a board session exists is for one to have been opened that way.
|
||||
public func resolved(against snapshot: BoardModel) -> StyleEditorSession? {
|
||||
switch target {
|
||||
case .board:
|
||||
return self
|
||||
case let .items(ids):
|
||||
let live = ItemReferenceSet(ids: ids, liveness: .live).resolved(against: snapshot).ids
|
||||
guard !live.isEmpty else { return nil }
|
||||
return live == ids ? self : StyleEditorSession(target: .items(live))
|
||||
}
|
||||
}
|
||||
|
||||
/// The item the popover hangs off, or `nil` for the board window itself.
|
||||
///
|
||||
/// **Derived, never stored.** Every candidate anchor — a card face, a lane header, the strip —
|
||||
/// asks this one question of the current snapshot, so exactly one surface presents the popover
|
||||
/// and the answer follows the target set across reloads: an anchor that vanishes hands the
|
||||
/// popover to the next live target rather than taking it down with it (only an *emptied* set
|
||||
/// dismisses). Display order — lanes left to right, cards top to bottom — is the tie-break, so
|
||||
/// the popover lands on the first thing the user's eye would find.
|
||||
public func presentationAnchor(in snapshot: BoardModel) -> ItemID? {
|
||||
guard case let .items(ids) = target else { return nil }
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
if ids.contains(lane.id) { return lane.id }
|
||||
if let card = lane.cards.first(where: { !$0.isDeleted && ids.contains($0.id) }) { return card.id }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import os
|
||||
|
||||
/// The quick-style row's memory: the backgrounds this user has applied lately, most-recent-first
|
||||
/// (03-board-ui.md § Styling ▸ Controls — "Quick-style row, recents only … Recents are app-wide and
|
||||
/// persist app-side (user preference, never board data)").
|
||||
///
|
||||
/// ### Why it lives here, and not in the registry
|
||||
///
|
||||
/// `BoardRegistry`'s own doc names this type's contents as the example of what does *not* belong in
|
||||
/// it: "App-wide state that is not board-scoped — quick-style recents, the SSH host-key table, the
|
||||
/// last-used card-window size — lives *beside* this file, not in it" (02-architecture.md §
|
||||
/// Per-board app state). No board owns the list; styling one board's cards blue is what makes blue
|
||||
/// offer itself on the next board, which is the whole point of the row.
|
||||
///
|
||||
/// ### Two halves, so the rules are testable without a defaults domain
|
||||
///
|
||||
/// The list *rule* is `updated(_:with:cap:)` — a static pure function — and this object is its
|
||||
/// persistence and its observability. `@Observable` so every open context menu reorders the instant a
|
||||
/// colour is applied from anywhere; `defaults` injectable so a test drives a suite of its own rather
|
||||
/// than the user's.
|
||||
///
|
||||
/// ### What never enters the list
|
||||
///
|
||||
/// **The None well is not a colour.** It removes the `background` key, so there is nothing to
|
||||
/// remember and nothing a future row could offer — the recording call site simply never fires for it
|
||||
/// (see `StyleCommand.apply`). Empty strings are refused here too, belt over braces.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class StyleRecents {
|
||||
|
||||
/// How many the row keeps. Six is "capped ~6" from the milestone's shape and about what a
|
||||
/// compact menu row carries without becoming a second palette — which is the one thing the
|
||||
/// design says this row is not ("the pathfinder's second full-palette tier is gone").
|
||||
public static let cap = 6
|
||||
|
||||
/// The recents, most-recent-first. Palette names or `#RRGGBB[AA]` hex — the same strings that go
|
||||
/// to frontmatter, so a hand-written hex applied from a board's file could join the row without
|
||||
/// any translation (nothing in-app can produce one today; § Controls' curated-in-app rule).
|
||||
public private(set) var backgrounds: [String]
|
||||
|
||||
@ObservationIgnored
|
||||
private let defaults: UserDefaults
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "style-recents")
|
||||
|
||||
/// - Parameter defaults: the domain to persist in. Injected for the reason `BoardRegistry` takes
|
||||
/// its storage URL: a test must be able to hold its own without touching the user's.
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
// Anything but an array of strings is treated as an empty list rather than as an error: this
|
||||
// is a convenience, and a hand-edited or truncated preference must never be the reason a
|
||||
// context menu cannot open (`BoardRegistry`'s "it never takes the app down", one scale down).
|
||||
backgrounds = (defaults.array(forKey: AppPreferences.quickStyleBackgroundsKey) as? [String]) ?? []
|
||||
}
|
||||
|
||||
/// Records `value` as the most recently used background and persists the new list.
|
||||
///
|
||||
/// Move-to-front, deduped, capped — see `updated(_:with:cap:)` for the rule itself. Called on
|
||||
/// **every** background application from every anchor (the editor's wells, the quick-style row),
|
||||
/// which is what "recently used" has to mean for the row to be worth having; the write is
|
||||
/// unconditional even when the value was already at the front, since re-persisting an identical
|
||||
/// list is cheaper than deciding not to.
|
||||
public func record(_ value: String) {
|
||||
backgrounds = Self.updated(backgrounds, with: value)
|
||||
defaults.set(backgrounds, forKey: AppPreferences.quickStyleBackgroundsKey)
|
||||
}
|
||||
|
||||
/// The list rule, as a pure function: `value` to the front, its earlier occurrence removed, the
|
||||
/// tail truncated to `cap`.
|
||||
///
|
||||
/// An empty `value` returns the list unchanged. It is not a colour anyone applied — the two ways
|
||||
/// to arrive at one would be a removal (which is the None well, and never recorded) or a
|
||||
/// hand-written blank — and a blank swatch in the row would be an unclickable hole.
|
||||
public static func updated(_ list: [String], with value: String, cap: Int = cap) -> [String] {
|
||||
guard !value.isEmpty else { return list }
|
||||
var updated = list.filter { $0 != value }
|
||||
updated.insert(value, at: 0)
|
||||
return Array(updated.prefix(cap))
|
||||
}
|
||||
}
|
||||
@@ -323,6 +323,18 @@ public final class TransientBoardState {
|
||||
/// out from under a commit that was about to read its draft.
|
||||
public private(set) var renameEditor: RenameEditor?
|
||||
|
||||
/// The open Style… popover's target, or `nil` when no popover is open (03-board-ui.md § Styling
|
||||
/// ▸ Controls). See `StyleEditorSession` for the lifecycle it implements and why it lives here
|
||||
/// rather than in a view.
|
||||
///
|
||||
/// **Not an inline editor**, deliberately: it is not a title field, it does not hold the text
|
||||
/// domain's keyboard, and `isEditingInline` must stay the answer to "may board commands run" —
|
||||
/// Style… itself is *disabled* while an inline editor is open, so the two never coexist anyway.
|
||||
///
|
||||
/// `private(set)` for `renameEditor`'s reason: its two transitions are the methods below, and a
|
||||
/// session assignable from anywhere could be re-aimed behind the reload rule's back.
|
||||
public private(set) var styleEditor: StyleEditorSession?
|
||||
|
||||
/// Whether a title editor holds focus — 04-interactions.md's **focused-editor rule** as one
|
||||
/// boolean: "while an inline title editor — rename or the new-card placeholder — is focused,
|
||||
/// board-scoped menu commands (Delete, New Card, Paste, Move, Style, …) disable via menu
|
||||
@@ -466,6 +478,26 @@ public final class TransientBoardState {
|
||||
renameEditor = nil
|
||||
}
|
||||
|
||||
// MARK: - The style editor's lifecycle
|
||||
|
||||
/// Opens the Style… popover on `target` — the menu item's call and both context menus'
|
||||
/// (03-board-ui.md § Styling ▸ Controls).
|
||||
///
|
||||
/// Replacing any session already open, for the inline editors' reason turned inside out: there is
|
||||
/// one popover, so a second Style… is the first one being re-aimed by a fresh gesture. Unlike
|
||||
/// `beginRename`/`beginPlaceholder` it does **not** clear the inline editors — it cannot coexist
|
||||
/// with one (Style… disables while an editor is focused), so clearing them here would be a rule
|
||||
/// about a state that menu validation already rules out.
|
||||
public func beginStyleEditor(for target: StyleTarget) {
|
||||
styleEditor = StyleEditorSession(target: target)
|
||||
}
|
||||
|
||||
/// Closes the popover — the user dismissing it, and the anchor's response to a session the
|
||||
/// reload rule emptied.
|
||||
public func discardStyleEditor() {
|
||||
styleEditor = nil
|
||||
}
|
||||
|
||||
// MARK: - Reload
|
||||
|
||||
/// The one reload hook: re-grounds every piece of this container on a freshly applied snapshot.
|
||||
@@ -508,6 +540,13 @@ public final class TransientBoardState {
|
||||
/// current universe does not have. It is not an `ItemReferenceSet` only because it is one
|
||||
/// optional rather than a set on a side — the rule it obeys is the same one.
|
||||
///
|
||||
/// **The style editor tracks its target set live** (03-board-ui.md § Styling ▸ Controls,
|
||||
/// settled): a member that vanishes or flips liveness leaves the set — so the editor's
|
||||
/// mixed-state display recomputes off the survivors — and a set emptied by a foreign reload
|
||||
/// clears the session, which is how "the popover dismisses when it empties" reaches the screen.
|
||||
/// It never becomes a board session on the way; `StyleEditorSession.resolved(against:)` owns
|
||||
/// both halves.
|
||||
///
|
||||
/// `searchQuery` and `isTrashVisible` are deliberately not mentioned below. Neither references
|
||||
/// an item, so no snapshot can invalidate either — the query's *results* change with every
|
||||
/// snapshot, which is precisely why the results are not stored here.
|
||||
@@ -516,6 +555,7 @@ public final class TransientBoardState {
|
||||
dragMembers = dragMembers.resolved(against: snapshot)
|
||||
pendingCut = pendingCut.resolved(against: snapshot)
|
||||
newCardPlaceholder = resolvedPlaceholder(against: snapshot)
|
||||
styleEditor = styleEditor?.resolved(against: snapshot)
|
||||
|
||||
// One universe computed once and asked three questions — the rename target's liveness, the
|
||||
// last-active lane's, and (via the placeholder above, which asks its own way) the anchor's.
|
||||
|
||||
Reference in New Issue
Block a user