The card menu learns to tag — a labels submenu of twelve checkmarks, and Copy Link moves in beside its mirror
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
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Presentation
|
||||
|
||||
/// Whether *this* card face is the one showing the open **Labels ▸ More…** dialog.
|
||||
///
|
||||
/// `styleEditorPresentation`'s shape and its reasoning, one session over and simpler for it: a label
|
||||
/// session names exactly one card, so the anchor test is an identity comparison rather than a walk for
|
||||
/// the first live member of a set. The setter is narrowed to this anchor's own dismissal for that
|
||||
/// function's reason exactly — a session that has moved on must not be discarded by a surface it is no
|
||||
/// longer about.
|
||||
@MainActor
|
||||
func labelEditorPresentation(_ store: BoardStore, anchor: ItemID) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: { store.transient.labelEditor?.cardID == anchor },
|
||||
set: { presented in
|
||||
guard !presented, store.transient.labelEditor?.cardID == anchor else { return }
|
||||
store.transient.discardLabelEditor()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - The dialog
|
||||
|
||||
/// **Labels ▸ More…** — every label the board uses, each with a toggle, plus a field that creates a
|
||||
/// new one (the owner's card-menu spec: "More… (show a dialog with a list of all used labels +
|
||||
/// ability to create new)").
|
||||
///
|
||||
/// ### Why it exists beside the twelve
|
||||
///
|
||||
/// The submenu's rows are a *shortcut* — the labels this user is most likely to want, ranked
|
||||
/// (`LabelRanking`). This is the **inventory**: every label in the board's universe, however rarely
|
||||
/// used, in an order built for looking one up rather than for reaching the common ones fast
|
||||
/// (`LabelIndex.alphabetical`). It is also the only surface on the board side that can mint a label
|
||||
/// the board has never used — the twelve can only ever offer what already exists.
|
||||
///
|
||||
/// ### A popover, not a sheet
|
||||
///
|
||||
/// "Dialog" is the owner's word for the shape, not necessarily for the presentation. A sheet would
|
||||
/// block the board window for a gesture whose whole character is quick tagging, and it would have
|
||||
/// nothing to anchor to — the user right-clicked a specific card and the answer belongs beside it.
|
||||
/// Style… is the precedent in this exact position: same anchor, same session-backed lifecycle, same
|
||||
/// dismissal on click-away. Flagged for owner review if a real modal was meant.
|
||||
///
|
||||
/// ### One card
|
||||
///
|
||||
/// Every row is a checkmark, and a checkmark states a fact about one card — see
|
||||
/// `CardFaceView.labelsMenu` for the argument and why it is also what keeps the menu render-safe.
|
||||
struct LabelPickerPopover: View {
|
||||
|
||||
let store: BoardStore
|
||||
let recents: LabelRecents
|
||||
|
||||
/// What is typed in the create field.
|
||||
@State private var draft = ""
|
||||
|
||||
@FocusState private var fieldFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
// Empty for the frame between a session ending and the popover's own dismissal landing —
|
||||
// `StyleEditorPopover`'s own formality, and for its reason.
|
||||
if let session = store.transient.labelEditor {
|
||||
content(for: session.cardID)
|
||||
}
|
||||
}
|
||||
|
||||
private func content(for cardID: ItemID) -> some View {
|
||||
let current = store.labels(ofCard: cardID)
|
||||
let universe = store.labelIndex.alphabetical
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Labels")
|
||||
.font(.caption.weight(.semibold))
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if universe.isEmpty {
|
||||
Text("This board has no labels yet.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
// A scroll view rather than a bare stack: the universe has no bound, and a popover
|
||||
// that grows past the screen is a popover that cannot be dismissed by its own bottom
|
||||
// edge. The height is a cap, not a size — a board with three labels draws three rows.
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(universe, id: \.self) { name in
|
||||
Toggle(name, isOn: binding(for: name, onCard: cardID, current: current))
|
||||
.toggleStyle(.checkbox)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 220)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 6) {
|
||||
TextField("New label", text: $draft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($fieldFocused)
|
||||
.onSubmit { create(onCard: cardID) }
|
||||
.accessibilityLabel("New Label")
|
||||
Button("Add") { create(onCard: cardID) }
|
||||
.disabled(CardLabels.normalized(draft) == nil)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(width: 240)
|
||||
.onAppear { fieldFocused = true }
|
||||
}
|
||||
|
||||
/// One row's checkmark: reads the card's current list, writes through the one funnel.
|
||||
///
|
||||
/// `current` is passed in rather than re-read per row — one snapshot lookup for the whole dialog,
|
||||
/// so twenty rows cost one walk instead of twenty.
|
||||
private func binding(for name: String, onCard cardID: ItemID, current: [String]) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: { CardLabels.contains(name, in: current) },
|
||||
set: { _ in LabelCommand.toggle(name, onCard: cardID, in: store, recents: recents) }
|
||||
)
|
||||
}
|
||||
|
||||
/// The create field's landing. It **applies** rather than toggles — the user typed a name into a
|
||||
/// box labelled "New label", which is a request to put it on the card, never to take it off
|
||||
/// (`LabelCommand.add`, which also resolves the board's own spelling for a name that merely looks
|
||||
/// new).
|
||||
///
|
||||
/// The field clears and keeps focus: adding two labels in a row is the common case, and the
|
||||
/// popover's own dismissal is click-away or Escape.
|
||||
private func create(onCard cardID: ItemID) {
|
||||
guard store.acceptsBoardMutations, CardLabels.normalized(draft) != nil else { return }
|
||||
LabelCommand.add(draft, onCard: cardID, in: store, recents: recents)
|
||||
draft = ""
|
||||
fieldFocused = true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user