import SwiftUI import MarkdownUI /// SwiftUI view that renders a MarkdownPanel's content using MarkdownUI. struct MarkdownPanelView: View { @ObservedObject var panel: MarkdownPanel let isFocused: Bool let isVisibleInUI: Bool let portalPriority: Int let onRequestPanelFocus: () -> Void @State private var focusFlashOpacity: Double = 0.0 @State private var focusFlashAnimationGeneration: Int = 0 @Environment(\.colorScheme) private var colorScheme var body: some View { Group { if panel.isFileUnavailable { fileUnavailableView } else { markdownContentView } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(backgroundColor) .overlay { RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius) .stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3) .shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10) .padding(FocusFlashPattern.ringInset) .allowsHitTesting(false) } .contentShape(Rectangle()) .onTapGesture { onRequestPanelFocus() } .onChange(of: panel.focusFlashToken) { _ in triggerFocusFlashAnimation() } } // MARK: - Content private var markdownContentView: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { // File path breadcrumb filePathHeader .padding(.horizontal, 24) .padding(.top, 16) .padding(.bottom, 8) Divider() .padding(.horizontal, 16) // Rendered markdown Markdown(panel.content) .markdownTheme(cmuxMarkdownTheme) .textSelection(.enabled) .padding(.horizontal, 24) .padding(.vertical, 16) } } } private var filePathHeader: some View { HStack(spacing: 6) { Image(systemName: "doc.richtext") .foregroundColor(.secondary) .font(.system(size: 12)) Text(panel.filePath) .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.middle) Spacer() } } private var fileUnavailableView: some View { VStack(spacing: 12) { Image(systemName: "doc.questionmark") .font(.system(size: 40)) .foregroundColor(.secondary) Text("File unavailable") .font(.headline) .foregroundColor(.primary) Text(panel.filePath) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.secondary) .lineLimit(2) .multilineTextAlignment(.center) Text("The file may have been moved or deleted.") .font(.caption) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } // MARK: - Theme private var backgroundColor: Color { colorScheme == .dark ? Color(nsColor: NSColor(white: 0.12, alpha: 1.0)) : Color(nsColor: NSColor(white: 0.98, alpha: 1.0)) } private var cmuxMarkdownTheme: Theme { let isDark = colorScheme == .dark return Theme() // Text .text { ForegroundColor(isDark ? .white.opacity(0.9) : .primary) FontSize(14) } // Headings .heading1 { configuration in VStack(alignment: .leading, spacing: 8) { configuration.label .markdownTextStyle { FontWeight(.bold) FontSize(28) ForegroundColor(isDark ? .white : .primary) } Divider() } .markdownMargin(top: 24, bottom: 16) } .heading2 { configuration in VStack(alignment: .leading, spacing: 6) { configuration.label .markdownTextStyle { FontWeight(.bold) FontSize(22) ForegroundColor(isDark ? .white : .primary) } Divider() } .markdownMargin(top: 20, bottom: 12) } .heading3 { configuration in configuration.label .markdownTextStyle { FontWeight(.semibold) FontSize(18) ForegroundColor(isDark ? .white : .primary) } .markdownMargin(top: 16, bottom: 8) } .heading4 { configuration in configuration.label .markdownTextStyle { FontWeight(.semibold) FontSize(16) ForegroundColor(isDark ? .white : .primary) } .markdownMargin(top: 12, bottom: 6) } .heading5 { configuration in configuration.label .markdownTextStyle { FontWeight(.medium) FontSize(14) ForegroundColor(isDark ? .white : .primary) } .markdownMargin(top: 10, bottom: 4) } .heading6 { configuration in configuration.label .markdownTextStyle { FontWeight(.medium) FontSize(13) ForegroundColor(isDark ? .white.opacity(0.7) : .secondary) } .markdownMargin(top: 8, bottom: 4) } // Code blocks .codeBlock { configuration in ScrollView(.horizontal, showsIndicators: true) { configuration.label .markdownTextStyle { FontFamilyVariant(.monospaced) FontSize(13) ForegroundColor(isDark ? Color(red: 0.9, green: 0.9, blue: 0.9) : Color(red: 0.2, green: 0.2, blue: 0.2)) } .padding(12) } .background(isDark ? Color(nsColor: NSColor(white: 0.08, alpha: 1.0)) : Color(nsColor: NSColor(white: 0.93, alpha: 1.0))) .clipShape(RoundedRectangle(cornerRadius: 6)) .markdownMargin(top: 8, bottom: 8) } // Inline code .code { FontFamilyVariant(.monospaced) FontSize(13) ForegroundColor(isDark ? Color(red: 0.85, green: 0.6, blue: 0.95) : Color(red: 0.6, green: 0.2, blue: 0.7)) BackgroundColor(isDark ? Color(nsColor: NSColor(white: 0.18, alpha: 1.0)) : Color(nsColor: NSColor(white: 0.92, alpha: 1.0))) } // Block quotes .blockquote { configuration in HStack(spacing: 0) { RoundedRectangle(cornerRadius: 1.5) .fill(isDark ? Color.white.opacity(0.2) : Color.gray.opacity(0.4)) .frame(width: 3) configuration.label .markdownTextStyle { ForegroundColor(isDark ? .white.opacity(0.6) : .secondary) FontSize(14) } .padding(.leading, 12) } .markdownMargin(top: 8, bottom: 8) } // Links .link { ForegroundColor(Color.accentColor) } // Strong .strong { FontWeight(.semibold) } // Tables .table { configuration in configuration.label .markdownTableBorderStyle(.init(color: isDark ? .white.opacity(0.15) : .gray.opacity(0.3))) .markdownTableBackgroundStyle( .alternatingRows( isDark ? Color(nsColor: NSColor(white: 0.14, alpha: 1.0)) : Color(nsColor: NSColor(white: 0.96, alpha: 1.0)), isDark ? Color(nsColor: NSColor(white: 0.10, alpha: 1.0)) : Color(nsColor: NSColor(white: 1.0, alpha: 1.0)) ) ) .markdownMargin(top: 8, bottom: 8) } // Thematic break (horizontal rule) .thematicBreak { Divider() .markdownMargin(top: 16, bottom: 16) } // List items .listItem { configuration in configuration.label .markdownMargin(top: 4, bottom: 4) } // Paragraphs .paragraph { configuration in configuration.label .markdownMargin(top: 4, bottom: 8) } } // MARK: - Focus Flash private func triggerFocusFlashAnimation() { focusFlashAnimationGeneration &+= 1 let generation = focusFlashAnimationGeneration focusFlashOpacity = FocusFlashPattern.values.first ?? 0 for segment in FocusFlashPattern.segments { DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) { guard focusFlashAnimationGeneration == generation else { return } withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) { focusFlashOpacity = segment.targetOpacity } } } } private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation { switch curve { case .easeIn: return .easeIn(duration: duration) case .easeOut: return .easeOut(duration: duration) } } }