PadXcode-iPad/Editor/GutterAnnotationView.swift
2026-04-12 22:07:35 -07:00

128 lines
4.8 KiB
Swift

import SwiftUI
import Runestone
// MARK: - Annotation Model
struct GutterAnnotation: Identifiable {
let id = UUID()
let line: Int
let severity: DiagnosticRange.Severity
let message: String
var color: Color { severity == .error ? Color(hex: "#F44747") : Color(hex: "#CCA700") }
var icon: String { severity == .error ? "xmark.circle.fill" : "exclamationmark.triangle.fill" }
}
// MARK: - Overlay
struct GutterAnnotationOverlay: View {
let diagnostics: [DiagnosticRange]
weak var textView: Runestone.TextView?
@State private var annotations: [PositionedAnnotation] = []
@State private var selected: GutterAnnotation?
struct PositionedAnnotation: Identifiable {
let id = UUID()
let annotation: GutterAnnotation
let y: CGFloat
}
var body: some View {
GeometryReader { _ in
ZStack(alignment: .topLeading) {
ForEach(annotations) { positioned in
annotationBadge(for: positioned)
}
}
}
.onAppear { computePositions() }
.onChange(of: diagnostics) { _, _ in computePositions() }
// Recompute when the text view scrolls so annotations track the correct line positions.
.onReceive(NotificationCenter.default.publisher(for: UIScrollView.didScrollNotification)) { note in
if (note.object as? UIScrollView) === textView { computePositions() }
}
.popover(item: $selected) { annotation in
DiagnosticPopover(annotation: annotation)
}
}
@ViewBuilder
private func annotationBadge(for positioned: PositionedAnnotation) -> some View {
HStack(spacing: 4) {
Image(systemName: positioned.annotation.icon)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(positioned.annotation.color)
Text(positioned.annotation.message)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(positioned.annotation.color)
.lineLimit(1)
}
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(positioned.annotation.color.opacity(0.12), in: RoundedRectangle(cornerRadius: 4))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(positioned.annotation.color.opacity(0.3), lineWidth: 0.5))
.position(x: 200, y: positioned.y)
.onTapGesture { selected = positioned.annotation }
.transition(.opacity)
}
private func computePositions() {
guard let tv = textView else { return }
var result: [PositionedAnnotation] = []
for diag in diagnostics {
let ann = GutterAnnotation(line: diag.line, severity: diag.severity, message: diag.message)
// caretRect(for:) on Runestone.TextView takes UITextPosition, not Int.
// Compute Y from font metrics instead reliable for a code editor
// where all lines have equal height.
let font = tv.theme.font
let lineHeight = ceil(font.lineHeight * tv.lineHeightMultiplier)
let y = CGFloat(ann.line - 1) * lineHeight
+ tv.textContainerInset.top
- tv.contentOffset.y
guard y > 0 && y < tv.bounds.height else { continue }
result.append(PositionedAnnotation(annotation: ann, y: y))
}
withAnimation(.easeInOut(duration: 0.15)) { annotations = result }
}
}
// MARK: - Diagnostic Popover
struct DiagnosticPopover: View {
let annotation: GutterAnnotation
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: annotation.icon).foregroundStyle(annotation.color)
Text(annotation.severity == .error ? "Error" : "Warning")
.font(.subheadline.bold()).foregroundStyle(annotation.color)
Spacer()
Text("Line \(annotation.line)").font(.caption).foregroundStyle(.secondary)
}
Divider()
Text(annotation.message)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
.padding(12)
.frame(minWidth: 280, maxWidth: 420)
.presentationCompactAdaptation(.popover)
}
}
// MARK: - Hex colour helper (private to this file)
private extension Color {
init(hex: String) {
var h = hex.trimmingCharacters(in: .whitespacesAndNewlines)
h = h.hasPrefix("#") ? String(h.dropFirst()) : h
var rgb: UInt64 = 0
Scanner(string: h).scanHexInt64(&rgb)
self.init(
red: Double((rgb >> 16) & 0xFF) / 255,
green: Double((rgb >> 8) & 0xFF) / 255,
blue: Double( rgb & 0xFF) / 255
)
}
}