Files

336 lines
13 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import SwiftUI
import TermindApplication
import TermindPresentation
/// A token field: the tags already chosen, shown as removable chips, over a
/// search field that narrows the tags in use elsewhere and offers to create the
/// typed one.
///
/// It replaces a comma-separated `TextField`, which asked the user to remember
/// how a tag was spelled the last time. A typo there is silent and permanent —
/// `postgres` and `Postgres` are two tags forever, and neither finds the other
/// in search. Offering what exists is the fix; `Create "…"` stays as an explicit
/// row rather than a side effect of pressing Return on something unrecognised,
/// so making a new tag is always a thing the user chose to do.
///
/// Lives in `Design/` because snippets carry tags too, and their editor
/// reuses this desktop token field unchanged.
struct TagField: View {
@Binding var tags: [String]
/// Every tag worth offering — `HostLibraryModel.allTags` at every call site so far.
/// Passed in rather than read from the environment so the control stays a
/// plain value: the snippet editor will hand it the same list.
let suggestions: [String]
@State private var query = ""
@State private var highlighted: Int?
/// Whether the dropdown is open, tracked separately from focus.
///
/// Focus alone is not enough to gate it: the field keeps focus after a tag
/// is committed, so a list gated on focus is a list with no way to put it
/// away. This is what Escape and the chevron write to.
@State private var showsList = false
@FocusState private var isFocused: Bool
/// The dropdown's ceiling. Six rows and a bit, so a long list reads as
/// scrollable rather than running the form row off the window.
private let listMaxHeight: CGFloat = 160
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Space.s) {
if !tags.isEmpty {
chips
}
VStack(alignment: .leading, spacing: 0) {
field
if isListOpen {
Divider()
list
}
}
.background(.quaternary.opacity(0.5), in: .rect(cornerRadius: Metrics.Radius.medium))
}
.animation(Motion.panel, value: rows)
.animation(Motion.panel, value: isListOpen)
.animation(Motion.panel, value: tags)
// Moving to another field puts the list away, so it never hangs over a
// form the user has moved on from.
.onChange(of: isFocused) { _, focused in
if focused { showsList = true } else { showsList = false }
}
}
private var isListOpen: Bool { showsList && !rows.isEmpty }
// MARK: - Pieces
private var chips: some View {
TagFlowLayout(spacing: Metrics.Space.xs, widthSizing: .proposed) {
ForEach(tags, id: \.self) { tag in
RemovableTagChip(tag: tag) { remove(tag) }
}
}
}
private var field: some View {
HStack(spacing: Metrics.Space.s) {
Image(systemName: "number")
.foregroundStyle(.secondary)
TextField(text: $query.singleLineText, prompt: Text(AppStrings.Vault.addATagProgress), axis: .horizontal) {
EmptyView()
}
.singleLinePaste()
.lineLimit(1)
.textFieldStyle(.plain)
.labelsHidden()
.multilineTextAlignment(.leading)
.accessibilityLabel(Text(AppStrings.Vault.addATagProgress))
.focused($isFocused)
// Return takes the highlighted row, so the whole interaction is
// type-then-Return without reaching for the arrow keys. A
// focused field's submit is handled before the Save button's
// `.defaultAction`, so this does not save the host by accident.
.onSubmit(commitHighlighted)
// And when the arrows are reached for, they move the highlight
// without the field giving up focus — correcting the query is
// always one keystroke away.
.onKeyPress(.upArrow) { move(-1) }
.onKeyPress(.downArrow) { move(1) }
// A comma commits, because that is what the field this replaces
// was made of and the habit outlives the control.
.onKeyPress(",") {
commitHighlighted()
return .handled
}
// Backspace on an empty query eats the last chip — the token
// field contract everywhere else on the platform.
.onKeyPress(.delete) {
guard query.isEmpty, let last = tags.last else { return .ignored }
remove(last)
return .handled
}
// Escape puts the list away and drops what was typed. Handing
// the key back when there is nothing left to dismiss is what
// leaves the editor's Cancel button its `.cancelAction`, so a
// second Escape still closes the form.
.onKeyPress(.escape) {
guard showsList || !query.isEmpty else { return .ignored }
query = ""
showsList = false
return .handled
}
// The same dismissal for a hand on the mouse. Also the way back in
// once the list has been closed without moving focus.
Button {
showsList.toggle()
isFocused = true
} label: {
Image(systemName: isListOpen ? "chevron.up" : "chevron.down")
.font(.caption2)
.foregroundStyle(.secondary)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel(
Text(isListOpen ? AppStrings.Vault.hideTagSuggestions : AppStrings.Vault.showTagSuggestions))
}
.padding(Metrics.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
// A row can leave the visible window as the query narrows the list, and
// a highlight nobody can see is the same as no highlight.
.onChange(of: query) { _, _ in
highlighted = rows.isEmpty ? nil : 0
// Typing always means "show me what matches", including after the
// list was dismissed with Escape.
showsList = true
}
}
private var list: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(rows.enumerated()), id: \.element) { index, row in
TagRowView(row: row, isHighlighted: highlighted == index)
.id(row)
// Single click commits: this is a picker, not a list of objects
// to select and then act on.
.onTapGesture {
commit(row)
}
}
}
.padding(.vertical, Metrics.Space.xxs)
}
.onAppear { scrollHighlightIntoView(using: proxy) }
.onChange(of: highlighted) { _, _ in scrollHighlightIntoView(using: proxy) }
.onChange(of: rows) { _, _ in scrollHighlightIntoView(using: proxy) }
}
.frame(maxHeight: listMaxHeight)
.scrollBounceBehavior(.basedOnSize)
.fixedSize(horizontal: false, vertical: true)
}
private func scrollHighlightIntoView(using proxy: ScrollViewProxy) {
guard let highlighted, rows.indices.contains(highlighted) else { return }
proxy.scrollTo(rows[highlighted])
}
// MARK: - Rows
private var trimmedQuery: String {
query.trimmingCharacters(in: .whitespaces)
}
private var rows: [TagFieldPolicy.Row] {
TagFieldPolicy.rows(tags: tags, suggestions: suggestions, query: query)
}
// MARK: - Editing
/// Moves the highlight by `delta`, clamped rather than wrapped: running off
/// the end of a list and reappearing at the other is how the wrong row gets
/// picked.
private func move(_ delta: Int) -> KeyPress.Result {
guard !rows.isEmpty else { return .ignored }
// An arrow after Escape means "show me that list again" — the key is
// asking to move within something, so it has to be on screen.
showsList = true
guard let current = highlighted else {
highlighted = delta > 0 ? 0 : rows.count - 1
return .handled
}
highlighted = min(max(current + delta, 0), rows.count - 1)
return .handled
}
/// Return with nothing highlighted still commits what was typed, so a tag
/// can be added without ever looking at the list. A closed list has no
/// highlight to honour either, however stale the index — Return there means
/// the text, not the row that happened to be under it before Escape.
private func commitHighlighted() {
if isListOpen, let highlighted, rows.indices.contains(highlighted) {
commit(rows[highlighted])
} else if !trimmedQuery.isEmpty {
add(trimmedQuery)
}
}
private func commit(_ row: TagFieldPolicy.Row) {
switch row {
case .existing(let tag), .create(let tag): add(tag)
}
}
/// Appends rather than sorts: the order tags were chosen in is the order the
/// host lists them, and a chip that jumps elsewhere the moment it is added
/// is a chip nobody can find again to remove.
private func add(_ tag: String) {
guard !tag.trimmingCharacters(in: .whitespaces).isEmpty else { return }
tags = TagFieldPolicy.adding(tag, to: tags)
query = ""
highlighted = rows.isEmpty ? nil : 0
isFocused = true
}
private func remove(_ tag: String) {
tags = TagFieldPolicy.removing(tag, from: tags)
highlighted = rows.isEmpty ? nil : 0
}
}
// MARK: - Row view
private struct TagRowView: View {
let row: TagFieldPolicy.Row
let isHighlighted: Bool
@State private var isHovering = false
var body: some View {
HStack(spacing: Metrics.Space.s) {
switch row {
case .existing(let tag):
Image(systemName: "number")
.foregroundStyle(.secondary)
Text(tag)
case .create(let tag):
Image(systemName: "plus.circle")
.foregroundStyle(.secondary)
Text(AppStrings.Vault.createNamed(tag))
}
Spacer(minLength: 0)
}
.lineLimit(1)
.padding(.horizontal, Metrics.Space.m)
.padding(.vertical, Metrics.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
Highlight.fill(isSelected: isHighlighted, isHovering: isHovering),
in: .rect(cornerRadius: Metrics.Radius.small)
)
.contentShape(.rect(cornerRadius: Metrics.Radius.small))
.onHover { isHovering = $0 }
.accessibilityElement(children: .combine)
.accessibilityAddTraits(isHighlighted ? [.isButton, .isSelected] : .isButton)
}
}
// MARK: - Chip
/// A chosen tag, with the ⓧ that takes it back off.
///
/// Not `CardChip` with an optional action bolted on: that one is read-only
/// decoration in five places, and the only thing the two share is a capsule.
/// The metrics are copied deliberately so the chip here and the chip on the
/// Hosts card read as the same object.
private struct RemovableTagChip: View {
let tag: String
let remove: () -> Void
@State private var isHovering = false
var body: some View {
HStack(spacing: Metrics.Space.xxs) {
Text(tag)
Button(action: remove) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(isHovering ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tertiary))
}
.buttonStyle(.plain)
.accessibilityLabel(Text(AppStrings.Vault.removeTag(tag)))
}
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, Metrics.Space.s)
.padding(.vertical, 2)
.background(.quaternary.opacity(0.6), in: .capsule)
.lineLimit(1)
.onHover { isHovering = $0 }
}
}
#Preview {
@Previewable @State var tags = ["nginx", "prod"]
Form {
Section {
VStack(alignment: .leading, spacing: Metrics.Space.s) {
Text(AppStrings.Vault.tags)
TagField(tags: $tags, suggestions: HostLibraryModel.demo().allTags)
}
}
}
.formStyle(.grouped)
.frame(width: 480, height: 380)
}