Compile, then decode through the module. Pass platform before arch, and default both to the host rather than darwin/arm64.
755 lines
26 KiB
Swift
755 lines
26 KiB
Swift
import LumaCore
|
|
import SwiftUI
|
|
|
|
struct PatternDecodeView: View {
|
|
let data: Data
|
|
let baseAddress: UInt64?
|
|
let sessionID: UUID
|
|
let engine: Engine
|
|
@Binding var placements: [PatternPlacement]
|
|
var sizing: PatternDecodeSizing = .capped
|
|
var onTruncated: (() -> Void)? = nil
|
|
|
|
@State private var summaries: [String: PatternSummary] = [:]
|
|
@State private var describeProblem: String?
|
|
@State private var results: [PatternPlacement.ID: PlacementResult] = [:]
|
|
@State private var locations: [UUID: PatternLocation] = [:]
|
|
@State private var annotations: [HexAnnotation] = []
|
|
@State private var caret = 0
|
|
@State private var selectedNodeID: UUID?
|
|
@State private var expanded: Set<UUID> = []
|
|
@State private var visualizations: [UUID: NodeVisualization] = [:]
|
|
@State private var openVisualizer: UUID?
|
|
@State private var callOutcome: CallOutcome?
|
|
@FocusState private var isTreeFocused: Bool
|
|
|
|
private var target: LumaCore.ProcessNode.ProcessInfo? {
|
|
engine.node(forSessionID: sessionID)?.processInfo
|
|
}
|
|
|
|
private var canDecode: Bool {
|
|
baseAddress != nil && target != nil && !engine.patterns.sources.isEmpty
|
|
}
|
|
|
|
private var describeKey: DescribeKey? {
|
|
target.map { DescribeKey(sources: engine.patterns.sources, platform: $0.platform, arch: $0.arch) }
|
|
}
|
|
|
|
private var decodeKey: DecodeKey {
|
|
DecodeKey(placements: placements, data: data)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
ViewThatFits(in: .horizontal) {
|
|
HStack(alignment: .top, spacing: 16) {
|
|
hexPane
|
|
if !placements.isEmpty {
|
|
treePane.frame(minWidth: 360, maxWidth: .infinity, alignment: .topLeading)
|
|
}
|
|
}
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
hexPane
|
|
if !placements.isEmpty {
|
|
treePane
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
if let describeProblem {
|
|
Text(describeProblem)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
.task(id: describeKey) {
|
|
await describeSources()
|
|
}
|
|
.task(id: decodeKey) {
|
|
await decodePlacements()
|
|
}
|
|
}
|
|
|
|
private var caretLabel: String {
|
|
String(format: "0x%llx", (baseAddress ?? 0) &+ UInt64(caret))
|
|
}
|
|
|
|
private var hexPane: some View {
|
|
ScrollViewReader { proxy in
|
|
DecodePane(sizing: sizing) {
|
|
hexView
|
|
.padding(.top, TreeMetrics.inset)
|
|
}
|
|
.onChange(of: caret) { _, caret in
|
|
proxy.scrollTo(caret / HexLayout.bytesPerRow)
|
|
}
|
|
.onChange(of: emphasis?.range.lowerBound) { _, start in
|
|
if let start {
|
|
proxy.scrollTo(start / HexLayout.bytesPerRow)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var hexView: some View {
|
|
HexView(
|
|
data: data,
|
|
baseAddress: baseAddress ?? 0,
|
|
annotations: annotations,
|
|
emphasis: emphasis,
|
|
onCaretMove: { index in
|
|
caret = index
|
|
selectNode(at: index)
|
|
}
|
|
) {
|
|
if canDecode {
|
|
Menu("Decode at \(caretLabel) as…") {
|
|
PlacementItems(sources: engine.patterns.sources, summaries: summaries, caret: caret, place: place)
|
|
}
|
|
}
|
|
}
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
}
|
|
|
|
private var emphasis: HexAnnotation? {
|
|
selectedNodeID.flatMap { locations[$0] }.map(\.annotation)
|
|
}
|
|
|
|
private var treePane: some View {
|
|
ScrollViewReader { proxy in
|
|
DecodePane(sizing: sizing) {
|
|
tree
|
|
}
|
|
.onChange(of: selectedNodeID) { _, id in
|
|
if let id {
|
|
proxy.scrollTo(id)
|
|
}
|
|
}
|
|
}
|
|
.focusable()
|
|
.focusEffectDisabled()
|
|
.focused($isTreeFocused)
|
|
.overlay {
|
|
if isTreeFocused {
|
|
RoundedRectangle(cornerRadius: 5)
|
|
.strokeBorder(Color.accentColor.opacity(0.5), lineWidth: 3)
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
.onKeyPress(keys: [.upArrow, .downArrow, .leftArrow, .rightArrow, .space, .return]) { press in
|
|
navigate(press.key)
|
|
}
|
|
}
|
|
|
|
private var tree: some View {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
ForEach(visibleRows) { row in
|
|
treeRow(row)
|
|
}
|
|
}
|
|
.font(.system(.caption, design: .monospaced))
|
|
.padding(TreeMetrics.inset)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func treeRow(_ row: TreeRow) -> some View {
|
|
switch row.kind {
|
|
case .placement(let placement):
|
|
PlacementRow(
|
|
id: placement.id,
|
|
title: title(of: placement),
|
|
address: (baseAddress ?? 0) &+ UInt64(placement.offset),
|
|
failure: failure(of: placement),
|
|
isExpanded: expanded.contains(row.id),
|
|
highlight: highlight(of: row.id),
|
|
actions: actions(for: row.id),
|
|
remove: { placements.removeAll { $0.id == placement.id } }
|
|
)
|
|
.equatable()
|
|
case .node(let node):
|
|
NodeRow(
|
|
node: node,
|
|
depth: row.depth,
|
|
isExpandable: row.isExpandable,
|
|
isExpanded: expanded.contains(row.id),
|
|
highlight: highlight(of: row.id),
|
|
color: locations[node.id]?.color,
|
|
visualization: visualizations[node.id],
|
|
isVisualizerOpen: openVisualizer == node.id,
|
|
callOutcome: callOutcome?.rowID == node.id ? callOutcome : nil,
|
|
actions: actions(for: row.id)
|
|
)
|
|
.equatable()
|
|
}
|
|
}
|
|
|
|
private func failure(of placement: PatternPlacement) -> String? {
|
|
guard case .failed(let message) = results[placement.id] else { return nil }
|
|
return message
|
|
}
|
|
|
|
private func highlight(of id: UUID) -> RowHighlight {
|
|
guard selectedNodeID == id else { return .none }
|
|
return isTreeFocused ? .focused : .unfocused
|
|
}
|
|
|
|
private func actions(for id: UUID) -> RowActions {
|
|
RowActions(
|
|
select: {
|
|
selectedNodeID = id
|
|
isTreeFocused = true
|
|
},
|
|
toggle: {
|
|
if expanded.contains(id) {
|
|
expanded.remove(id)
|
|
} else {
|
|
expanded.insert(id)
|
|
}
|
|
},
|
|
press: { press(id) },
|
|
showVisualizer: { shown in
|
|
if shown {
|
|
selectedNodeID = id
|
|
openVisualizer = id
|
|
} else if openVisualizer == id {
|
|
openVisualizer = nil
|
|
}
|
|
},
|
|
dismissCallOutcome: {
|
|
if callOutcome?.rowID == id {
|
|
callOutcome = nil
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private func navigate(_ key: KeyEquivalent) -> KeyPress.Result {
|
|
let rows = visibleRows
|
|
guard let current = rows.firstIndex(where: { $0.id == selectedNodeID }) else {
|
|
selectedNodeID = rows.first?.id
|
|
return rows.isEmpty ? .ignored : .handled
|
|
}
|
|
let row = rows[current]
|
|
let isExpanded = expanded.contains(row.id)
|
|
switch key {
|
|
case .upArrow:
|
|
selectedNodeID = rows[max(current - 1, 0)].id
|
|
case .downArrow:
|
|
selectedNodeID = rows[min(current + 1, rows.count - 1)].id
|
|
case .leftArrow:
|
|
if row.isExpandable && isExpanded {
|
|
expanded.remove(row.id)
|
|
} else if let parent = row.parent {
|
|
selectedNodeID = parent
|
|
}
|
|
case .rightArrow:
|
|
if row.isExpandable && !isExpanded {
|
|
expanded.insert(row.id)
|
|
} else if row.isExpandable {
|
|
selectedNodeID = rows[current + 1].id
|
|
}
|
|
default:
|
|
activate(row.id)
|
|
}
|
|
return .handled
|
|
}
|
|
|
|
private var visibleRows: [TreeRow] {
|
|
var rows: [TreeRow] = []
|
|
for placement in placements {
|
|
guard case .decoded(let root) = results[placement.id] else {
|
|
rows.append(TreeRow(id: placement.id, parent: nil, depth: 0, isExpandable: false, kind: .placement(placement)))
|
|
continue
|
|
}
|
|
let children = root.visibleChildren
|
|
rows.append(TreeRow(id: placement.id, parent: nil, depth: 0, isExpandable: !children.isEmpty, kind: .placement(placement)))
|
|
if expanded.contains(placement.id) {
|
|
appendRows(for: children, parent: placement.id, depth: 1, to: &rows)
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
private func appendRows(for nodes: [DecodedPattern], parent: UUID, depth: Int, to rows: inout [TreeRow]) {
|
|
for node in nodes {
|
|
let children = node.visibleChildren
|
|
rows.append(TreeRow(id: node.id, parent: parent, depth: depth, isExpandable: !children.isEmpty, kind: .node(node)))
|
|
if expanded.contains(node.id) {
|
|
appendRows(for: children, parent: node.id, depth: depth + 1, to: &rows)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func activate(_ id: UUID) {
|
|
guard let visualization = visualizations[id] else { return }
|
|
if case .ready(let ready) = visualization.outcome, visualization.presentation == .inline, ready.isInline {
|
|
press(id)
|
|
} else {
|
|
openVisualizer = openVisualizer == id ? nil : id
|
|
}
|
|
}
|
|
|
|
private func press(_ id: UUID) {
|
|
guard let visualization = visualizations[id], case .ready(.button(let function, _)) = visualization.outcome,
|
|
let baseAddress, let target, let source = engine.patterns.source(withID: visualization.placement.sourceID)
|
|
else { return }
|
|
let placement = visualization.placement
|
|
Task {
|
|
do {
|
|
let output = try await engine.patternDecoder.callFunction(
|
|
function, on: visualization.nodeID, of: source, typeName: placement.typeName, data: bytes(at: placement),
|
|
address: baseAddress &+ UInt64(placement.offset), platform: target.platform, arch: target.arch)
|
|
callOutcome = CallOutcome(rowID: id, text: output.isEmpty ? "No output." : output, isFailure: false)
|
|
} catch {
|
|
callOutcome = CallOutcome(rowID: id, text: error.localizedDescription, isFailure: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func title(of placement: PatternPlacement) -> String {
|
|
if summaries[placement.sourceID]?.rootType == placement.typeName,
|
|
let source = engine.patterns.source(withID: placement.sourceID)
|
|
{
|
|
return source.name
|
|
}
|
|
return placement.typeName
|
|
}
|
|
|
|
private func place(_ placement: PatternPlacement) {
|
|
placements.append(placement)
|
|
expanded.insert(placement.id)
|
|
}
|
|
|
|
private func selectNode(at index: Int) {
|
|
let hits = locations.values.filter { $0.range.contains(index) }
|
|
guard let innermost = hits.min(by: { ($0.range.count, -$0.ancestors.count) < ($1.range.count, -$1.ancestors.count) }) else {
|
|
return
|
|
}
|
|
selectedNodeID = innermost.node.id
|
|
expanded.formUnion(innermost.ancestors)
|
|
}
|
|
|
|
private func describeSources() async {
|
|
guard let target else { return }
|
|
var described: [String: PatternSummary] = [:]
|
|
for source in engine.patterns.sources {
|
|
do {
|
|
described[source.id] = try await engine.patternDecoder.summary(of: source, platform: target.platform, arch: target.arch)
|
|
} catch {
|
|
describeProblem = "\(source.name): \(error.localizedDescription)"
|
|
}
|
|
}
|
|
guard !Task.isCancelled else { return }
|
|
summaries = described
|
|
}
|
|
|
|
private func decodePlacements() async {
|
|
guard let baseAddress, let target else { return }
|
|
var results: [PatternPlacement.ID: PlacementResult] = [:]
|
|
for placement in placements {
|
|
guard let source = engine.patterns.source(withID: placement.sourceID) else { continue }
|
|
do {
|
|
let value = try await engine.patternDecoder.decode(
|
|
source, typeName: placement.typeName, data: bytes(at: placement), address: baseAddress &+ UInt64(placement.offset),
|
|
platform: target.platform, arch: target.arch)
|
|
results[placement.id] = .decoded(value)
|
|
} catch {
|
|
results[placement.id] = .failed(error.localizedDescription)
|
|
}
|
|
}
|
|
guard !Task.isCancelled else { return }
|
|
let remap = PatternNodeRemap(from: Self.roots(of: self.results), to: Self.roots(of: results), placements: Set(results.keys))
|
|
self.results = results
|
|
expanded = Set(expanded.compactMap(remap.callAsFunction))
|
|
selectedNodeID = selectedNodeID.flatMap(remap.callAsFunction)
|
|
openVisualizer = openVisualizer.flatMap(remap.callAsFunction)
|
|
callOutcome = nil
|
|
locations = locate(results, base: baseAddress)
|
|
annotations = locations.values.filter(\.isLeaf).map(\.annotation)
|
|
visualizations = visualize(results)
|
|
if results.values.contains(where: \.isTruncated) {
|
|
onTruncated?()
|
|
}
|
|
}
|
|
|
|
private func bytes(at placement: PatternPlacement) -> Data {
|
|
data.suffix(from: data.startIndex + min(placement.offset, data.count))
|
|
}
|
|
|
|
private static func roots(of results: [PatternPlacement.ID: PlacementResult]) -> [UUID: DecodedPattern] {
|
|
results.compactMapValues { result in
|
|
guard case .decoded(let root) = result else { return nil }
|
|
return root
|
|
}
|
|
}
|
|
|
|
private func visualize(_ results: [PatternPlacement.ID: PlacementResult]) -> [UUID: NodeVisualization] {
|
|
var visualized: [UUID: NodeVisualization] = [:]
|
|
for placement in placements {
|
|
guard case .decoded(let root) = results[placement.id] else { continue }
|
|
visualize(root, root: root, placement: placement, into: &visualized)
|
|
}
|
|
return visualized
|
|
}
|
|
|
|
private func visualize(
|
|
_ node: DecodedPattern, root: DecodedPattern, placement: PatternPlacement, into visualized: inout [UUID: NodeVisualization]
|
|
) {
|
|
if let visualizer = node.visualizer {
|
|
let outcome: NodeVisualization.Outcome
|
|
do {
|
|
outcome = .ready(try PatternVisualization(visualizer, of: node, in: root))
|
|
} catch {
|
|
outcome = .failed(error.localizedDescription)
|
|
}
|
|
visualized[node.id] = NodeVisualization(
|
|
placement: placement, nodeID: node.nodeID, presentation: visualizer.presentation, outcome: outcome)
|
|
}
|
|
for child in node.children {
|
|
visualize(child, root: root, placement: placement, into: &visualized)
|
|
}
|
|
}
|
|
|
|
private func locate(_ results: [PatternPlacement.ID: PlacementResult], base: UInt64) -> [UUID: PatternLocation] {
|
|
var located: [UUID: PatternLocation] = [:]
|
|
for placement in placements {
|
|
if case .decoded(let root) = results[placement.id] {
|
|
PatternLocation.locateFields(of: root, under: placement.id, base: base, into: &located)
|
|
}
|
|
}
|
|
return located
|
|
}
|
|
}
|
|
|
|
private struct PlacementItems: View {
|
|
let sources: [PatternSource]
|
|
let summaries: [String: PatternSummary]
|
|
let caret: Int
|
|
let place: (PatternPlacement) -> Void
|
|
|
|
var body: some View {
|
|
ForEach(sources) { source in
|
|
if let summary = summaries[source.id] {
|
|
Section(source.name) {
|
|
if let rootType = summary.rootType {
|
|
Button("Run file") {
|
|
place(PatternPlacement(sourceID: source.id, typeName: rootType, offset: 0))
|
|
}
|
|
}
|
|
ForEach(summary.decodableTypes) { type in
|
|
Button(type.name) {
|
|
place(PatternPlacement(sourceID: source.id, typeName: type.name, offset: caret))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct TreeRow: Identifiable {
|
|
let id: UUID
|
|
let parent: UUID?
|
|
let depth: Int
|
|
let isExpandable: Bool
|
|
let kind: Kind
|
|
|
|
enum Kind {
|
|
case placement(PatternPlacement)
|
|
case node(DecodedPattern)
|
|
}
|
|
}
|
|
|
|
private struct NodeVisualization {
|
|
let placement: PatternPlacement
|
|
let nodeID: UInt
|
|
let presentation: DecodedVisualizer.Presentation
|
|
let outcome: Outcome
|
|
|
|
enum Outcome {
|
|
case ready(PatternVisualization)
|
|
case failed(String)
|
|
}
|
|
}
|
|
|
|
private struct CallOutcome: Equatable {
|
|
let rowID: UUID
|
|
let text: String
|
|
let isFailure: Bool
|
|
}
|
|
|
|
enum PatternDecodeSizing {
|
|
case capped
|
|
case fill
|
|
}
|
|
|
|
private struct DecodePane<Content: View>: View {
|
|
let sizing: PatternDecodeSizing
|
|
@ViewBuilder let content: Content
|
|
|
|
@State private var contentHeight: CGFloat = 0
|
|
|
|
var body: some View {
|
|
switch sizing {
|
|
case .capped:
|
|
scrollView
|
|
.frame(height: min(contentHeight, TreeMetrics.cappedPaneHeight))
|
|
case .fill:
|
|
scrollView
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
}
|
|
}
|
|
|
|
private var scrollView: some View {
|
|
ScrollView(.vertical) {
|
|
content
|
|
.onGeometryChange(for: CGFloat.self, of: \.size.height) { contentHeight = $0 }
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum TreeMetrics {
|
|
static let inset: CGFloat = 4
|
|
static let cappedPaneHeight: CGFloat = 400
|
|
static let rowHeight = HexMetrics.regular.rowHeight
|
|
}
|
|
|
|
private enum RowHighlight {
|
|
case none
|
|
case unfocused
|
|
case focused
|
|
}
|
|
|
|
private struct RowActions {
|
|
let select: () -> Void
|
|
let toggle: () -> Void
|
|
let press: () -> Void
|
|
let showVisualizer: (Bool) -> Void
|
|
let dismissCallOutcome: () -> Void
|
|
}
|
|
|
|
private enum PlacementResult {
|
|
case decoded(DecodedPattern)
|
|
case failed(String)
|
|
|
|
var isTruncated: Bool {
|
|
guard case .decoded(let root) = self else { return false }
|
|
return root.truncated
|
|
}
|
|
}
|
|
|
|
private struct DescribeKey: Equatable {
|
|
let sources: [PatternSource]
|
|
let platform: String
|
|
let arch: String
|
|
}
|
|
|
|
private struct DecodeKey: Equatable {
|
|
let placements: [PatternPlacement]
|
|
let data: Data
|
|
}
|
|
|
|
private struct PlacementRow: View, Equatable {
|
|
let id: UUID
|
|
let title: String
|
|
let address: UInt64
|
|
let failure: String?
|
|
let isExpanded: Bool
|
|
let highlight: RowHighlight
|
|
let actions: RowActions
|
|
let remove: () -> Void
|
|
|
|
static func == (lhs: Self, rhs: Self) -> Bool {
|
|
lhs.id == rhs.id && lhs.title == rhs.title && lhs.address == rhs.address && lhs.failure == rhs.failure
|
|
&& lhs.isExpanded == rhs.isExpanded && lhs.highlight == rhs.highlight
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 6) {
|
|
DisclosureChevron(isExpanded: isExpanded, isVisible: failure == nil, toggle: actions.toggle)
|
|
Text(title)
|
|
.fontWeight(.semibold)
|
|
Text(String(format: "@ 0x%llx", address))
|
|
.foregroundStyle(.secondary)
|
|
if let failure {
|
|
Text(failure)
|
|
.foregroundStyle(.red)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.help(failure)
|
|
}
|
|
Spacer(minLength: 12)
|
|
Button(action: remove) {
|
|
Image(systemName: "xmark.circle")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.modifier(RowStyle(highlight: highlight, select: actions.select))
|
|
}
|
|
}
|
|
|
|
private struct NodeRow: View, Equatable {
|
|
let node: DecodedPattern
|
|
let depth: Int
|
|
let isExpandable: Bool
|
|
let isExpanded: Bool
|
|
let highlight: RowHighlight
|
|
let color: Color?
|
|
let visualization: NodeVisualization?
|
|
let isVisualizerOpen: Bool
|
|
let callOutcome: CallOutcome?
|
|
let actions: RowActions
|
|
|
|
static func == (lhs: Self, rhs: Self) -> Bool {
|
|
lhs.node.id == rhs.node.id && lhs.depth == rhs.depth && lhs.isExpandable == rhs.isExpandable
|
|
&& lhs.isExpanded == rhs.isExpanded && lhs.highlight == rhs.highlight && lhs.color == rhs.color
|
|
&& lhs.isVisualizerOpen == rhs.isVisualizerOpen && lhs.callOutcome == rhs.callOutcome
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 6) {
|
|
DisclosureChevron(isExpanded: isExpanded, isVisible: isExpandable, toggle: actions.toggle)
|
|
if let color {
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(color)
|
|
.frame(width: 8, height: 8)
|
|
}
|
|
Text(node.displayName ?? node.name)
|
|
.fontWeight(.semibold)
|
|
Text(node.typeName)
|
|
.foregroundStyle(.secondary)
|
|
Spacer(minLength: 12)
|
|
value
|
|
}
|
|
.padding(.leading, CGFloat(depth) * 14)
|
|
.modifier(RowStyle(highlight: highlight, select: actions.select))
|
|
.help(node.comment ?? String(format: "0x%llx", node.address))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var value: some View {
|
|
if let visualization {
|
|
if case .ready(let ready) = visualization.outcome, visualization.presentation == .inline, ready.isInline {
|
|
PatternInlineVisualizationView(visualization: ready, press: actions.press)
|
|
.popover(isPresented: callOutcomeShown, arrowEdge: .bottom) {
|
|
if let callOutcome {
|
|
Text(callOutcome.text)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(callOutcome.isFailure ? .red : .primary)
|
|
.textSelection(.enabled)
|
|
.padding(10)
|
|
}
|
|
}
|
|
} else {
|
|
summary
|
|
Button {
|
|
actions.showVisualizer(!isVisualizerOpen)
|
|
} label: {
|
|
Image(systemName: symbolName(of: visualization))
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.help("Visualize")
|
|
.popover(isPresented: visualizerShown, arrowEdge: .bottom) {
|
|
switch visualization.outcome {
|
|
case .ready(let ready):
|
|
PatternVisualizationView(visualization: ready)
|
|
case .failed(let message):
|
|
Text(message)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(.red)
|
|
.padding(10)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
summary
|
|
}
|
|
}
|
|
|
|
private var summary: some View {
|
|
Text(node.summary)
|
|
.foregroundStyle(node.truncated ? AnyShapeStyle(.red) : AnyShapeStyle(.foreground))
|
|
.lineLimit(1)
|
|
}
|
|
|
|
private func symbolName(of visualization: NodeVisualization) -> String {
|
|
guard case .ready(let ready) = visualization.outcome else { return "exclamationmark.triangle" }
|
|
return ready.symbolName
|
|
}
|
|
|
|
private var visualizerShown: Binding<Bool> {
|
|
Binding(get: { isVisualizerOpen }, set: actions.showVisualizer)
|
|
}
|
|
|
|
private var callOutcomeShown: Binding<Bool> {
|
|
Binding(get: { callOutcome != nil }, set: { if !$0 { actions.dismissCallOutcome() } })
|
|
}
|
|
}
|
|
|
|
private struct RowStyle: ViewModifier {
|
|
let highlight: RowHighlight
|
|
let select: () -> Void
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.frame(height: TreeMetrics.rowHeight)
|
|
.padding(.trailing, 2)
|
|
.foregroundStyle(highlight == .focused ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
|
|
.background(background, in: RoundedRectangle(cornerRadius: 3))
|
|
.contentShape(Rectangle())
|
|
.onTapGesture(perform: select)
|
|
}
|
|
|
|
private var background: Color {
|
|
switch highlight {
|
|
case .none:
|
|
.clear
|
|
case .unfocused:
|
|
Color.secondary.opacity(0.25)
|
|
case .focused:
|
|
.accentColor
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct DisclosureChevron: View {
|
|
let isExpanded: Bool
|
|
let isVisible: Bool
|
|
let toggle: () -> Void
|
|
|
|
var body: some View {
|
|
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 10)
|
|
.opacity(isVisible ? 1 : 0)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture(perform: toggle)
|
|
}
|
|
}
|
|
|
|
enum PatternPalette {
|
|
private static let colors: [Color] = [.blue, .green, .orange, .purple, .pink, .teal, .yellow, .red]
|
|
|
|
static func color(for tint: PatternTint) -> Color {
|
|
switch tint {
|
|
case .palette(let index):
|
|
return colors[index % colors.count]
|
|
case .rgb(let red, let green, let blue):
|
|
return Color(red: Double(red) / 255, green: Double(green) / 255, blue: Double(blue) / 255)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension PatternLocation {
|
|
var color: Color {
|
|
PatternPalette.color(for: tint)
|
|
}
|
|
|
|
var annotation: HexAnnotation {
|
|
HexAnnotation(id: node.id, range: range, color: color)
|
|
}
|
|
}
|