cmux/Sources/Panels/MarkdownPanelView.swift
Lawrence Chen e04787789e
Fix markdown panel text click focus (#991)
* Add markdown click regression test

* Fix markdown panel click focus

* Preserve markdown text selection

* Make markdown observer tests deterministic
2026-03-05 22:38:10 -08:00

353 lines
12 KiB
Swift

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("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)
}
}
}
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?
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? {
nil
}
func shouldHandle(_ event: NSEvent) -> Bool {
guard event.type == .leftMouseDown,
let window,
event.window === window,
!isHiddenOrHasHiddenAncestor else { 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
}
}
}