import AppKit 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) } .overlay { if isVisibleInUI { // Observe left-clicks without intercepting them so markdown text // selection and link activation continue to use the native path. MarkdownPointerObserver(onPointerDown: 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(String(localized: "markdown.fileUnavailable.title", defaultValue: "File unavailable")) .font(.headline) .foregroundColor(.primary) Text(panel.filePath) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.secondary) .multilineTextAlignment(.center) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 24) Text(String(localized: "markdown.fileUnavailable.message", defaultValue: "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) } } } private struct MarkdownPointerObserver: NSViewRepresentable { let onPointerDown: () -> Void func makeNSView(context: Context) -> MarkdownPanelPointerObserverView { let view = MarkdownPanelPointerObserverView() view.onPointerDown = onPointerDown return view } func updateNSView(_ nsView: MarkdownPanelPointerObserverView, context: Context) { nsView.onPointerDown = onPointerDown } } final class MarkdownPanelPointerObserverView: NSView { var onPointerDown: (() -> Void)? private var eventMonitor: Any? private weak var forwardedMouseTarget: NSView? override var mouseDownCanMoveWindow: Bool { false } override init(frame frameRect: NSRect) { super.init(frame: frameRect) installEventMonitorIfNeeded() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } } override func hitTest(_ point: NSPoint) -> NSView? { guard PaneFirstClickFocusSettings.isEnabled(), window?.isKeyWindow != true, bounds.contains(point) else { return nil } return self } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { PaneFirstClickFocusSettings.isEnabled() } override func mouseDown(with event: NSEvent) { onPointerDown?() forwardedMouseTarget = forwardedTarget(for: event) forwardedMouseTarget?.mouseDown(with: event) } override func mouseDragged(with event: NSEvent) { forwardedMouseTarget?.mouseDragged(with: event) } override func mouseUp(with event: NSEvent) { forwardedMouseTarget?.mouseUp(with: event) forwardedMouseTarget = nil } func shouldHandle(_ event: NSEvent) -> Bool { guard event.type == .leftMouseDown, let window, event.window === window, !isHiddenOrHasHiddenAncestor else { return false } if PaneFirstClickFocusSettings.isEnabled(), window.isKeyWindow != true { return false } let point = convert(event.locationInWindow, from: nil) return bounds.contains(point) } func handleEventIfNeeded(_ event: NSEvent) -> NSEvent { guard shouldHandle(event) else { return event } DispatchQueue.main.async { [weak self] in self?.onPointerDown?() } return event } private func installEventMonitorIfNeeded() { guard eventMonitor == nil else { return } eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in self?.handleEventIfNeeded(event) ?? event } } private func forwardedTarget(for event: NSEvent) -> NSView? { guard let window else { #if DEBUG NSLog("MarkdownPanelPointerObserverView.forwardedTarget skipped, window=0 contentView=0") #endif return nil } guard let contentView = window.contentView else { #if DEBUG NSLog("MarkdownPanelPointerObserverView.forwardedTarget skipped, window=1 contentView=0") #endif return nil } isHidden = true defer { isHidden = false } let point = contentView.convert(event.locationInWindow, from: nil) let target = contentView.hitTest(point) return target === self ? nil : target } }