The card window's title learns Edit mode — a sibling session rides the body's own doors
CardWindowView's header is now the title, live: a static Text in Preview, an editable single-line TextField in Edit, both reading a new CardTitleEditSession's buffer rather than the card's own snapshot value — the same "buffer outranks the snapshot" reason the body surface already reads bodySession.text instead of card.body. CardTitleEditSession is CardBodyEditSession's shape one field over: the one-isDirty write gate, dirty-buffer-wins on adopt(diskTitle:), the ~700ms injectable debounce, flush() / flushOrThrow() for DirtyBufferGuard, and beginEditSession()/endEditSession() with a session-coalesced undo step (one per session, never per debounced tick). It rides the body's own begin/end/flush doors rather than opening a second session boundary — title is only ever editable while the body column is in Edit mode — because the two write through different WriteOperations with different validation and merging them would conflate two unrelated frontmatter keys behind one buffer. BoardStore.commitCardTitle(inCard:title:) reuses the same private setTitle helper and the same .rename WriteOperation the board's own inline rename commits through, so trimming, empty-removes-the-key, unchanged-writes-nothing and banner enrichment are one code path, not a re-implementation. It resolves through cardBodyTarget (spans lanes and the trash), not boardItem (board only), because a card window's title field stays live through the same dismissal-into-trash flush the body already gets — the one deliberate divergence from the board's own rename, which treats a trashed target as vanished. registerTitleEdit(inCard:priorTitle:newTitle🔛) mirrors registerBodyEdit, anchored by card identity and the already-reserved ExpectedField.title, folding into the same one-coarse-step-per-window-close undo model. Every place the body's Edit buffer flushes, the title's now does too: mode exit (bodyPresentation.flushEdits/beginEdits), window close and the dismissal path (CardWindowSession.endSession()), raw-source entry (configureRawSource), the close-time DirtyBufferGuard modal (attemptSave tries body then title), and the fast-path close gate (closeAfterFlushing() now checks title.isDirty alongside body.isDirty). holdsUnsavedContent and settlement carry the title too. Tests: CardTitleEditSessionTests.swift mirrors CardBodyEditSessionTests.swift (write gates, normalization and newline-stripping, dirty-buffer-wins, debounce, undo-step coalescing). CardTitleWriteTests.swift covers commitCardTitle/registerTitleEdit: byte-identity no-op, empty-removes-key, vanished, the trashed-card-is-still-writable divergence, a readable-but-uneditable target refusing and bannering, and a read-only board suspending quietly. CardSessionUndoTests.swift gains coverage that a title edit folds into the coarse close step alongside a body edit and registers on the window's own stack, never the board's. RawSourceTests.swift's hand-wired rig picks up the title session configureRawSource now also flushes. 3148 KanbanTests pass, 0 failures. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -32,16 +32,18 @@ import UniformTypeIdentifiers
|
||||
///
|
||||
/// ### What this milestone builds, and what it deliberately does not
|
||||
///
|
||||
/// The *shell*: the two columns, their scrolling, the sidebar's fixed width, and the read-only
|
||||
/// The *shell*: the two columns, their scrolling, the sidebar's fixed width, and the header's
|
||||
/// renderings of what the loader already knows — the card's title and its created/modified line —
|
||||
/// over the body column's two live surfaces (Preview and Edit, `CardBodySurface`), with the
|
||||
/// raw-source outlet swapping the pair of columns out entirely when it is active. Everything that
|
||||
/// reads or writes beyond that is later work and is marked where it lands:
|
||||
/// raw-source outlet swapping the pair of columns out entirely when it is active.
|
||||
///
|
||||
/// - the title as an editable field (commit on Return / focus loss, Escape abandons).
|
||||
/// **The title is an editable field in Edit mode, a static rendering in Preview** (m6-card-body,
|
||||
/// widened): it rides the body column's own mode rather than opening a second editor of its own, so
|
||||
/// "Edit mode" means both surfaces at once, and its own session (`titleSession`, `CardTitleEditSession`)
|
||||
/// begins and ends with the body's — see that type's doc comment for the whole of what that buys.
|
||||
///
|
||||
/// The placeholders are structural rather than apologetic: the sidebar's inventory and its order are
|
||||
/// settled (05 ▸ The attributes sidebar), so the shell states them and the sections fill in
|
||||
/// The placeholders that remain are structural rather than apologetic: the sidebar's inventory and its
|
||||
/// order are settled (05 ▸ The attributes sidebar), so the shell states them and the sections fill in
|
||||
/// underneath without the composition moving.
|
||||
///
|
||||
/// ### The width rule, in one line
|
||||
@@ -91,6 +93,11 @@ struct CardWindowView: View {
|
||||
/// it, Preview renders it, and `adopt(diskBody:)` below is where the snapshot gets a say —
|
||||
/// which is exactly the point at which dirty-buffer-wins is decided.
|
||||
let bodySession: CardBodyEditSession
|
||||
/// This window's title field — `bodySession`'s sibling, over the header's single line rather than
|
||||
/// the body span (m6-card-body, `CardTitleEditSession`). Read in both modes for `bodySession`'s
|
||||
/// own reason: a flushed buffer is ahead of the snapshot by a reload, so it is the truer of the
|
||||
/// two whether the header is showing static text or the editable field.
|
||||
let titleSession: CardTitleEditSession
|
||||
/// This window's raw-source outlet. While it is active the two columns are gone entirely — see
|
||||
/// `body`.
|
||||
let rawSource: CardRawSourceSession
|
||||
@@ -325,15 +332,7 @@ struct CardWindowView: View {
|
||||
private var bodyColumn: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: bodyPointSize * 0.5) {
|
||||
// m6-card-body: the title *field* — large and borderless, committing to frontmatter
|
||||
// on Return or focus loss, clearing to remove the `title` key, Escape abandoning to
|
||||
// the on-disk title. Read-only here; the placeholder rendering is already final.
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.largeTitle)
|
||||
// "Untitled" is a rendering, never a value (03-board-ui.md § Card face) — the
|
||||
// same secondary treatment the face gives it.
|
||||
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
||||
.textSelection(.enabled)
|
||||
titleRow
|
||||
|
||||
if let dateLine {
|
||||
Text(dateLine)
|
||||
@@ -368,12 +367,62 @@ struct CardWindowView: View {
|
||||
.onChange(of: card.body, initial: true) { _, body in
|
||||
bodySession.adopt(diskBody: body)
|
||||
}
|
||||
// The title field's own dirty-buffer-wins, `bodySession`'s exact rule one property over — see
|
||||
// `titleSession`'s doc comment.
|
||||
.onChange(of: card.title.value, initial: true) { _, title in
|
||||
titleSession.adopt(diskTitle: title)
|
||||
}
|
||||
// **The opening rule, applied once** (05 ▸ Mode grammar): a card opens in Preview unless its
|
||||
// body is empty, in which case it opens straight into Edit. `openIfNeeded` is what makes it
|
||||
// "once" — a later reload that empties the file must not drag a reader into Edit.
|
||||
.task { bodyPresentation.openIfNeeded(body: card.body) }
|
||||
}
|
||||
|
||||
/// The header's title — large and borderless in both readings (m6-card-body). **Edit mode makes it
|
||||
/// an editable field; Preview keeps it static text** — the same surfaces `CardBodySurface` gives
|
||||
/// the body one column down, over the smaller session one property to the side.
|
||||
///
|
||||
/// Both branches read `titleSession.text`, never `card.title.value` directly, for the reason
|
||||
/// `titleSession`'s doc comment gives: the buffer outranks the snapshot while it is dirty, and
|
||||
/// outruns it by exactly one reload the instant a save lands, so reading the snapshot in Preview
|
||||
/// would show a stale title for that reload's length — precisely what a card window must never do
|
||||
/// (05-card-window.md ▸ Window).
|
||||
@ViewBuilder
|
||||
private var titleRow: some View {
|
||||
if bodyPresentation.mode == .edit {
|
||||
TextField(
|
||||
"Card title",
|
||||
text: Binding(
|
||||
get: { titleSession.text },
|
||||
set: { titleSession.edited($0) }
|
||||
)
|
||||
)
|
||||
.textFieldStyle(.plain)
|
||||
.lineLimit(1)
|
||||
.font(.largeTitle)
|
||||
// Return commits the field rather than exiting Edit mode — the title's own session ends
|
||||
// with the body's (mode exit, window close), not with this key. `.onSubmit` never sees a
|
||||
// literal newline in the first place: a plain `TextField` treats Return as a submit, which
|
||||
// is the other half of "no newlines" beside `CardTitleEditSession.edited(_:)`'s paste
|
||||
// filter.
|
||||
.onSubmit { titleSession.flush() }
|
||||
} else {
|
||||
// Trimmed for the read-only rendering only — the field above shows `titleSession.text`
|
||||
// raw, so a mid-typed run of padding never has its cursor position disturbed
|
||||
// (`CardTitleEditSession.saveNow()` never rewrites the live buffer either, for the same
|
||||
// reason). Preview only ever renders while nothing is being typed, so trimming purely for
|
||||
// display is free of that hazard and is what keeps a title committed down to nothing
|
||||
// (spaces alone) reading as "Untitled" here rather than as a run of blank space.
|
||||
let displayed = titleSession.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
Text(displayed.isEmpty ? "Untitled" : displayed)
|
||||
.font(.largeTitle)
|
||||
// "Untitled" is a rendering, never a value (03-board-ui.md § Card face) — the same
|
||||
// secondary treatment the face gives it.
|
||||
.foregroundStyle(displayed.isEmpty ? .secondary : .primary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
/// "Created ⟨date⟩ · Modified ⟨date⟩ · by ⟨modified-by⟩", **omitting whichever keys are absent**
|
||||
/// (05 ▸ Composition) — the whole line disappears when the card carries none of the three.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user