// GutterAnnotationView.swift // DiagnosticRange is defined in Shared/SharedModels.swift // No Identifiable conformance extension needed here. import SwiftUI import Runestone // MARK: - Annotation Model (local to this file — wraps DiagnosticRange with position) 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() } .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) guard let range = tv.range(atLine: ann.line, column: 0), let rect = tv.caretRect(for: range.location) else { continue } let y = rect.midY - tv.contentOffset.y if y > 0 && y < tv.bounds.height { 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 (local) 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 ) } }