aboutsummaryrefslogtreecommitdiff
path: root/Sources/NorgUI
diff options
context:
space:
mode:
Diffstat (limited to 'Sources/NorgUI')
-rw-r--r--Sources/NorgUI/Math/MathBlockView.swift34
-rw-r--r--Sources/NorgUI/Math/NorgInlineText.swift52
-rw-r--r--Sources/NorgUI/Math/NorgMathRenderer.swift46
-rw-r--r--Sources/NorgUI/Math/SwiftMathRenderer.swift60
-rw-r--r--Sources/NorgUI/Theme/NorgTheme.swift17
-rw-r--r--Sources/NorgUI/Views/Blocks/ListItemView.swift2
-rw-r--r--Sources/NorgUI/Views/Blocks/ParagraphView.swift4
-rw-r--r--Sources/NorgUI/Views/Blocks/QuoteView.swift2
-rw-r--r--Sources/NorgUI/Views/Blocks/RangedTagView.swift8
9 files changed, 219 insertions, 6 deletions
diff --git a/Sources/NorgUI/Math/MathBlockView.swift b/Sources/NorgUI/Math/MathBlockView.swift
new file mode 100644
index 0000000..c29fdb6
--- /dev/null
+++ b/Sources/NorgUI/Math/MathBlockView.swift
@@ -0,0 +1,34 @@
+import SwiftUI
+
+/// Renders a `@math` block asynchronously with a fallback to `VerbatimBlock`.
+struct MathBlockView: View {
+ @Environment(\.norgTheme) private var theme
+
+ let latex: String
+
+ @State private var rendered: NorgMathImage?
+
+ var body: some View {
+ content
+ .task(id: renderKey) {
+ guard let renderer = theme.mathRenderer else { return }
+ rendered = await renderer.render(
+ latex: latex, mode: .block, fontSize: theme.mathFontSize, color: nil)
+ }
+ }
+
+ @ViewBuilder
+ private var content: some View {
+ if let rendered {
+ rendered.swiftUIImage
+ .frame(width: rendered.size.width, height: rendered.size.height, alignment: .leading)
+ .padding(theme.verbatimPadding)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(theme.codeBackground, in: RoundedRectangle(cornerRadius: 8))
+ } else {
+ VerbatimBlock(label: nil, content: latex)
+ }
+ }
+
+ private var renderKey: String { "\(latex)@\(theme.mathFontSize)" }
+}
diff --git a/Sources/NorgUI/Math/NorgInlineText.swift b/Sources/NorgUI/Math/NorgInlineText.swift
new file mode 100644
index 0000000..0d607f5
--- /dev/null
+++ b/Sources/NorgUI/Math/NorgInlineText.swift
@@ -0,0 +1,52 @@
+import NorgKit
+import SwiftUI
+
+/// Renders a run of inline spans as a single `Text`, splicing in rendered
+/// images for `.math` spans when a ``NorgMathRenderer`` is configured.
+struct NorgInlineText: View {
+ @Environment(\.norgTheme) private var theme
+
+ let spans: [InlineSpan]
+ var baseFont: Font = .body
+
+ @State private var images: [Int: NorgMathImage] = [:]
+
+ var body: some View {
+ text
+ .task(id: renderKey) { await renderMath() }
+ }
+
+ private var text: Text {
+ var result = Text(verbatim: "")
+ for (index, span) in spans.enumerated() {
+ if span.styles.contains(.math), let image = images[index] {
+ result = result + Text(image.swiftUIImage).baselineOffset(-image.descent)
+ } else {
+ result = result + Text(AttributedString(norg: [span], baseFont: baseFont, theme: theme))
+ }
+ }
+ return result
+ }
+
+ private var mathSpans: [(offset: Int, element: InlineSpan)] {
+ spans.enumerated().filter { $0.element.styles.contains(.math) }.map { ($0.offset, $0.element) }
+ }
+
+ private var renderKey: String {
+ mathSpans.map { "\($0.offset):\($0.element.text)" }.joined(separator: "|")
+ + "@\(theme.mathFontSize)"
+ }
+
+ @MainActor
+ private func renderMath() async {
+ guard let renderer = theme.mathRenderer, !mathSpans.isEmpty else { return }
+ for (index, span) in mathSpans where images[index] == nil {
+ if let image = await renderer.render(
+ latex: span.text, mode: .inline, fontSize: theme.mathFontSize, color: nil)
+ {
+ images[index] = image
+ }
+ await Task.yield()
+ }
+ }
+}
diff --git a/Sources/NorgUI/Math/NorgMathRenderer.swift b/Sources/NorgUI/Math/NorgMathRenderer.swift
new file mode 100644
index 0000000..4fcf60f
--- /dev/null
+++ b/Sources/NorgUI/Math/NorgMathRenderer.swift
@@ -0,0 +1,46 @@
+import SwiftUI
+
+#if canImport(UIKit)
+ import UIKit
+ public typealias PlatformImage = UIImage
+#elseif canImport(AppKit)
+ import AppKit
+ public typealias PlatformImage = NSImage
+#endif
+
+public enum NorgMathMode: Sendable {
+ case inline
+ case block
+}
+
+public struct NorgMathImage {
+ public let image: PlatformImage
+ public let size: CGSize
+ public let descent: CGFloat
+
+ public init(image: PlatformImage, size: CGSize, descent: CGFloat) {
+ self.image = image
+ self.size = size
+ self.descent = descent
+ }
+
+ public var swiftUIImage: Image {
+ #if canImport(UIKit)
+ Image(uiImage: image)
+ #elseif canImport(AppKit)
+ Image(nsImage: image)
+ #endif
+ }
+}
+
+/// Renders LaTeX-style math into an image for display.
+public protocol NorgMathRenderer: Sendable {
+ /// Render `latex` at the given point size and colour.
+ ///
+ /// - Returns: the rendered image, or `nil` if the input could not be
+ /// rendered (the caller then keeps its text fallback).
+ @MainActor
+ func render(
+ latex: String, mode: NorgMathMode, fontSize: CGFloat, color: Color?
+ ) async -> NorgMathImage?
+}
diff --git a/Sources/NorgUI/Math/SwiftMathRenderer.swift b/Sources/NorgUI/Math/SwiftMathRenderer.swift
new file mode 100644
index 0000000..6012a44
--- /dev/null
+++ b/Sources/NorgUI/Math/SwiftMathRenderer.swift
@@ -0,0 +1,60 @@
+#if SwiftMathSupport
+ import SwiftMath
+ import SwiftUI
+
+ /// ``NorgMathRenderer`` backed by SwiftMath. Enabled by the
+ /// `SwiftMathSupport` package trait.
+ struct SwiftMathRenderer: NorgMathRenderer {
+ @MainActor
+ func render(
+ latex: String, mode: NorgMathMode, fontSize: CGFloat, color: Color?
+ ) async -> NorgMathImage? {
+ let label = MTMathUILabel()
+ label.latex = latex
+ label.labelMode = mode == .block ? .display : .text
+ label.fontSize = mode == .block ? fontSize * 1.4 : fontSize
+ if let color {
+ #if canImport(UIKit)
+ label.textColor = UIColor(color)
+ #elseif canImport(AppKit)
+ label.textColor = NSColor(color)
+ #endif
+ }
+
+ let size = label.intrinsicContentSize
+ guard size.width > 0, size.height > 0, !latex.isEmpty else { return nil }
+ guard let image = rasterize(label, size: size) else { return nil }
+
+ // SwiftMath does not expose the layout descent publicly, so approximate
+ // it by centring the formula on the text line. Tune if inline math sits
+ // too high or low against surrounding text.
+ let descent = max(0, (size.height - fontSize) / 2)
+ return NorgMathImage(image: image, size: size, descent: descent)
+ }
+
+ @MainActor
+ private func rasterize(_ label: MTMathUILabel, size: CGSize) -> PlatformImage? {
+ label.frame = CGRect(origin: .zero, size: size)
+ #if canImport(UIKit)
+ label.layoutIfNeeded()
+ let format = UIGraphicsImageRendererFormat()
+ format.scale = UIScreen.main.scale
+ format.opaque = false
+ let renderer = UIGraphicsImageRenderer(size: size, format: format)
+ return renderer.image { context in
+ let cg = context.cgContext
+ cg.translateBy(x: 0, y: size.height)
+ cg.scaleBy(x: 1, y: -1)
+ label.layer.render(in: cg)
+ }
+ #elseif canImport(AppKit)
+ label.layoutSubtreeIfNeeded()
+ guard let rep = label.bitmapImageRepForCachingDisplay(in: label.bounds) else { return nil }
+ label.cacheDisplay(in: label.bounds, to: rep)
+ let image = NSImage(size: size)
+ image.addRepresentation(rep)
+ return image
+ #endif
+ }
+ }
+#endif
diff --git a/Sources/NorgUI/Theme/NorgTheme.swift b/Sources/NorgUI/Theme/NorgTheme.swift
index c788812..fa8ab25 100644
--- a/Sources/NorgUI/Theme/NorgTheme.swift
+++ b/Sources/NorgUI/Theme/NorgTheme.swift
@@ -40,6 +40,12 @@ public struct NorgTheme: Sendable {
public var blockSpacing: CGFloat
/// Padding applied above a heading.
public var headingTopPadding: CGFloat
+ /// Point size used when rendering math. Inline math is rendered at this size
+ /// and display blocks slightly larger.
+ public var mathFontSize: CGFloat
+ /// Renderer used for inline and block math. When `nil`, math is shown as
+ /// monospaced text. See ``NorgMathRenderer``.
+ public var mathRenderer: (any NorgMathRenderer)?
/// Tint for a task's status symbol.
public var statusTint: @Sendable (TaskStatus) -> Color
/// SF Symbol name for a task's status symbol.
@@ -68,6 +74,8 @@ public struct NorgTheme: Sendable {
verbatimPadding: CGFloat = 10,
blockSpacing: CGFloat = 10,
headingTopPadding: CGFloat = 4,
+ mathFontSize: CGFloat = 17,
+ mathRenderer: (any NorgMathRenderer)? = nil,
statusTint: @escaping @Sendable (TaskStatus) -> Color,
statusSymbol: @escaping @Sendable (TaskStatus) -> String,
statusLabel: @escaping @Sendable (TaskStatus) -> LocalizedStringResource = { $0.displayName }
@@ -90,6 +98,8 @@ public struct NorgTheme: Sendable {
self.verbatimPadding = verbatimPadding
self.blockSpacing = blockSpacing
self.headingTopPadding = headingTopPadding
+ self.mathFontSize = mathFontSize
+ self.mathRenderer = mathRenderer
self.statusTint = statusTint
self.statusSymbol = statusSymbol
self.statusLabel = statusLabel
@@ -120,6 +130,7 @@ public struct NorgTheme: Sendable {
codeBackground: AnyShapeStyle(.quaternary),
codeFont: .system(.callout, design: .monospaced),
indentWidth: 18,
+ mathRenderer: defaultMathRenderer,
statusTint: { status in
switch status {
case .done: return .green
@@ -141,4 +152,10 @@ public struct NorgTheme: Sendable {
}
}
)
+
+ #if SwiftMathSupport
+ static let defaultMathRenderer: (any NorgMathRenderer)? = SwiftMathRenderer()
+ #else
+ static let defaultMathRenderer: (any NorgMathRenderer)? = nil
+ #endif
}
diff --git a/Sources/NorgUI/Views/Blocks/ListItemView.swift b/Sources/NorgUI/Views/Blocks/ListItemView.swift
index 581974b..ffff2eb 100644
--- a/Sources/NorgUI/Views/Blocks/ListItemView.swift
+++ b/Sources/NorgUI/Views/Blocks/ListItemView.swift
@@ -20,7 +20,7 @@ struct ListItemView: View {
Text(marker)
.foregroundStyle(.secondary)
.monospacedDigit()
- Text(AttributedString(norg: content, theme: theme))
+ NorgInlineText(spans: content)
}
}
}
diff --git a/Sources/NorgUI/Views/Blocks/ParagraphView.swift b/Sources/NorgUI/Views/Blocks/ParagraphView.swift
index 2ad0dd5..d117ccc 100644
--- a/Sources/NorgUI/Views/Blocks/ParagraphView.swift
+++ b/Sources/NorgUI/Views/Blocks/ParagraphView.swift
@@ -2,11 +2,9 @@ import NorgKit
import SwiftUI
struct ParagraphView: View {
- @Environment(\.norgTheme) private var theme
-
let content: [InlineSpan]
var body: some View {
- Text(AttributedString(norg: content, theme: theme))
+ NorgInlineText(spans: content)
}
}
diff --git a/Sources/NorgUI/Views/Blocks/QuoteView.swift b/Sources/NorgUI/Views/Blocks/QuoteView.swift
index 0347d78..f5a6ec8 100644
--- a/Sources/NorgUI/Views/Blocks/QuoteView.swift
+++ b/Sources/NorgUI/Views/Blocks/QuoteView.swift
@@ -19,7 +19,7 @@ struct QuoteView: View {
status: status, content: content, font: theme.body, line: line, onSetStatus: onSetStatus
)
} else {
- Text(AttributedString(norg: content, theme: theme))
+ NorgInlineText(spans: content)
.foregroundStyle(theme.quoteColor)
.italic()
}
diff --git a/Sources/NorgUI/Views/Blocks/RangedTagView.swift b/Sources/NorgUI/Views/Blocks/RangedTagView.swift
index 1cdf1d9..04ec4d1 100644
--- a/Sources/NorgUI/Views/Blocks/RangedTagView.swift
+++ b/Sources/NorgUI/Views/Blocks/RangedTagView.swift
@@ -1,10 +1,16 @@
import SwiftUI
struct RangedTagView: View {
+ @Environment(\.norgTheme) private var theme
+
let name: [String]
let content: String
var body: some View {
- VerbatimBlock(label: name.joined(separator: "."), content: content)
+ if name == ["math"], theme.mathRenderer != nil {
+ MathBlockView(latex: content)
+ } else {
+ VerbatimBlock(label: name.joined(separator: "."), content: content)
+ }
}
}