Right-clicking a card now offers Labels: up to twelve rows ranked by how many cards on the board carry each one, tie-broken by what this user reached for last, each a checkmark that says whether *this* card already has it. More… opens the whole inventory beside the card, in lookup order, with a field that mints a name the board has never used. The rows deliberately do not widen to the selection the way Copy, Cut and Send to Trash do. A checkmark is a claim about one card, and three cards where two carry `bug` have no honest checked state — the rows that widen are the ones that say what they will do rather than what is already true. It is also what keeps the menu cheap: a context menu's contents are rebuilt on every ordinary pass of every card face, so reading the selection there would resubscribe the whole board. The board-sized half of the ranking is cached on the store and gated on equality; what runs per face is bounded by how many distinct labels exist, not by how many cards do. Copy Link leaves the first group for a new Copy Special submenu, mirroring Paste Special — the placement the owner's latest layout asks for, and the one the row's own note has been waiting on since it shipped as a same-day deviation. It keeps its VoiceOver action even so: it is an action that changed doors, not a door. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
243 lines
12 KiB
Swift
243 lines
12 KiB
Swift
import Foundation
|
|
|
|
// MARK: - One label's standing on a board
|
|
|
|
/// One label the board actually uses, and how many cards use it.
|
|
public struct LabelTally: Sendable, Equatable {
|
|
|
|
/// The label as it is **spelled on the board** — the first spelling the derivation met, in board
|
|
/// order (lanes left to right, each lane's cards top to bottom, then the trash).
|
|
///
|
|
/// Case-insensitive uniqueness is a per-*card* rule (`CardLabels`), so two cards may perfectly
|
|
/// legally spell one label two ways. The universe has to pick one to offer, and the first is the
|
|
/// only choice that does not depend on how many cards happen to carry each variant — a count-based
|
|
/// pick would make a menu row's capitalisation flicker as cards are tagged.
|
|
public let name: String
|
|
|
|
/// How many **cards** carry it — never how many times it appears, which is the same number: a
|
|
/// card's own list is deduplicated by the reading.
|
|
public let count: Int
|
|
|
|
public init(name: String, count: Int) {
|
|
self.name = name
|
|
self.count = count
|
|
}
|
|
}
|
|
|
|
// MARK: - The universe
|
|
|
|
/// **Every label the board uses, with its frequency** — the "used labels" universe both label
|
|
/// surfaces draw from (the sidebar's autocomplete and the context menu's twelve rows plus its More…
|
|
/// dialog).
|
|
///
|
|
/// ### Derived from the snapshot, and there is no other store
|
|
///
|
|
/// The owner's ruling, and the format's own posture: the files *are* the board, so the set of labels
|
|
/// in use is a fact about the cards rather than a list the app maintains beside them. A label exists
|
|
/// because a card carries it and stops existing when the last card drops it — there is no rename, no
|
|
/// palette, no orphan to garbage-collect, and an agent that hand-writes `labels: [spike]` into an
|
|
/// `index.md` has created a label as fully as the menu can.
|
|
///
|
|
/// **The trash counts.** `BoardModel.trash` is walked beside `lanes` because a trashed card is "an
|
|
/// ordinary card in a special place" (03-board-ui.md § Trash) and, more to the point, because the
|
|
/// alternative is worse: deleting the only card carrying `spike` would silently evict the label from
|
|
/// the autocomplete, and restoring the card would bring it back — a vocabulary that flickers with the
|
|
/// trash is not a vocabulary. `trashedLanes` is deliberately *not* walked: a trashed lane is an opaque
|
|
/// unit whose cards the snapshot never loads (`TrashedLane`), so there is nothing there to count.
|
|
///
|
|
/// ### Cost
|
|
///
|
|
/// One pass over every live and trashed card, reading a field the loader already parsed — the same
|
|
/// cost class as any other whole-board derivation, and paid **once per snapshot** (`BoardStore`
|
|
/// caches it, equality-gated) rather than once per view body. That is what keeps the context menu's
|
|
/// builder free of O(board) work: everything a card face reads is this value, already built.
|
|
public struct LabelIndex: Sendable, Equatable {
|
|
|
|
/// Every used label, **ordered by frequency descending, then case-insensitively alphabetically**
|
|
/// — a total, snapshot-only order, so two runs over the same board produce the identical array.
|
|
///
|
|
/// The alphabetical tie-break is here rather than left to the ranking because this ordering has to
|
|
/// be deterministic on its own: it is what `universe` shows and what the ranking starts from, and
|
|
/// a derivation whose ties fell out of dictionary iteration order would make the More… dialog
|
|
/// reshuffle itself between reloads that changed nothing.
|
|
public let tallies: [LabelTally]
|
|
|
|
public init(tallies: [LabelTally]) {
|
|
self.tallies = tallies
|
|
}
|
|
|
|
/// The empty board's index — no cards, or no card carrying a label.
|
|
public static let empty = LabelIndex(tallies: [])
|
|
|
|
/// Every used label's display name, in `tallies`' order.
|
|
public var names: [String] { tallies.map(\.name) }
|
|
|
|
public var isEmpty: Bool { tallies.isEmpty }
|
|
|
|
/// The universe as the More… dialog lists it: **alphabetically, case-insensitively**, because a
|
|
/// dialog whose whole job is "find the one you mean among all of them" is a lookup, and a
|
|
/// frequency order is a lookup you cannot do.
|
|
public var alphabetical: [String] {
|
|
tallies.map(\.name).sorted { Self.precedesAlphabetically($0, $1) }
|
|
}
|
|
|
|
/// Whether the board already uses a case-insensitively equal name — the create field's "this is
|
|
/// not new" test.
|
|
public func contains(_ name: String) -> Bool {
|
|
CardLabels.contains(name, in: names)
|
|
}
|
|
|
|
/// The board's own spelling of a name, when it has one — so a user who types `BUG` into the create
|
|
/// field adds the `bug` the rest of the board already says, rather than minting a second variant.
|
|
public func canonicalSpelling(of name: String) -> String? {
|
|
guard let name = CardLabels.normalized(name) else { return nil }
|
|
let key = CardLabels.canonical(name)
|
|
return tallies.first { CardLabels.canonical($0.name) == key }?.name
|
|
}
|
|
|
|
// MARK: Derivation
|
|
|
|
/// The universe of `snapshot`, live cards and trash alike.
|
|
public static func derive(from snapshot: BoardModel) -> LabelIndex {
|
|
var order: [String] = []
|
|
var counts: [String: Int] = [:]
|
|
var spellings: [String: String] = [:]
|
|
|
|
func absorb(_ card: Card) {
|
|
for name in card.labels.value ?? [] {
|
|
let key = CardLabels.canonical(name)
|
|
if spellings[key] == nil {
|
|
spellings[key] = name
|
|
order.append(key)
|
|
}
|
|
counts[key, default: 0] += 1
|
|
}
|
|
}
|
|
|
|
for lane in snapshot.lanes {
|
|
for card in lane.cards { absorb(card) }
|
|
}
|
|
for card in snapshot.trash { absorb(card) }
|
|
|
|
let tallies = order.map { key in
|
|
LabelTally(name: spellings[key] ?? key, count: counts[key] ?? 0)
|
|
}
|
|
return LabelIndex(tallies: tallies.sorted(by: precedes))
|
|
}
|
|
|
|
/// Frequency descending, then case-insensitively alphabetical, then by raw spelling — a **total**
|
|
/// order, which is what makes the derivation reproducible.
|
|
private static func precedes(_ lhs: LabelTally, _ rhs: LabelTally) -> Bool {
|
|
if lhs.count != rhs.count { return lhs.count > rhs.count }
|
|
return precedesAlphabetically(lhs.name, rhs.name)
|
|
}
|
|
|
|
/// Case-insensitive alphabetical, with the raw spelling as the last resort so `Bug` and `bug`
|
|
/// never compare equal and the sort stays total.
|
|
static func precedesAlphabetically(_ lhs: String, _ rhs: String) -> Bool {
|
|
let folded = CardLabels.canonical(lhs).compare(CardLabels.canonical(rhs))
|
|
if folded != .orderedSame { return folded == .orderedAscending }
|
|
return lhs < rhs
|
|
}
|
|
}
|
|
|
|
// MARK: - The menu's twelve
|
|
|
|
/// **"The most frequently and recently used labels"** — the owner's phrase for the card context
|
|
/// menu's twelve rows, turned into arithmetic.
|
|
///
|
|
/// ### The ranking, exactly
|
|
///
|
|
/// 1. **Frequency, descending** — how many of the board's cards (live and trashed) carry the label
|
|
/// (`LabelIndex`).
|
|
/// 2. **Recency, as the tie-break** — among labels used by the same number of cards, the one this
|
|
/// user applied most recently comes first (`LabelRecents`, an app-side MRU updated on every apply).
|
|
/// A label the MRU has never seen sorts behind every label it has.
|
|
/// 3. **Case-insensitively alphabetical**, then raw spelling — so the order is *total* and a menu
|
|
/// never reshuffles between two openings that changed nothing.
|
|
///
|
|
/// Then the first twelve.
|
|
///
|
|
/// ### Why frequency leads and recency follows, and what that costs
|
|
///
|
|
/// This is the ruling as given, implemented as given. It is worth being explicit about the
|
|
/// consequence, because it is visible: on a board with more than twelve labels in heavy use, a label
|
|
/// **invented today** will not appear in these rows until enough cards carry it to beat the
|
|
/// twelfth-most-common one — even though it is by far the most *recently* used. The recency half only
|
|
/// separates labels that are already tied on frequency, which on a board with an even spread of usage
|
|
/// is often most of them, and on a lopsided board is almost none.
|
|
///
|
|
/// The alternative — a blended score, or reserving a slot or two for pure recency — would surface a
|
|
/// fresh label immediately at the cost of a menu whose contents move under the user's hand. Neither
|
|
/// is obviously right, so the literal reading of the ruling is what shipped, and the observation is
|
|
/// journaled for the owner rather than quietly designed around. **More…** is the escape hatch either
|
|
/// way: every used label is one row further in, and the create field is beside it.
|
|
public enum LabelRanking {
|
|
|
|
/// How many rows the context menu's `labels` submenu shows before its separator — the owner's
|
|
/// "up to 12".
|
|
public static let menuLimit = 12
|
|
|
|
/// The top `limit` of `index`, ranked as the type comment describes.
|
|
///
|
|
/// - Parameter recents: most-recent-first, `LabelRecents.labels`' own order. Entries naming labels
|
|
/// the board no longer uses are simply never matched — the MRU is app-wide and a board is not
|
|
/// obliged to know about labels from another one.
|
|
public static func ranked(_ index: LabelIndex, recents: [String], limit: Int = menuLimit) -> [String] {
|
|
guard limit > 0 else { return [] }
|
|
// Position in the MRU, by canonical name — built once rather than searched per comparison, so
|
|
// the sort stays O(n log n) instead of O(n² ) in the number of distinct labels.
|
|
var recency: [String: Int] = [:]
|
|
for (position, name) in recents.enumerated() {
|
|
let key = CardLabels.canonical(name)
|
|
if recency[key] == nil { recency[key] = position }
|
|
}
|
|
|
|
let ordered = index.tallies.sorted { lhs, rhs in
|
|
if lhs.count != rhs.count { return lhs.count > rhs.count }
|
|
let left = recency[CardLabels.canonical(lhs.name)] ?? Int.max
|
|
let right = recency[CardLabels.canonical(rhs.name)] ?? Int.max
|
|
if left != right { return left < right }
|
|
return LabelIndex.precedesAlphabetically(lhs.name, rhs.name)
|
|
}
|
|
return ordered.prefix(limit).map(\.name)
|
|
}
|
|
}
|
|
|
|
// MARK: - The More… dialog's session
|
|
|
|
/// **The open More… dialog's card** — the `labels` submenu's escape hatch, which lists every used
|
|
/// label with a toggle plus a field to create a new one (the owner's card menu spec).
|
|
///
|
|
/// `StyleEditorSession`'s shape and its whole lifecycle argument, narrowed to one card: it lives in
|
|
/// `TransientBoardState` rather than in a view because a card face is inside a lazy stack that may be
|
|
/// recycled out from under an open surface, and because the reload rule has to be able to take the
|
|
/// dialog down when its subject leaves the board.
|
|
///
|
|
/// **One card, not a target set**, which is the whole difference from the style session and the same
|
|
/// decision the submenu itself makes: every row in this dialog is a *checkmark*, and a checkmark has
|
|
/// to state a fact about one card. See `CardFaceView.labelsMenu` for the argument in full.
|
|
public struct LabelEditorSession: Sendable, Equatable {
|
|
|
|
/// Whose labels are being edited. `let`, for `StyleEditorSession.target`'s reason: a session whose
|
|
/// card has vanished is *gone*, never re-aimed at another one.
|
|
public let cardID: ItemID
|
|
|
|
public init(cardID: ItemID) {
|
|
self.cardID = cardID
|
|
}
|
|
|
|
/// This session re-grounded on a freshly applied snapshot, or `nil` when its card is no longer a
|
|
/// live board card — which the presenting anchor reads as "dismiss".
|
|
///
|
|
/// Liveness is **effective, ancestor-walked**, for free and for `StyleEditorSession`'s reason: the
|
|
/// resolution runs through `ItemReferenceSet`, so a card under a lane somebody just trashed leaves
|
|
/// the set exactly as it leaves the selection. A card moved into the trash dismisses too, which is
|
|
/// right — `BoardStore.setLabels` refuses a trashed card, so a dialog left open over one would be
|
|
/// a surface offering writes it cannot make.
|
|
public func resolved(against snapshot: BoardModel) -> LabelEditorSession? {
|
|
let live = ItemReferenceSet(ids: [cardID], container: .board).resolved(against: snapshot).ids
|
|
return live.contains(cardID) ? self : nil
|
|
}
|
|
}
|