706 lines
26 KiB
Swift
706 lines
26 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import SwiftUI
|
|
import TermindPresentation
|
|
|
|
#if canImport(AppKit)
|
|
import AppKit
|
|
#endif
|
|
|
|
/// Type-erased selection hooks used by `CardPage`'s background marquee.
|
|
/// Pages keep their strongly typed selection; the grid only needs stable IDs to
|
|
/// compare card frames with the marquee rectangle.
|
|
struct CardMarqueeSelection {
|
|
let selected: () -> Set<AnyHashable>
|
|
let update: (Set<AnyHashable>) -> Void
|
|
}
|
|
|
|
private enum CardSelectionCoordinateSpace {
|
|
nonisolated static let name = "Termind.CardSelection"
|
|
}
|
|
|
|
/// Pure geometry shared by the AppKit input bridge and unit tests. Keeping the
|
|
/// hit policy here ensures a blank click, a marquee start and the visible
|
|
/// selection all agree about where cards and controls are.
|
|
enum CardMarqueeSelectionResolver {
|
|
static func rectangle(from start: CGPoint, to current: CGPoint) -> CGRect {
|
|
CGRect(
|
|
x: min(start.x, current.x),
|
|
y: min(start.y, current.y),
|
|
width: abs(current.x - start.x),
|
|
height: abs(current.y - start.y)
|
|
)
|
|
}
|
|
|
|
static func isBlank(
|
|
_ point: CGPoint,
|
|
cardFrames: [CGRect],
|
|
interactiveFrames: [CGRect]
|
|
) -> Bool {
|
|
!contains(point, in: cardFrames) && !contains(point, in: interactiveFrames)
|
|
}
|
|
|
|
static func selection<ID: Hashable>(
|
|
intersecting rect: CGRect,
|
|
cardFrames: [ID: CGRect],
|
|
base: Set<ID> = [],
|
|
extendsSelection: Bool = false
|
|
) -> Set<ID> {
|
|
let intersecting = Set(
|
|
cardFrames.compactMap { id, frame in frame.intersects(rect) ? id : nil }
|
|
)
|
|
return extendsSelection ? base.union(intersecting) : intersecting
|
|
}
|
|
|
|
private static func contains(_ point: CGPoint, in frames: [CGRect]) -> Bool {
|
|
frames.contains { $0.insetBy(dx: -1, dy: -1).contains(point) }
|
|
}
|
|
}
|
|
|
|
/// Geometry used only by pointer gestures does not belong in SwiftUI state.
|
|
/// Publishing every card through a preference and then assigning the aggregate
|
|
/// to `@State` makes scrolling a layout feedback loop: moving a lazy-grid cell
|
|
/// changes the preference, the state write redraws the page, and the preference
|
|
/// is evaluated again in the same display frame. A page-owned reference keeps
|
|
/// the latest hit-test geometry without invalidating layout.
|
|
private final class CardGeometryStore {
|
|
private var cardFrames: [AnyHashable: (reporter: UUID, frame: CGRect)] = [:]
|
|
private var cardIDsByReporter: [UUID: AnyHashable] = [:]
|
|
private var latestCardFrames: [UUID: CGRect] = [:]
|
|
private var interactiveFrames: [UUID: CGRect] = [:]
|
|
|
|
func updateCardFrame(_ frame: CGRect, id: AnyHashable?, reporter: UUID) {
|
|
latestCardFrames[reporter] = frame
|
|
updateCardID(id, reporter: reporter)
|
|
}
|
|
|
|
func updateCardID(_ id: AnyHashable?, reporter: UUID) {
|
|
if let previousID = cardIDsByReporter[reporter], previousID != id,
|
|
cardFrames[previousID]?.reporter == reporter
|
|
{
|
|
cardFrames.removeValue(forKey: previousID)
|
|
}
|
|
|
|
guard let id, let frame = latestCardFrames[reporter] else {
|
|
cardIDsByReporter.removeValue(forKey: reporter)
|
|
return
|
|
}
|
|
cardIDsByReporter[reporter] = id
|
|
cardFrames[id] = (reporter, frame)
|
|
}
|
|
|
|
func removeCardFrame(reporter: UUID) {
|
|
latestCardFrames.removeValue(forKey: reporter)
|
|
guard let id = cardIDsByReporter.removeValue(forKey: reporter) else { return }
|
|
if cardFrames[id]?.reporter == reporter {
|
|
cardFrames.removeValue(forKey: id)
|
|
}
|
|
}
|
|
|
|
func updateInteractiveFrame(_ frame: CGRect, reporter: UUID) {
|
|
interactiveFrames[reporter] = frame
|
|
}
|
|
|
|
func removeInteractiveFrame(reporter: UUID) {
|
|
interactiveFrames.removeValue(forKey: reporter)
|
|
}
|
|
|
|
func isBlank(at point: CGPoint) -> Bool {
|
|
CardMarqueeSelectionResolver.isBlank(
|
|
point,
|
|
cardFrames: cardFrames.values.map(\.frame),
|
|
interactiveFrames: Array(interactiveFrames.values)
|
|
)
|
|
}
|
|
|
|
func cardIDs(intersecting rect: CGRect) -> Set<AnyHashable> {
|
|
CardMarqueeSelectionResolver.selection(
|
|
intersecting: rect,
|
|
cardFrames: cardFrames.mapValues(\.frame)
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct CardGeometryStoreKey: EnvironmentKey {
|
|
static let defaultValue: CardGeometryStore? = nil
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
fileprivate var cardGeometryStore: CardGeometryStore? {
|
|
get { self[CardGeometryStoreKey.self] }
|
|
set { self[CardGeometryStoreKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
/// The shared shape of every manager page, modelled on Rayon: a scrolling page of
|
|
/// titled sections, each one a grid of cards.
|
|
///
|
|
/// Cards rather than a table because these pages answer "which one do I want",
|
|
/// not "sort these by column". A card can show a name, an address, a status dot,
|
|
/// and two lines of detail in the space a table spends on one column, it reflows
|
|
/// instead of truncating when the Sidebar widens, and it gives the primary
|
|
/// action — connect, insert, start — somewhere obvious to live.
|
|
///
|
|
/// The outer stack is deliberately *not* lazy, and turning it back into a
|
|
/// `LazyVStack` reintroduces a real bug. A lazy stack estimates the height of the
|
|
/// rows it has not built yet, which is fine while the content is static and wrong
|
|
/// the moment a section collapses: scrolled to the bottom, most sections above
|
|
/// are estimates, and folding one leaves the rest drawn at stale offsets. Its
|
|
/// children are sections — a handful even on a large fleet — so measuring them
|
|
/// all costs nothing. The laziness that matters is one level down, in `CardGrid`,
|
|
/// where the hundreds of cards are.
|
|
struct CardPage<Content: View>: View {
|
|
var marqueeSelection: CardMarqueeSelection? = nil
|
|
/// Blank-space input is routed through an AppKit overlay that declines hits
|
|
/// over cards, so marquee selection can coexist with native item drags.
|
|
var supportsMarqueeSelection = true
|
|
/// What right-clicking the canvas offers, given where the pointer is. The
|
|
/// page's cards keep their own menus; this only answers for the space
|
|
/// between them, which is where creating a new one now lives.
|
|
var blankAreaMenu: ((CGPoint) -> [NativeMenuEntry])? = nil
|
|
var blankAreaPrimaryAction: (() -> Void)? = nil
|
|
@ViewBuilder var content: Content
|
|
|
|
@State private var geometryStore = CardGeometryStore()
|
|
@State private var marqueeStart: CGPoint?
|
|
@State private var marqueeCurrent: CGPoint?
|
|
@State private var marqueeBase: Set<AnyHashable> = []
|
|
@State private var marqueeExtendsSelection = false
|
|
|
|
var body: some View {
|
|
pageScroll
|
|
.overlay {
|
|
if let marqueeRect {
|
|
RoundedRectangle(cornerRadius: Metrics.Radius.small)
|
|
.fill(Color.accentColor.opacity(0.10))
|
|
.strokeBorder(Color.accentColor.opacity(0.85), lineWidth: 1)
|
|
.frame(width: marqueeRect.width, height: marqueeRect.height)
|
|
.position(x: marqueeRect.midX, y: marqueeRect.midY)
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
.overlay { marqueeInputHost }
|
|
.overlay { blankAreaMenuHost }
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(TermindColor.windowSurface)
|
|
}
|
|
|
|
/// This is deliberately native rather than a SwiftUI `DragGesture`: an
|
|
/// ancestor drag recognizer competes with `onDrag` even when its callback
|
|
/// later discovers that the pointer started on a card. Hit testing here
|
|
/// rejects card and header points before either interaction begins.
|
|
@ViewBuilder
|
|
private var marqueeInputHost: some View {
|
|
if let marqueeSelection {
|
|
NativeCardMarqueeInput(
|
|
allowsDragging: supportsMarqueeSelection,
|
|
claims: geometryStore.isBlank(at:),
|
|
click: {
|
|
marqueeSelection.update([])
|
|
blankAreaPrimaryAction?()
|
|
},
|
|
begin: { point, extendsSelection in
|
|
marqueeStart = point
|
|
marqueeCurrent = point
|
|
marqueeBase = marqueeSelection.selected()
|
|
marqueeExtendsSelection = extendsSelection
|
|
},
|
|
change: { point in
|
|
marqueeCurrent = point
|
|
guard let marqueeRect else { return }
|
|
let intersecting = geometryStore.cardIDs(intersecting: marqueeRect)
|
|
marqueeSelection.update(
|
|
marqueeExtendsSelection
|
|
? marqueeBase.union(intersecting)
|
|
: intersecting
|
|
)
|
|
},
|
|
end: resetMarquee
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Declines every point occupied by a card or by a section header, so those
|
|
/// keep the menus they already have and only the canvas answers here.
|
|
@ViewBuilder
|
|
private var blankAreaMenuHost: some View {
|
|
if let blankAreaMenu {
|
|
NativeBlankAreaMenu(
|
|
claims: geometryStore.isBlank(at:),
|
|
entries: blankAreaMenu
|
|
)
|
|
}
|
|
}
|
|
|
|
private var pageScroll: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: Metrics.Space.xxl) {
|
|
content
|
|
}
|
|
.padding(Metrics.Space.xl)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.environment(\.cardGeometryStore, geometryStore)
|
|
}
|
|
.coordinateSpace(name: CardSelectionCoordinateSpace.name)
|
|
}
|
|
|
|
private var marqueeRect: CGRect? {
|
|
guard let marqueeStart, let marqueeCurrent else { return nil }
|
|
return CardMarqueeSelectionResolver.rectangle(from: marqueeStart, to: marqueeCurrent)
|
|
}
|
|
|
|
private func resetMarquee() {
|
|
marqueeStart = nil
|
|
marqueeCurrent = nil
|
|
marqueeBase.removeAll()
|
|
marqueeExtendsSelection = false
|
|
}
|
|
}
|
|
|
|
#if canImport(AppKit)
|
|
/// A transparent left-mouse-only overlay for the canvas between cards.
|
|
/// Once it accepts mouse-down, AppKit keeps subsequent drag/up events routed
|
|
/// here even when the pointer leaves the view bounds.
|
|
private struct NativeCardMarqueeInput: NSViewRepresentable {
|
|
var allowsDragging = true
|
|
var claims: (CGPoint) -> Bool = { _ in true }
|
|
var click: () -> Void = {}
|
|
var begin: (CGPoint, Bool) -> Void = { _, _ in }
|
|
var change: (CGPoint) -> Void = { _ in }
|
|
var end: () -> Void = {}
|
|
|
|
func makeNSView(context: Context) -> MarqueeInputView {
|
|
MarqueeInputView()
|
|
}
|
|
|
|
func updateNSView(_ view: MarqueeInputView, context: Context) {
|
|
view.allowsDragging = allowsDragging
|
|
view.claims = claims
|
|
view.click = click
|
|
view.begin = begin
|
|
view.change = change
|
|
view.end = end
|
|
}
|
|
|
|
static func dismantleNSView(_ view: MarqueeInputView, coordinator: ()) {
|
|
view.cancelTracking()
|
|
}
|
|
|
|
@MainActor
|
|
final class MarqueeInputView: NSView {
|
|
var allowsDragging = true
|
|
var claims: (CGPoint) -> Bool = { _ in true }
|
|
var click: () -> Void = {}
|
|
var begin: (CGPoint, Bool) -> Void = { _, _ in }
|
|
var change: (CGPoint) -> Void = { _ in }
|
|
var end: () -> Void = {}
|
|
|
|
private var start: CGPoint?
|
|
private var extendsSelection = false
|
|
private var exceededDragThreshold = false
|
|
private var marqueeStarted = false
|
|
|
|
override var isFlipped: Bool { true }
|
|
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
guard let event = NSApp.currentEvent, event.type == .leftMouseDown,
|
|
!Self.hitsVisibleScroller(event, in: window?.contentView)
|
|
else { return nil }
|
|
let local = convert(point, from: superview)
|
|
guard bounds.contains(local), claims(local) else { return nil }
|
|
return self
|
|
}
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
start = convert(event.locationInWindow, from: nil)
|
|
extendsSelection = event.modifierFlags.contains(.shift)
|
|
exceededDragThreshold = false
|
|
marqueeStarted = false
|
|
}
|
|
|
|
override func mouseDragged(with event: NSEvent) {
|
|
guard let start else { return }
|
|
let current = convert(event.locationInWindow, from: nil)
|
|
if !exceededDragThreshold {
|
|
exceededDragThreshold = hypot(current.x - start.x, current.y - start.y) >= 5
|
|
}
|
|
guard exceededDragThreshold, allowsDragging else { return }
|
|
if !marqueeStarted {
|
|
marqueeStarted = true
|
|
begin(start, extendsSelection)
|
|
}
|
|
change(current)
|
|
}
|
|
|
|
override func mouseUp(with event: NSEvent) {
|
|
if marqueeStarted {
|
|
end()
|
|
} else if !exceededDragThreshold {
|
|
click()
|
|
}
|
|
clearTracking()
|
|
}
|
|
|
|
func cancelTracking() {
|
|
if marqueeStarted { end() }
|
|
clearTracking()
|
|
}
|
|
|
|
override func viewWillMove(toWindow newWindow: NSWindow?) {
|
|
if newWindow == nil { cancelTracking() }
|
|
super.viewWillMove(toWindow: newWindow)
|
|
}
|
|
|
|
private func clearTracking() {
|
|
start = nil
|
|
extendsSelection = false
|
|
exceededDragThreshold = false
|
|
marqueeStarted = false
|
|
}
|
|
|
|
private static func hitsVisibleScroller(_ event: NSEvent, in view: NSView?) -> Bool {
|
|
guard let view, !view.isHidden, view.alphaValue > 0 else { return false }
|
|
if let scroller = view as? NSScroller,
|
|
scroller.bounds.contains(scroller.convert(event.locationInWindow, from: nil))
|
|
{
|
|
return true
|
|
}
|
|
return view.subviews.contains { hitsVisibleScroller(event, in: $0) }
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
/// The title row a section leads with, on its own so a section whose header does
|
|
/// something — a group, which can be collapsed, selected, renamed, deleted — can
|
|
/// reuse it instead of drawing a second one that has to be kept looking alike.
|
|
///
|
|
/// Every interactive part is optional and off by default, so the plain sections
|
|
/// on Keychain, Snippets and the rest render exactly as they did.
|
|
struct CardSectionHeader: View {
|
|
/// A plain `String` for the same reason as `CardSection.title`: half of
|
|
/// these are names the user typed.
|
|
let title: String
|
|
var count: Int?
|
|
/// `nil` means no disclosure triangle — the section cannot be collapsed.
|
|
var toggle: (() -> Void)?
|
|
var isCollapsed = false
|
|
var select: (() -> Void)?
|
|
var isSelected = false
|
|
/// A host card is currently over this section and can be filed here.
|
|
var isDropTarget = false
|
|
|
|
@State private var isHovering = false
|
|
|
|
var body: some View {
|
|
Group {
|
|
if select == nil, let toggle {
|
|
Button(action: toggle) { paddedLabel }
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Text(title))
|
|
.accessibilityHint(Text(isCollapsed ? AppStrings.Common.expand : AppStrings.Common.collapse))
|
|
} else {
|
|
paddedLabel.onTapGesture { select?() }
|
|
}
|
|
}
|
|
// Same fill as `SidebarRow`, and fixed for the same reason: derived from
|
|
// the text color rather than the accent, so it does not change with the
|
|
// accent or with which view happens to hold focus.
|
|
.background(fill, in: .rect(cornerRadius: Metrics.Radius.medium))
|
|
.contentShape(.rect(cornerRadius: Metrics.Radius.medium))
|
|
.cardInteractiveFrame()
|
|
.onHover { isHovering = $0 }
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [])
|
|
}
|
|
|
|
private var label: some View {
|
|
HStack(spacing: Metrics.Space.s) {
|
|
if let toggle {
|
|
if select == nil {
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
.rotationEffect(.degrees(isCollapsed ? 0 : 90))
|
|
} else {
|
|
Button(action: toggle) {
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
.rotationEffect(.degrees(isCollapsed ? 0 : 90))
|
|
.contentShape(.rect)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Text(isCollapsed ? AppStrings.Common.expand : AppStrings.Common.collapse))
|
|
}
|
|
}
|
|
|
|
Text(title)
|
|
.font(.headline)
|
|
|
|
if let count {
|
|
Text(count, format: .number)
|
|
.font(.caption.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, Metrics.Space.s)
|
|
.padding(.vertical, 1)
|
|
.background(.quaternary.opacity(0.6), in: .capsule)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var paddedLabel: some View {
|
|
label
|
|
.padding(.horizontal, isInteractive ? Metrics.Space.s : 0)
|
|
.padding(.vertical, isInteractive ? Metrics.Space.xs : 0)
|
|
}
|
|
|
|
private var isInteractive: Bool { select != nil || toggle != nil }
|
|
|
|
private var fill: Color {
|
|
if isDropTarget {
|
|
Color.accentColor.opacity(0.16)
|
|
} else if select == nil {
|
|
.clear
|
|
} else if isSelected {
|
|
.primary.opacity(0.10)
|
|
} else if isHovering {
|
|
.primary.opacity(0.05)
|
|
} else {
|
|
.clear
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Marks controls that sit in the card canvas but are not selectable cards.
|
|
/// A click on one should perform that control, not also look like a click on
|
|
/// empty space and clear the page selection.
|
|
private struct CardInteractiveFrameModifier: ViewModifier {
|
|
@Environment(\.cardGeometryStore) private var geometryStore
|
|
@State private var reporterID = UUID()
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.onGeometryChange(for: CGRect.self) {
|
|
$0.frame(in: .named(CardSelectionCoordinateSpace.name))
|
|
} action: { frame in
|
|
geometryStore?.updateInteractiveFrame(frame, reporter: reporterID)
|
|
}
|
|
.onDisappear {
|
|
geometryStore?.removeInteractiveFrame(reporter: reporterID)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct CardFrameModifier: ViewModifier {
|
|
let selectionID: AnyHashable?
|
|
|
|
@Environment(\.cardGeometryStore) private var geometryStore
|
|
@State private var reporterID = UUID()
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.onGeometryChange(for: CGRect.self) {
|
|
$0.frame(in: .named(CardSelectionCoordinateSpace.name))
|
|
} action: { newFrame in
|
|
geometryStore?.updateCardFrame(
|
|
newFrame,
|
|
id: selectionID,
|
|
reporter: reporterID
|
|
)
|
|
}
|
|
.onChange(of: selectionID) {
|
|
geometryStore?.updateCardID(selectionID, reporter: reporterID)
|
|
}
|
|
.onDisappear {
|
|
geometryStore?.removeCardFrame(reporter: reporterID)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func cardInteractiveFrame() -> some View {
|
|
modifier(CardInteractiveFrameModifier())
|
|
}
|
|
}
|
|
|
|
/// The grid itself. Adaptive columns, so the same page works at 600pt with the
|
|
/// Sidebar widened and at 1600pt on an external display.
|
|
struct CardGrid<Content: View>: View {
|
|
var minimumWidth: CGFloat = Metrics.Card.min
|
|
var maximumWidth: CGFloat = Metrics.Card.max
|
|
@ViewBuilder var content: Content
|
|
|
|
private var columns: [GridItem] {
|
|
[
|
|
GridItem(
|
|
.adaptive(minimum: minimumWidth, maximum: max(minimumWidth, maximumWidth)),
|
|
spacing: Metrics.Space.l,
|
|
alignment: .top
|
|
)
|
|
]
|
|
}
|
|
|
|
var body: some View {
|
|
LazyVGrid(columns: columns, alignment: .leading, spacing: Metrics.Space.l) {
|
|
content
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One card.
|
|
///
|
|
/// Single click selects, double click activates — the Finder contract, and the
|
|
/// one every list in this app already follows. Two `onTapGesture`s rather than a
|
|
/// `Button`: a button would swallow the double click and give back a control
|
|
/// that cannot be selected without also being triggered.
|
|
struct CardTile<Content: View>: View {
|
|
var selectionID: AnyHashable? = nil
|
|
var isSelected: Bool = false
|
|
var select: () -> Void = {}
|
|
var extendSelection: (() -> Void)?
|
|
var activate: (() -> Void)?
|
|
var activatesOnReturn = false
|
|
var activatesOnSingleClick = false
|
|
@ViewBuilder var content: Content
|
|
|
|
@State private var isHovering = false
|
|
|
|
var body: some View {
|
|
content
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(Metrics.Space.l)
|
|
// Keep short cards level with their neighbours and split any extra
|
|
// height evenly above and below the content.
|
|
.frame(minHeight: Metrics.Card.minHeight, alignment: .center)
|
|
.background(
|
|
isHovering && !isSelected ? Color.primary.opacity(0.035) : .clear,
|
|
in: .rect(cornerRadius: Metrics.Radius.large)
|
|
)
|
|
.termindGlassSurface(
|
|
cornerRadius: Metrics.Radius.large,
|
|
tint: isSelected ? Color.accentColor.opacity(0.16) : nil,
|
|
isInteractive: true
|
|
)
|
|
// Nothing a card holds may paint outside the card. Clipped here
|
|
// rather than trusted to each page, because the failure is silent
|
|
// until it isn't: one subtitle that will not compress and the text
|
|
// draws over the neighbouring tile. The border below survives the
|
|
// clip — `strokeBorder` insets, so the stroke is inside the shape.
|
|
.clipShape(.rect(cornerRadius: Metrics.Radius.large))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: Metrics.Radius.large)
|
|
.strokeBorder(
|
|
isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.quaternary),
|
|
lineWidth: isSelected ? 2 : 1
|
|
)
|
|
}
|
|
.contentShape(.rect(cornerRadius: Metrics.Radius.large))
|
|
.modifier(
|
|
CardTapModifier(
|
|
select: {
|
|
select()
|
|
if activatesOnSingleClick { activate?() }
|
|
},
|
|
extendSelection: extendSelection,
|
|
activate: activatesOnSingleClick ? nil : activate
|
|
)
|
|
)
|
|
.focusable(activatesOnReturn && activate != nil)
|
|
.termindInputAwareFocusEffect()
|
|
.onKeyPress(.return) {
|
|
guard activatesOnReturn, let activate else { return .ignored }
|
|
activate()
|
|
return .handled
|
|
}
|
|
.modifier(CardFrameModifier(selectionID: selectionID))
|
|
.onHover { isHovering = $0 }
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityAddTraits(.isButton)
|
|
}
|
|
|
|
}
|
|
|
|
/// A selection-only card has no double-click recognizer at all. Besides making
|
|
/// one click immediate in Keychain, this avoids turning two clicks into a
|
|
/// special selection gesture. Host cards opt into the second recognizer because
|
|
/// their double click has a distinct meaning: Connect.
|
|
private struct CardTapModifier: ViewModifier {
|
|
let select: () -> Void
|
|
let extendSelection: (() -> Void)?
|
|
let activate: (() -> Void)?
|
|
|
|
func body(content: Content) -> some View {
|
|
if let activate {
|
|
content
|
|
.onTapGesture(count: 2, perform: activate)
|
|
.onTapGesture(perform: performSelection)
|
|
} else {
|
|
content.onTapGesture(perform: performSelection)
|
|
}
|
|
}
|
|
|
|
private func performSelection() {
|
|
#if canImport(AppKit)
|
|
if NSEvent.modifierFlags.contains(.shift), let extendSelection {
|
|
extendSelection()
|
|
return
|
|
}
|
|
#endif
|
|
select()
|
|
}
|
|
}
|
|
|
|
/// The tinted rounded square a card leads with. Gives every page the same
|
|
/// left-hand rhythm whatever it is listing.
|
|
struct CardIcon: View {
|
|
let symbol: String
|
|
var tint: Color = .accentColor
|
|
|
|
var body: some View {
|
|
RoundedRectangle(cornerRadius: Metrics.Radius.medium)
|
|
.fill(tint.opacity(0.16))
|
|
.frame(width: Metrics.Card.icon, height: Metrics.Card.icon)
|
|
.overlay {
|
|
Image(systemName: symbol)
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundStyle(tint)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A one-word capsule: a tag, an algorithm, a transfer state.
|
|
struct CardChip: View {
|
|
let text: String
|
|
var symbol: String?
|
|
var tint: Color?
|
|
|
|
var body: some View {
|
|
HStack(spacing: Metrics.Space.xxs) {
|
|
if let symbol {
|
|
Image(systemName: symbol)
|
|
}
|
|
Text(text)
|
|
}
|
|
.font(.caption2)
|
|
.foregroundStyle(foreground)
|
|
.padding(.horizontal, Metrics.Space.s)
|
|
.padding(.vertical, 2)
|
|
.background(fill, in: .capsule)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
private var foreground: AnyShapeStyle {
|
|
if let tint { AnyShapeStyle(tint) } else { AnyShapeStyle(.secondary) }
|
|
}
|
|
|
|
private var fill: AnyShapeStyle {
|
|
if let tint {
|
|
AnyShapeStyle(tint.opacity(0.14))
|
|
} else {
|
|
AnyShapeStyle(.quaternary.opacity(0.6))
|
|
}
|
|
}
|
|
}
|