536 lines
20 KiB
Swift
536 lines
20 KiB
Swift
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
nonisolated private let managedCardDragSpace = "termind.managedCardPage.drag"
|
|
|
|
/// One homogeneous section in a managed card page. Pages decide what a section
|
|
/// means; the renderer only needs stable identity, ordered items and capabilities.
|
|
struct ManagedCardSection<SectionID: Hashable, Item: Identifiable>: Identifiable {
|
|
let id: SectionID
|
|
let title: String
|
|
let items: [Item]
|
|
var isExpanded: Bool? = nil
|
|
var canAcceptDrop = true
|
|
var showsWhenEmpty = true
|
|
}
|
|
|
|
/// Strongly typed access to selection owned elsewhere, normally WindowModel.
|
|
/// Type erasure is kept inside CardPage's geometry preferences and never leaks
|
|
/// into a manager page or its model operations.
|
|
struct ManagedCardSelection<ID: Hashable> {
|
|
let selected: () -> Set<ID>
|
|
let update: (Set<ID>) -> Void
|
|
}
|
|
|
|
enum ManagedCardSelectionMode {
|
|
case single
|
|
case multiple
|
|
}
|
|
|
|
enum ManagedCardDropPolicy<SectionID: Hashable> {
|
|
case sameSection
|
|
case anySection
|
|
case custom((SectionID, SectionID) -> Bool)
|
|
|
|
func allowsMove(from source: SectionID, to destination: SectionID) -> Bool {
|
|
switch self {
|
|
case .sameSection: source == destination
|
|
case .anySection: true
|
|
case .custom(let predicate): predicate(source, destination)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The model-facing end of reordering. The renderer resolves a pointer to a
|
|
/// destination and next sibling, then hands only stable IDs to this closure.
|
|
struct ManagedCardReordering<SectionID: Hashable, ID: Hashable> {
|
|
var isEnabled = true
|
|
var dropPolicy: ManagedCardDropPolicy<SectionID>
|
|
let move: (_ ids: [ID], _ destination: SectionID, _ before: ID?) -> Void
|
|
}
|
|
|
|
struct ManagedCardCapabilities<SectionID: Hashable, ID: Hashable> {
|
|
var selection: ManagedCardSelectionMode = .multiple
|
|
var marqueeSelection = true
|
|
var reordering: ManagedCardReordering<SectionID, ID>?
|
|
}
|
|
|
|
/// Everything a business card needs to know about page-level interaction.
|
|
struct ManagedCardItemContext<Item: Identifiable> {
|
|
let isSelected: Bool
|
|
let actionItems: [Item]
|
|
let select: () -> Void
|
|
let extendSelection: () -> Void
|
|
}
|
|
|
|
/// Marker for page-specific sheets, alerts and confirmation dialogs applied by
|
|
/// the inherited body after the shared page has been rendered.
|
|
protocol ManagedCardPresentationModifier: ViewModifier {}
|
|
|
|
struct ManagedCardDeletionPresentation<ID: Hashable>: ManagedCardPresentationModifier {
|
|
@Binding var deleting: Set<ID>
|
|
@Binding var error: String?
|
|
let ids: [ID]
|
|
let confirmationTitle: LocalizedStringKey
|
|
let errorTitle: LocalizedStringKey
|
|
var explanation: Text? = nil
|
|
let prune: () -> Void
|
|
let delete: () -> Void
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.onAppear(perform: prune)
|
|
.onChange(of: ids) { prune() }
|
|
.confirmationDialog(
|
|
confirmationTitle,
|
|
isPresented: Binding(get: { !deleting.isEmpty }, set: { if !$0 { deleting = [] } })
|
|
) {
|
|
Button("Delete", role: .destructive, action: delete)
|
|
} message: {
|
|
if let explanation { explanation }
|
|
}
|
|
.alert(
|
|
errorTitle,
|
|
isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })
|
|
) {
|
|
Button("OK") { error = nil }
|
|
} message: {
|
|
Text(error ?? "")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// SwiftUI's protocol-oriented equivalent of a page base class. Conformers
|
|
/// provide data and business views; this protocol extension owns the body.
|
|
protocol ManagedCardPage: View {
|
|
associatedtype SectionID: Hashable
|
|
associatedtype Item: Identifiable
|
|
associatedtype CardContent: View
|
|
associatedtype HeaderContent: View
|
|
associatedtype GhostContent: View
|
|
associatedtype EmptyContent: View
|
|
associatedtype NoResultsContent: View
|
|
associatedtype Presentation: ManagedCardPresentationModifier
|
|
|
|
var managedMinimumCardWidth: CGFloat { get }
|
|
var managedMaximumCardWidth: CGFloat { get }
|
|
var managedSections: [ManagedCardSection<SectionID, Item>] { get }
|
|
var managedSelection: ManagedCardSelection<Item.ID> { get }
|
|
var managedCapabilities: ManagedCardCapabilities<SectionID, Item.ID> { get }
|
|
var isManagedSourceEmpty: Bool { get }
|
|
var managedPresentation: Presentation { get }
|
|
|
|
@ViewBuilder func managedCard(
|
|
for item: Item, context: ManagedCardItemContext<Item>
|
|
) -> CardContent
|
|
@ViewBuilder func managedHeader(
|
|
for section: ManagedCardSection<SectionID, Item>, isDropTarget: Bool
|
|
) -> HeaderContent
|
|
/// The card-shaped drag preview, reused as the destination slot while the
|
|
/// surrounding grid reflows around the dragged items.
|
|
@ViewBuilder func managedGhost(for item: Item) -> GhostContent
|
|
/// What right-clicking the canvas offers, given the section the pointer is
|
|
/// over — nil when it is past the last one.
|
|
///
|
|
/// This is where creation lives now that the dashed New tiles are gone: the
|
|
/// page's own verbs are in the window titlebar, and the empty space beside
|
|
/// the cards answers the same question with the section already chosen.
|
|
func managedBlankAreaMenu(for section: SectionID?) -> [NativeMenuEntry]
|
|
/// A primary click on true canvas space. CardPage uses the same native hit
|
|
/// geometry as marquee selection, so cards, headers, controls, and visible
|
|
/// scrollbars keep their own interactions.
|
|
func managedBlankAreaPrimaryAction()
|
|
@ViewBuilder func managedEmptyState() -> EmptyContent
|
|
@ViewBuilder func managedNoResultsState() -> NoResultsContent
|
|
}
|
|
|
|
extension ManagedCardPage {
|
|
var managedMinimumCardWidth: CGFloat { Metrics.Card.min }
|
|
var managedMaximumCardWidth: CGFloat { Metrics.Card.max }
|
|
func managedBlankAreaMenu(for section: SectionID?) -> [NativeMenuEntry] { [] }
|
|
func managedBlankAreaPrimaryAction() {}
|
|
|
|
var body: some View {
|
|
ManagedCardPageRenderer<Self>(
|
|
minimumCardWidth: managedMinimumCardWidth,
|
|
maximumCardWidth: managedMaximumCardWidth,
|
|
sections: managedSections,
|
|
selection: managedSelection,
|
|
capabilities: managedCapabilities,
|
|
isSourceEmpty: isManagedSourceEmpty,
|
|
card: managedCard,
|
|
header: managedHeader,
|
|
ghost: managedGhost,
|
|
blankAreaMenu: managedBlankAreaMenu,
|
|
blankAreaPrimaryAction: managedBlankAreaPrimaryAction,
|
|
emptyState: managedEmptyState,
|
|
noResultsState: managedNoResultsState
|
|
)
|
|
.modifier(managedPresentation)
|
|
}
|
|
}
|
|
|
|
struct ManagedCardDropLocation<SectionID: Hashable>: Equatable {
|
|
let section: SectionID
|
|
/// Index after the cards in flight have been removed.
|
|
let index: Int
|
|
}
|
|
|
|
struct ManagedCardDropSection<SectionID: Hashable, ID: Hashable> {
|
|
let id: SectionID
|
|
let frame: CGRect
|
|
let isExpanded: Bool
|
|
let itemIDs: [ID]
|
|
}
|
|
|
|
/// Pure, testable pointer-to-slot calculation. Capability filtering and source
|
|
/// omission happen before this boundary; the resolver only understands layout.
|
|
enum ManagedCardDropResolver {
|
|
static func location<SectionID: Hashable, ID: Hashable>(
|
|
at point: CGPoint,
|
|
sections: [ManagedCardDropSection<SectionID, ID>],
|
|
itemFrames: [ID: CGRect]
|
|
) -> ManagedCardDropLocation<SectionID>? {
|
|
guard
|
|
let section = sections.min(by: {
|
|
verticalDistance(from: point.y, to: $0.frame)
|
|
< verticalDistance(from: point.y, to: $1.frame)
|
|
})
|
|
else { return nil }
|
|
|
|
guard section.isExpanded else {
|
|
return ManagedCardDropLocation(section: section.id, index: section.itemIDs.count)
|
|
}
|
|
for (index, id) in section.itemIDs.enumerated() {
|
|
guard let frame = itemFrames[id] else { continue }
|
|
if point.y < frame.minY || (point.y <= frame.maxY && point.x < frame.midX) {
|
|
return ManagedCardDropLocation(section: section.id, index: index)
|
|
}
|
|
}
|
|
return ManagedCardDropLocation(section: section.id, index: section.itemIDs.count)
|
|
}
|
|
|
|
private static func verticalDistance(from y: CGFloat, to frame: CGRect) -> CGFloat {
|
|
if y < frame.minY { return frame.minY - y }
|
|
if y > frame.maxY { return y - frame.maxY }
|
|
return 0
|
|
}
|
|
}
|
|
|
|
private struct ManagedCardDragSession<SectionID: Hashable, ID: Hashable> {
|
|
let source: SectionID
|
|
let ids: [ID]
|
|
}
|
|
|
|
/// Generic implementation behind ManagedCardPage's inherited body.
|
|
struct ManagedCardPageRenderer<Page: ManagedCardPage>: View {
|
|
var minimumCardWidth: CGFloat = Metrics.Card.min
|
|
var maximumCardWidth: CGFloat = Metrics.Card.max
|
|
let sections: [ManagedCardSection<Page.SectionID, Page.Item>]
|
|
let selection: ManagedCardSelection<Page.Item.ID>
|
|
let capabilities: ManagedCardCapabilities<Page.SectionID, Page.Item.ID>
|
|
let isSourceEmpty: Bool
|
|
let card: (Page.Item, ManagedCardItemContext<Page.Item>) -> Page.CardContent
|
|
let header: (ManagedCardSection<Page.SectionID, Page.Item>, Bool) -> Page.HeaderContent
|
|
let ghost: (Page.Item) -> Page.GhostContent
|
|
let blankAreaMenu: (Page.SectionID?) -> [NativeMenuEntry]
|
|
let blankAreaPrimaryAction: () -> Void
|
|
let emptyState: () -> Page.EmptyContent
|
|
let noResultsState: () -> Page.NoResultsContent
|
|
|
|
@State private var dragSession: ManagedCardDragSession<Page.SectionID, Page.Item.ID>?
|
|
@State private var omittedIDs: Set<Page.Item.ID> = []
|
|
@State private var ghostLocation: ManagedCardDropLocation<Page.SectionID>?
|
|
@State private var itemFrames: [Page.Item.ID: CGRect] = [:]
|
|
@State private var sectionFrames: [Page.SectionID: CGRect] = [:]
|
|
|
|
@ViewBuilder
|
|
var body: some View {
|
|
Group {
|
|
if isSourceEmpty {
|
|
emptyState()
|
|
} else if !hasVisibleContent {
|
|
noResultsState()
|
|
} else {
|
|
collection
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(TermindColor.windowSurface)
|
|
}
|
|
|
|
private var hasVisibleContent: Bool {
|
|
sections.contains { !$0.items.isEmpty || $0.showsWhenEmpty }
|
|
}
|
|
|
|
private var activeReordering: ManagedCardReordering<Page.SectionID, Page.Item.ID>? {
|
|
guard let reordering = capabilities.reordering, reordering.isEnabled else {
|
|
return nil
|
|
}
|
|
return reordering
|
|
}
|
|
|
|
private var collection: some View {
|
|
CardPage(
|
|
marqueeSelection: marqueeSelection,
|
|
supportsMarqueeSelection: capabilities.marqueeSelection,
|
|
blankAreaMenu: { point in blankAreaMenu(section(at: point)) },
|
|
blankAreaPrimaryAction: blankAreaPrimaryAction
|
|
) {
|
|
ForEach(sections) { section in
|
|
managedSection(section)
|
|
}
|
|
}
|
|
.contentShape(.rect)
|
|
.coordinateSpace(.named(managedCardDragSpace))
|
|
.onDrop(
|
|
of: [.text],
|
|
delegate: ManagedCardDropDelegate(
|
|
isActive: activeReordering != nil,
|
|
location: dropLocation(at:),
|
|
ghostLocation: $ghostLocation,
|
|
perform: performDrop,
|
|
cancel: cancelDrag
|
|
)
|
|
)
|
|
// AppKit does not expose drag-ended to SwiftUI. The next normal click
|
|
// restores a source released outside every valid destination.
|
|
.simultaneousGesture(TapGesture().onEnded(cancelDrag))
|
|
}
|
|
|
|
/// Which section the pointer is inside, for the blank-area menu. Reported
|
|
/// in the drag coordinate space, which is anchored to the page's own frame
|
|
/// and therefore already accounts for how far the page is scrolled.
|
|
private func section(at point: CGPoint) -> Page.SectionID? {
|
|
sections.lazy
|
|
.compactMap { section -> (Page.SectionID, CGRect)? in
|
|
guard let frame = sectionFrames[section.id] else { return nil }
|
|
return (section.id, frame)
|
|
}
|
|
.first { $0.1.contains(point) }?
|
|
.0
|
|
}
|
|
|
|
private func managedSection(
|
|
_ section: ManagedCardSection<Page.SectionID, Page.Item>
|
|
) -> some View {
|
|
// Geometry is reported for every section, not only while a drag is in
|
|
// flight: the blank-area menu asks the same question with no drag.
|
|
VStack(alignment: .leading, spacing: Metrics.Space.l) {
|
|
header(section, ghostLocation?.section == section.id)
|
|
|
|
if section.isExpanded ?? true {
|
|
CardGrid(minimumWidth: minimumCardWidth, maximumWidth: maximumCardWidth) { cards(in: section) }
|
|
}
|
|
}
|
|
.onGeometryChange(for: CGRect.self) {
|
|
$0.frame(in: .named(managedCardDragSpace))
|
|
} action: {
|
|
sectionFrames[section.id] = $0
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func cards(
|
|
in section: ManagedCardSection<Page.SectionID, Page.Item>
|
|
) -> some View {
|
|
let displayed = section.items.filter { !omittedIDs.contains($0.id) }
|
|
|
|
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, item in
|
|
if ghostLocation?.section == section.id, ghostLocation?.index == index {
|
|
ghosts
|
|
}
|
|
draggableCard(item, in: section.id)
|
|
.onGeometryChange(for: CGRect.self) {
|
|
$0.frame(in: .named(managedCardDragSpace))
|
|
} action: {
|
|
itemFrames[item.id] = $0
|
|
}
|
|
}
|
|
if ghostLocation?.section == section.id, ghostLocation?.index == displayed.count {
|
|
ghosts
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func draggableCard(_ item: Page.Item, in section: Page.SectionID) -> some View {
|
|
let card = card(item, itemContext(for: item))
|
|
if activeReordering != nil {
|
|
card.onDrag {
|
|
beginDrag(from: item, in: section)
|
|
return NSItemProvider(object: String(describing: item.id) as NSString)
|
|
} preview: {
|
|
// Supplying the preview avoids macOS 27's oversized prohibited
|
|
// operation badge in the default snapshot of the glass card.
|
|
ghost(item)
|
|
}
|
|
} else {
|
|
card
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var ghosts: some View {
|
|
ForEach(draggedItems) { item in
|
|
ghost(item)
|
|
}
|
|
}
|
|
|
|
// MARK: Selection
|
|
|
|
private var visibleItems: [Page.Item] { sections.flatMap(\.items) }
|
|
|
|
private var selectedIDs: Set<Page.Item.ID> { selection.selected() }
|
|
|
|
private var marqueeSelection: CardMarqueeSelection {
|
|
CardMarqueeSelection(
|
|
selected: { Set(selectedIDs.map { AnyHashable($0) }) },
|
|
update: { erased in
|
|
setSelection(Set(erased.compactMap { $0.base as? Page.Item.ID }))
|
|
}
|
|
)
|
|
}
|
|
|
|
private func itemContext(for item: Page.Item) -> ManagedCardItemContext<Page.Item> {
|
|
let selected = selectedIDs.contains(item.id)
|
|
let actions =
|
|
selected
|
|
? visibleItems.filter { selectedIDs.contains($0.id) }
|
|
: [item]
|
|
return ManagedCardItemContext(
|
|
isSelected: selected,
|
|
actionItems: actions,
|
|
select: { setSelection([item.id]) },
|
|
extendSelection: { toggleSelection(item.id) }
|
|
)
|
|
}
|
|
|
|
private func toggleSelection(_ id: Page.Item.ID) {
|
|
guard capabilities.selection == .multiple else {
|
|
setSelection([id])
|
|
return
|
|
}
|
|
var updated = selectedIDs
|
|
if updated.contains(id) { updated.remove(id) } else { updated.insert(id) }
|
|
setSelection(updated)
|
|
}
|
|
|
|
private func setSelection(_ ids: Set<Page.Item.ID>) {
|
|
let valid = ids.intersection(Set(visibleItems.map(\.id)))
|
|
if capabilities.selection == .single {
|
|
selection.update(Set(visibleItems.lazy.filter { valid.contains($0.id) }.prefix(1).map(\.id)))
|
|
} else {
|
|
selection.update(valid)
|
|
}
|
|
}
|
|
|
|
// MARK: Reordering
|
|
|
|
private var draggedItems: [Page.Item] {
|
|
guard let dragSession else { return [] }
|
|
let wanted = Set(dragSession.ids)
|
|
return visibleItems.filter { wanted.contains($0.id) }
|
|
}
|
|
|
|
private func beginDrag(from item: Page.Item, in source: Page.SectionID) {
|
|
guard let reordering = activeReordering else { return }
|
|
let actionItems = itemContext(for: item).actionItems
|
|
let sourceIDs = Set(
|
|
sections.first(where: { $0.id == source })?.items.map(\.id) ?? []
|
|
)
|
|
let items =
|
|
reordering.dropPolicy.allowsMove(from: source, to: source)
|
|
&& isFixedSection(policy: reordering.dropPolicy)
|
|
? actionItems.filter { sourceIDs.contains($0.id) }
|
|
: actionItems
|
|
let ids = items.map(\.id)
|
|
guard !ids.isEmpty else { return }
|
|
|
|
dragSession = ManagedCardDragSession(source: source, ids: ids)
|
|
ghostLocation = nil
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(100))
|
|
guard dragSession?.ids == ids else { return }
|
|
omittedIDs = Set(ids)
|
|
}
|
|
}
|
|
|
|
private func isFixedSection(
|
|
policy: ManagedCardDropPolicy<Page.SectionID>
|
|
) -> Bool {
|
|
if case .sameSection = policy { return true }
|
|
return false
|
|
}
|
|
|
|
private func dropLocation(at point: CGPoint) -> ManagedCardDropLocation<Page.SectionID>? {
|
|
guard let reordering = activeReordering, let dragSession else { return nil }
|
|
let moving = Set(dragSession.ids)
|
|
let candidates = sections.compactMap {
|
|
section -> ManagedCardDropSection<Page.SectionID, Page.Item.ID>? in
|
|
guard section.canAcceptDrop,
|
|
reordering.dropPolicy.allowsMove(from: dragSession.source, to: section.id),
|
|
let frame = sectionFrames[section.id]
|
|
else { return nil }
|
|
return ManagedCardDropSection(
|
|
id: section.id, frame: frame,
|
|
isExpanded: section.isExpanded ?? true,
|
|
itemIDs: section.items.lazy.filter { !moving.contains($0.id) }.map(\.id)
|
|
)
|
|
}
|
|
return ManagedCardDropResolver.location(
|
|
at: point, sections: candidates, itemFrames: itemFrames
|
|
)
|
|
}
|
|
|
|
private func performDrop(_ location: ManagedCardDropLocation<Page.SectionID>) {
|
|
guard let reordering = activeReordering, let dragSession,
|
|
let section = sections.first(where: { $0.id == location.section })
|
|
else {
|
|
cancelDrag()
|
|
return
|
|
}
|
|
let moving = Set(dragSession.ids)
|
|
let remaining = section.items.filter { !moving.contains($0.id) }
|
|
let index = min(location.index, remaining.count)
|
|
let next = index < remaining.count ? remaining[index].id : nil
|
|
reordering.move(dragSession.ids, location.section, next)
|
|
cancelDrag()
|
|
}
|
|
|
|
private func cancelDrag() {
|
|
dragSession = nil
|
|
omittedIDs.removeAll()
|
|
ghostLocation = nil
|
|
}
|
|
}
|
|
|
|
private struct ManagedCardDropDelegate<SectionID: Hashable>: DropDelegate {
|
|
let isActive: Bool
|
|
let location: (CGPoint) -> ManagedCardDropLocation<SectionID>?
|
|
@Binding var ghostLocation: ManagedCardDropLocation<SectionID>?
|
|
let perform: (ManagedCardDropLocation<SectionID>) -> Void
|
|
let cancel: () -> Void
|
|
|
|
func validateDrop(info: DropInfo) -> Bool {
|
|
isActive && info.hasItemsConforming(to: [.text])
|
|
}
|
|
|
|
func dropEntered(info: DropInfo) { ghostLocation = location(info.location) }
|
|
|
|
func dropUpdated(info: DropInfo) -> DropProposal? {
|
|
ghostLocation = location(info.location)
|
|
return DropProposal(operation: ghostLocation == nil ? .cancel : .move)
|
|
}
|
|
|
|
func dropExited(info: DropInfo) { ghostLocation = nil }
|
|
|
|
func performDrop(info: DropInfo) -> Bool {
|
|
guard let landed = location(info.location) else {
|
|
cancel()
|
|
return false
|
|
}
|
|
perform(landed)
|
|
return true
|
|
}
|
|
}
|