Files
luma/Luma/PatternSidebar.swift

284 lines
8.9 KiB
Swift

import Frida
import LumaCore
import SwiftUI
struct PatternsSidebarRows: View {
let engine: Engine
@Binding var selection: SidebarItemID?
@State private var outlines: [String: [PatternTypeSummary]] = [:]
@State private var expanded: Set<String> = []
@AppStorage("sidebar.patterns.expansion") private var expansion: SidebarExpansion = .expanded
private var sources: [PatternSource] { engine.patterns.sources }
var body: some View {
SidebarPatternsRow(count: sources.count, isExpanded: expansion == .expanded) {
expansion = expansion == .expanded ? .collapsed : .expanded
}
.tag(SidebarItemID.patterns)
ForEach(expansion == .expanded ? sources : []) { source in
let types = outlines[source.id] ?? []
let isExpanded = expanded.contains(source.id)
SidebarPatternRow(
source: source,
engine: engine,
selection: $selection,
hasTypes: !types.isEmpty,
isExpanded: isExpanded,
onToggleExpansion: { toggle(source.id) }
)
.tag(SidebarItemID.pattern(source.id))
if isExpanded {
PatternTypeSidebarChildren(source: source, types: types, selection: $selection)
}
}
.task(id: sources) {
await outline()
}
.onChange(of: selection, initial: true) {
if let selectedSource {
expansion = .expanded
expanded.insert(selectedSource)
}
}
}
private var selectedSource: String? {
switch selection {
case .pattern(let id), .patternType(let id, _):
return id
default:
return nil
}
}
private func toggle(_ sourceID: String) {
if expanded.contains(sourceID) {
expanded.remove(sourceID)
} else {
expanded.insert(sourceID)
}
}
private func outline() async {
var outlined: [String: [PatternTypeSummary]] = [:]
for source in sources {
guard let summary = try? await engine.patternDecoder.summary(of: source) else { continue }
outlined[source.id] = summary.declaredTypes
}
guard !Task.isCancelled else { return }
outlines = outlined
expanded.formIntersection(sources.map(\.id))
}
}
private struct PatternTypeSidebarChildren: View {
let source: PatternSource
let types: [PatternTypeSummary]
@Binding var selection: SidebarItemID?
var body: some View {
let highlights = types.sidebarHighlights(selectedID: selectedTypeName)
ForEach(highlights) { type in
SidebarFeatureRowLabel(
icon: { Image(systemName: type.kind.symbolName).font(.system(size: 11)) },
title: type.name,
help: type.kind.title
)
.tag(SidebarItemID.patternType(source.id, type.name))
}
if types.count > highlights.count {
SidebarBrowseAllRow(count: types.count) { dismiss in
SidebarBrowserPopover(
placeholder: "Filter types",
emptyMessage: "No matching types",
items: types,
groupName: { $0.kind.title },
title: { $0.name },
help: { $0.kind.title },
isDimmed: { _ in false },
matches: { type, query in type.name.localizedCaseInsensitiveContains(query) },
onChoose: { type in
selection = .patternType(source.id, type.name)
dismiss()
}
)
}
}
}
private var selectedTypeName: String? {
if case .patternType(let id, let name) = selection, id == source.id { return name }
return nil
}
}
private struct SidebarPatternsRow: View {
let count: Int
let isExpanded: Bool
let onToggle: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "square.stack.3d.up")
.frame(width: 18, alignment: .center)
Text("Patterns")
Spacer()
if count > 0 {
Text("\(count)").font(.caption).foregroundStyle(.secondary)
Button(action: onToggle) {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: sidebarChevronWidth, height: sidebarChevronWidth)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(isExpanded ? "Collapse patterns" : "Expand patterns")
}
}
.accessibilityElement(children: .combine)
.accessibilityIdentifier("sidebar.patterns")
}
}
private struct SidebarPatternRow: View {
let source: PatternSource
let engine: Engine
@Binding var selection: SidebarItemID?
let hasTypes: Bool
let isExpanded: Bool
let onToggleExpansion: () -> Void
@State private var isShowingRename = false
@State private var newName = ""
@State private var isShowingDeleteConfirmation = false
@State private var errorMessage: String?
var body: some View {
HStack(spacing: 0) {
SidebarDisclosure(isExpanded: isExpanded, canToggle: hasTypes, onToggle: onToggleExpansion)
Image(systemName: source.kind.symbolName)
.foregroundStyle(.secondary)
.frame(width: sidebarChildIconWidth)
.padding(.trailing, sidebarIconToLabelSpacing)
Text(source.name)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 0)
}
.accessibilityElement(children: .combine)
.accessibilityIdentifier("sidebar.pattern.\(source.id)")
.contextMenu {
Button {
newName = source.name
isShowingRename = true
} label: {
Label("Rename…", systemImage: "pencil")
}
Button(role: .destructive) {
isShowingDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
}
}
.alert("Rename \(source.kind.title)", isPresented: $isShowingRename) {
TextField("Name", text: $newName)
Button("Rename") { rename() }
Button("Cancel", role: .cancel) {}
}
.confirmationDialog("Delete \(source.name)?", isPresented: $isShowingDeleteConfirmation, titleVisibility: .visible) {
Button("Delete", role: .destructive) { delete() }
Button("Cancel", role: .cancel) {}
} message: {
Text("This removes \(source.id) from the pattern library.")
}
.alert("Pattern library error", isPresented: errorBinding, presenting: errorMessage) { _ in
Button("OK") { errorMessage = nil }
} message: { message in
Text(message)
}
}
private func rename() {
do {
let renamed = try engine.patterns.rename(source.id, to: newName)
if selection == .pattern(source.id) {
selection = .pattern(renamed.id)
}
} catch {
errorMessage = error.localizedDescription
}
}
private func delete() {
do {
try engine.patterns.delete(source.id)
if selection == .pattern(source.id) {
selection = .patterns
}
} catch {
errorMessage = error.localizedDescription
}
}
private var errorBinding: Binding<Bool> {
Binding(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)
}
}
extension PatternSource.Kind {
var title: String {
switch self {
case .pattern:
return "Pattern"
case .library:
return "Library"
}
}
var symbolName: String {
switch self {
case .pattern:
return "doc.text"
case .library:
return "books.vertical"
}
}
}
extension PatternTypeKind {
var title: String {
switch self {
case .struct:
return "Struct"
case .union:
return "Union"
case .enum:
return "Enum"
case .bitfield:
return "Bitfield"
case .alias:
return "Alias"
}
}
var symbolName: String {
switch self {
case .struct, .union:
return "curlybraces"
case .enum:
return "list.number"
case .bitfield:
return "01.square"
case .alias:
return "equal"
}
}
}