2026-04-12 00:46:30 -07:00
|
|
|
import SwiftUI
|
|
|
|
|
import Runestone
|
|
|
|
|
|
2026-04-12 22:07:35 -07:00
|
|
|
// MARK: - Annotation Model
|
2026-04-12 00:46:30 -07:00
|
|
|
|
|
|
|
|
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() }
|
2026-04-12 22:07:35 -07:00
|
|
|
// 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() }
|
|
|
|
|
}
|
2026-04-12 00:46:30 -07:00
|
|
|
.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)
|
2026-04-12 22:07:35 -07:00
|
|
|
// 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))
|
2026-04-12 00:46:30 -07:00
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 22:07:35 -07:00
|
|
|
// MARK: - Hex colour helper (private to this file)
|
|
|
|
|
|
2026-04-12 00:46:30 -07:00
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|