835 lines
29 KiB
Swift
835 lines
29 KiB
Swift
import SwiftUI
|
|
|
|
enum MDSegment {
|
|
case text(AttributedString)
|
|
case image(ImageSource, src: String)
|
|
}
|
|
|
|
enum AppLink {
|
|
static func task(_ id: String, tab: String? = nil) -> URL {
|
|
route("#/task/\(id)\(tab.map { "/\($0)" } ?? "")")
|
|
}
|
|
|
|
static func route(_ hash: String) -> URL {
|
|
var c = URLComponents()
|
|
c.scheme = "byre-app"
|
|
c.host = "route"
|
|
c.queryItems = [URLQueryItem(name: "hash", value: hash)]
|
|
return c.url!
|
|
}
|
|
|
|
static func todo(_ projectId: String, _ title: String, discuss: Bool) -> URL {
|
|
var c = URLComponents()
|
|
c.scheme = "byre-app"
|
|
c.host = "todo"
|
|
c.queryItems = [URLQueryItem(name: "project", value: projectId), URLQueryItem(name: "title", value: title), URLQueryItem(name: "discuss", value: discuss ? "1" : "0")]
|
|
return c.url!
|
|
}
|
|
|
|
static func suggest(_ projectId: String, _ title: String, _ brief: String, discuss: Bool) -> URL {
|
|
var c = URLComponents()
|
|
c.scheme = "byre-app"
|
|
c.host = "suggest"
|
|
c.queryItems = [URLQueryItem(name: "project", value: projectId), URLQueryItem(name: "title", value: title), URLQueryItem(name: "brief", value: brief), URLQueryItem(name: "discuss", value: discuss ? "1" : "0")]
|
|
return c.url!
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAction.Result {
|
|
guard url.scheme == "byre-app", let c = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
|
return .systemAction
|
|
}
|
|
let q = Dictionary((c.queryItems ?? []).map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { a, _ in a })
|
|
switch c.host {
|
|
case "route":
|
|
store.route(q["hash"] ?? "")
|
|
case "todo":
|
|
store.startTodo(q["project"] ?? "", q["title"] ?? "", discuss: q["discuss"] == "1", overlays)
|
|
case "suggest":
|
|
if q["discuss"] == "1" {
|
|
store.discussSuggestion(q["project"] ?? "", q["title"] ?? "", brief: q["brief"] ?? "", overlays)
|
|
} else {
|
|
store.startSuggestion(q["project"] ?? "", q["title"] ?? "", brief: q["brief"] ?? "", overlays)
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
return .handled
|
|
}
|
|
|
|
private struct ChoiceTapKey: EnvironmentKey {
|
|
static let defaultValue: (@MainActor @Sendable () -> Void)? = nil
|
|
}
|
|
|
|
private struct CenterImagesKey: EnvironmentKey {
|
|
static let defaultValue = false
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
var choiceTap: (@MainActor @Sendable () -> Void)? {
|
|
get {
|
|
self[ChoiceTapKey.self]
|
|
}
|
|
set {
|
|
self[ChoiceTapKey.self] = newValue
|
|
}
|
|
}
|
|
|
|
var centerImages: Bool {
|
|
get {
|
|
self[CenterImagesKey.self]
|
|
}
|
|
set {
|
|
self[CenterImagesKey.self] = newValue
|
|
}
|
|
}
|
|
}
|
|
|
|
extension MDMark {
|
|
var color: Color {
|
|
switch self {
|
|
case .ok: Palette.ok
|
|
case .warn: Palette.warn
|
|
case .danger: Palette.danger
|
|
case .muted: Palette.muted
|
|
}
|
|
}
|
|
|
|
var tint: Color {
|
|
switch self {
|
|
case .ok: Palette.okTint
|
|
case .warn: Palette.warnTint
|
|
case .danger: Palette.dangerTint
|
|
case .muted: Palette.surface
|
|
}
|
|
}
|
|
}
|
|
|
|
private let httpLink = Re(#"^https?://"#, caseless: true)
|
|
private let taskHref = Re(#"^#/task/(\w+)(/\w+)?$"#)
|
|
private let todoHref = Re(#"^#/todo/(\w+)/(.+)$"#)
|
|
private let suggestHref = Re(#"^#/suggest/(\w+)/([^/]+)/(.+)$"#)
|
|
|
|
@MainActor
|
|
struct InlineRenderer {
|
|
let store: Store
|
|
let size: CGFloat
|
|
let taskId: String?
|
|
var weight: CGFloat = 400
|
|
|
|
private struct Style {
|
|
var bold = false
|
|
var italic = false
|
|
var link: URL?
|
|
var color: Color?
|
|
var small = false
|
|
var tip: String?
|
|
}
|
|
|
|
func segments(_ items: [MDInline]) -> [MDSegment] {
|
|
var out: [MDSegment] = []
|
|
render(items, Style(), &out)
|
|
return out
|
|
}
|
|
|
|
func text(_ items: [MDInline]) -> AttributedString {
|
|
var out = AttributedString()
|
|
for seg in segments(items) {
|
|
if case .text(let a) = seg {
|
|
out += a
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
private func run(_ s: String, _ style: Style) -> AttributedString {
|
|
var a = AttributedString(s)
|
|
a.font = Fonts.font(style.small ? 11 : size, weight: style.bold ? 700 : weight, italic: style.italic)
|
|
a.mergeAttributes(try! AttributeContainer([.font: Fonts.ns(style.small ? 11 : size, weight: style.bold ? 700 : weight, italic: style.italic)], including: \.appKit))
|
|
if let link = style.link {
|
|
a.link = link
|
|
}
|
|
if let color = style.color {
|
|
a.foregroundColor = color
|
|
a.appKit.foregroundColor = NSColor(color)
|
|
}
|
|
if let tip = style.tip {
|
|
a.appKit.toolTip = tip
|
|
}
|
|
return a
|
|
}
|
|
|
|
private func append(_ a: AttributedString, _ out: inout [MDSegment]) {
|
|
if case .text(let prev) = out.last {
|
|
out[out.count - 1] = .text(prev + a)
|
|
return
|
|
}
|
|
out.append(.text(a))
|
|
}
|
|
|
|
private func render(_ items: [MDInline], _ style: Style, _ out: inout [MDSegment]) {
|
|
for item in items {
|
|
switch item {
|
|
case .text(let t):
|
|
append(run(t, style), &out)
|
|
case .lineBreak:
|
|
append(run("\n", style), &out)
|
|
case .code(let c):
|
|
var a = run(c, style)
|
|
a.backgroundColor = Palette.surface
|
|
a.appKit.backgroundColor = NSColor(Palette.surface)
|
|
append(a, &out)
|
|
case .strong(let inner):
|
|
var s = style
|
|
s.bold = true
|
|
render(inner, s, &out)
|
|
case .em(let inner):
|
|
var s = style
|
|
s.italic = true
|
|
render(inner, s, &out)
|
|
case .image(let src, let alt):
|
|
if let source = worktreeImageSource(src, taskId) {
|
|
out.append(.image(source, src: src))
|
|
} else {
|
|
link(src, [.text(alt.isEmpty ? src : alt)], style, &out)
|
|
}
|
|
case .link(let href, let inner):
|
|
link(href, inner, style, &out)
|
|
case .mark(let mark, let inner):
|
|
var s = style
|
|
s.color = mark.color
|
|
render(inner, s, &out)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func link(_ href: String, _ inner: [MDInline], _ style: Style, _ out: inout [MDSegment]) {
|
|
var s = style
|
|
if httpLink.test(href), let url = URL(string: href) {
|
|
s.link = url
|
|
s.color = Palette.accent
|
|
render(inner, s, &out)
|
|
return
|
|
}
|
|
if let m = taskHref.exec(href) {
|
|
s.link = AppLink.route(href)
|
|
s.color = store.taskColor(m[1]!) ?? Palette.accent
|
|
render(inner, s, &out)
|
|
return
|
|
}
|
|
if let m = todoHref.exec(href), let title = m[2]!.removingPercentEncoding {
|
|
todo(m[1]!, title, nil, inner, style, &out)
|
|
return
|
|
}
|
|
if let m = suggestHref.exec(href), let title = m[2]!.removingPercentEncoding, let brief = m[3]!.removingPercentEncoding {
|
|
todo(m[1]!, title, brief, inner, style, &out)
|
|
return
|
|
}
|
|
if href.hasPrefix("#/") {
|
|
s.link = AppLink.route(href)
|
|
s.color = Palette.accent
|
|
render(inner, s, &out)
|
|
return
|
|
}
|
|
render(inner, style, &out)
|
|
}
|
|
|
|
private func todo(_ projectId: String, _ title: String, _ brief: String?, _ inner: [MDInline], _ style: Style, _ out: inout [MDSegment]) {
|
|
let task = (store.tasks[projectId] ?? []).first { $0.status == .active && (brief == nil ? $0.todoItem == title : $0.title == title) }
|
|
var s = style
|
|
if let task {
|
|
s.color = taskPalette[task.color]
|
|
render(inner, s, &out)
|
|
append(run(" ", style), &out)
|
|
let state = store.currentSession(task.id)?.state
|
|
var dot = Style(link: AppLink.task(task.id), color: state == .running ? Palette.ok : Palette.muted, small: true, tip: state?.rawValue ?? "no session")
|
|
append(run(state == .running ? "●" : "○", dot), &out)
|
|
dot.color = Palette.muted
|
|
dot.tip = nil
|
|
append(run(" \(state?.rawValue ?? "no agent")", dot), &out)
|
|
return
|
|
}
|
|
render(inner, style, &out)
|
|
append(run(" ", style), &out)
|
|
let busy = store.todoBusy["\(projectId)\n\(title)"]
|
|
func action(_ discuss: Bool, _ label: String, _ busyLabel: String, _ tip: String) {
|
|
let url = brief.map { AppLink.suggest(projectId, title, $0, discuss: discuss) } ?? AppLink.todo(projectId, title, discuss: discuss)
|
|
let mine = busy == (discuss ? "discuss" : "start")
|
|
let a = Style(link: busy == nil ? url : nil, color: busy == nil ? Palette.accent : Palette.muted, small: true, tip: tip)
|
|
append(run(mine ? busyLabel : label, a), &out)
|
|
}
|
|
if brief != nil {
|
|
action(false, "start", "starting…", "Start a task with the lead's brief")
|
|
append(run(" ", style), &out)
|
|
action(true, "discuss", "sending…", "Ask your lead to talk it over in chat")
|
|
return
|
|
}
|
|
action(false, "start", "starting…", "Start a task that gets straight to work")
|
|
append(run(" ", style), &out)
|
|
action(true, "discuss", "starting…", "Start a task that discusses it with you in chat before building anything")
|
|
}
|
|
}
|
|
|
|
private func flowColor(_ s: NSMutableAttributedString, _ color: NSColor) {
|
|
let all = NSRange(location: 0, length: s.length)
|
|
unsafe s.enumerateAttribute(.foregroundColor, in: all) { value, range, _ in
|
|
if value == nil {
|
|
s.addAttribute(.foregroundColor, value: color, range: range)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MDContext {
|
|
var size: CGFloat = 13
|
|
var color: Color = Palette.text
|
|
var taskId: String?
|
|
var choice: ((MDItem, Bool) -> AnyView)?
|
|
var gridColumns = 4
|
|
var weight: CGFloat = 400
|
|
}
|
|
|
|
private enum MDRun {
|
|
case flow([MDBlock])
|
|
case block(MDBlock)
|
|
}
|
|
|
|
struct MarkdownView: View {
|
|
let blocks: [MDBlock]
|
|
var ctx = MDContext()
|
|
@Environment(Store.self) private var store
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
ForEach(Array(runs.enumerated()), id: \.offset) { _, run in
|
|
switch run {
|
|
case .flow(let blocks):
|
|
FlowText(text: flow(blocks))
|
|
case .block(let block):
|
|
BlockView(block: block, ctx: ctx)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private func flow(_ blocks: [MDBlock]) -> NSAttributedString {
|
|
var builder = FlowBuilder(store: store, ctx: ctx)
|
|
return builder.build(blocks)
|
|
}
|
|
|
|
private var runs: [MDRun] {
|
|
var out: [MDRun] = []
|
|
for block in blocks {
|
|
if !flows(block) {
|
|
out.append(.block(block))
|
|
continue
|
|
}
|
|
if case .flow(let prev) = out.last {
|
|
out[out.count - 1] = .flow(prev + [block])
|
|
continue
|
|
}
|
|
out.append(.flow([block]))
|
|
}
|
|
return out
|
|
}
|
|
|
|
private func flows(_ block: MDBlock) -> Bool {
|
|
switch block {
|
|
case .paragraph(let inlines), .heading(_, let inlines):
|
|
return !hasImage(inlines)
|
|
case .list(_, _, let items, _):
|
|
if ctx.choice != nil && items.contains(where: { $0.choice != nil }) {
|
|
return false
|
|
}
|
|
return items.allSatisfy { !hasImage($0.inline) && $0.blocks.allSatisfy(flows) }
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func hasImage(_ inlines: [MDInline]) -> Bool {
|
|
inlines.contains { item in
|
|
switch item {
|
|
case .image:
|
|
true
|
|
case .strong(let inner), .em(let inner), .link(_, let inner), .mark(_, let inner):
|
|
hasImage(inner)
|
|
default:
|
|
false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private struct FlowBuilder {
|
|
let store: Store
|
|
let ctx: MDContext
|
|
private var out = NSMutableAttributedString()
|
|
private var gap: CGFloat = 0
|
|
|
|
init(store: Store, ctx: MDContext) {
|
|
self.store = store
|
|
self.ctx = ctx
|
|
}
|
|
|
|
mutating func build(_ blocks: [MDBlock]) -> NSAttributedString {
|
|
self.blocks(blocks, 0)
|
|
if out.length > 0 {
|
|
out.deleteCharacters(in: NSRange(location: out.length - 1, length: 1))
|
|
}
|
|
return out
|
|
}
|
|
|
|
private mutating func blocks(_ blocks: [MDBlock], _ indent: CGFloat) {
|
|
for (i, block) in blocks.enumerated() {
|
|
if i > 0 {
|
|
gap = 8
|
|
}
|
|
self.block(block, indent)
|
|
}
|
|
}
|
|
|
|
private mutating func block(_ block: MDBlock, _ indent: CGFloat) {
|
|
switch block {
|
|
case .paragraph(let inlines):
|
|
para(inline(inlines, ctx.size), indent, indent)
|
|
case .heading(let level, let inlines):
|
|
gap += 4
|
|
para(inline([.strong(inlines)], level == 3 ? 14 : 13), indent, indent)
|
|
case .list(let ordered, let start, let items, _):
|
|
let font = Fonts.ns(ctx.size)
|
|
for (n, item) in items.enumerated() {
|
|
if n > 0 {
|
|
gap = 2
|
|
}
|
|
let marker = ordered ? "\(start + n)." : "•"
|
|
let inner = indent + ceil((marker as NSString).size(withAttributes: [.font: font]).width) + 6
|
|
let line = NSMutableAttributedString(string: "\(marker)\t", attributes: [.font: font])
|
|
line.append(inline(item.inline, ctx.size))
|
|
para(line, indent, inner)
|
|
gap = 6
|
|
blocks(item.blocks, inner)
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func inline(_ inlines: [MDInline], _ size: CGFloat) -> NSAttributedString {
|
|
let a = InlineRenderer(store: store, size: size, taskId: ctx.taskId, weight: ctx.weight).text(inlines)
|
|
return try! NSAttributedString(a, including: \.appKit)
|
|
}
|
|
|
|
private mutating func para(_ a: NSAttributedString, _ first: CGFloat, _ head: CGFloat) {
|
|
let s = NSMutableAttributedString(attributedString: a)
|
|
s.append(NSAttributedString(string: "\n", attributes: [.font: Fonts.ns(ctx.size)]))
|
|
let all = NSRange(location: 0, length: s.length)
|
|
flowColor(s, NSColor(ctx.color))
|
|
let lead = (s.string as NSString).range(of: "\n").location + 1
|
|
s.addAttribute(.paragraphStyle, value: style(first, head, 0), range: all)
|
|
s.addAttribute(.paragraphStyle, value: style(first, head, gap), range: NSRange(location: 0, length: lead))
|
|
out.append(s)
|
|
gap = 0
|
|
}
|
|
|
|
private func style(_ first: CGFloat, _ head: CGFloat, _ before: CGFloat) -> NSParagraphStyle {
|
|
let p = NSMutableParagraphStyle()
|
|
p.lineSpacing = 3
|
|
p.paragraphSpacingBefore = before
|
|
p.firstLineHeadIndent = first
|
|
p.headIndent = head
|
|
p.tabStops = [NSTextTab(textAlignment: .left, location: head)]
|
|
return p
|
|
}
|
|
}
|
|
|
|
private final class FlowTextView: NSTextView {
|
|
var onTap: (@MainActor @Sendable () -> Void)?
|
|
|
|
override func resignFirstResponder() -> Bool {
|
|
setSelectedRange(NSRange(location: 0, length: 0))
|
|
return super.resignFirstResponder()
|
|
}
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
let onLink = link(at: convert(event.locationInWindow, from: nil))
|
|
super.mouseDown(with: event)
|
|
if let onTap, !onLink, selectedRange().length == 0 {
|
|
onTap()
|
|
}
|
|
}
|
|
|
|
private func link(at point: NSPoint) -> Bool {
|
|
guard let lm = unsafe layoutManager, let tc = unsafe textContainer, let ts = unsafe textStorage, ts.length > 0 else {
|
|
return false
|
|
}
|
|
let p = NSPoint(x: point.x - textContainerOrigin.x, y: point.y - textContainerOrigin.y)
|
|
let g = lm.glyphIndex(for: p, in: tc)
|
|
if !lm.boundingRect(forGlyphRange: NSRange(location: g, length: 1), in: tc).contains(p) {
|
|
return false
|
|
}
|
|
let i = lm.characterIndexForGlyph(at: g)
|
|
if i >= ts.length {
|
|
return false
|
|
}
|
|
let value = unsafe ts.attribute(.link, at: i, effectiveRange: nil)
|
|
return value != nil
|
|
}
|
|
}
|
|
|
|
private struct FlowText: NSViewRepresentable {
|
|
let text: NSAttributedString
|
|
|
|
func makeNSView(context: Context) -> FlowTextView {
|
|
let v = FlowTextView(usingTextLayoutManager: false)
|
|
v.isEditable = false
|
|
v.isSelectable = true
|
|
v.drawsBackground = false
|
|
v.isVerticallyResizable = false
|
|
v.isHorizontallyResizable = false
|
|
v.textContainerInset = .zero
|
|
unsafe v.textContainer!.lineFragmentPadding = 0
|
|
unsafe v.textContainer!.widthTracksTextView = true
|
|
v.linkTextAttributes = [.cursor: NSCursor.pointingHand]
|
|
v.delegate = context.coordinator
|
|
return v
|
|
}
|
|
|
|
func updateNSView(_ v: FlowTextView, context: Context) {
|
|
context.coordinator.openURL = context.environment.openURL
|
|
v.onTap = context.environment.choiceTap
|
|
if unsafe v.textStorage!.isEqual(to: text) {
|
|
return
|
|
}
|
|
let selected = v.selectedRanges
|
|
unsafe v.textStorage!.setAttributedString(text)
|
|
if selected.allSatisfy({ NSMaxRange($0.rangeValue) <= text.length }) {
|
|
v.selectedRanges = selected
|
|
}
|
|
}
|
|
|
|
func sizeThatFits(_ proposal: ProposedViewSize, nsView v: FlowTextView, context: Context) -> CGSize? {
|
|
context.coordinator.size(text, proposal.width.flatMap { $0.isFinite ? $0 : nil })
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator()
|
|
}
|
|
|
|
final class Coordinator: NSObject, NSTextViewDelegate {
|
|
var openURL: OpenURLAction?
|
|
private let storage = NSTextStorage()
|
|
private let layout = NSLayoutManager()
|
|
private let container = NSTextContainer()
|
|
private var measured: (text: NSAttributedString, width: CGFloat?, size: CGSize)?
|
|
|
|
override init() {
|
|
super.init()
|
|
container.lineFragmentPadding = 0
|
|
layout.addTextContainer(container)
|
|
storage.addLayoutManager(layout)
|
|
}
|
|
|
|
func size(_ text: NSAttributedString, _ width: CGFloat?) -> CGSize {
|
|
let same = measured.map { $0.text === text || $0.text.isEqual(to: text) } ?? false
|
|
if same, let m = measured, m.width == width {
|
|
return m.size
|
|
}
|
|
if !same {
|
|
storage.setAttributedString(text)
|
|
}
|
|
container.size = CGSize(width: width ?? .greatestFiniteMagnitude, height: .greatestFiniteMagnitude)
|
|
layout.ensureLayout(for: container)
|
|
let used = layout.usedRect(for: container)
|
|
let size = CGSize(width: width ?? ceil(used.width), height: ceil(used.height))
|
|
measured = (text, width, size)
|
|
return size
|
|
}
|
|
|
|
func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
|
|
guard let url = link as? URL, let openURL else {
|
|
return false
|
|
}
|
|
openURL(url)
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
struct InlineView: View {
|
|
let inlines: [MDInline]
|
|
let ctx: MDContext
|
|
@Environment(Store.self) private var store
|
|
@Environment(\.choiceTap) private var choiceTap
|
|
@Environment(\.centerImages) private var centerImages
|
|
|
|
var body: some View {
|
|
let segs = InlineRenderer(store: store, size: ctx.size, taskId: ctx.taskId, weight: ctx.weight).segments(inlines)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(Array(segs.enumerated()), id: \.offset) { _, seg in
|
|
switch seg {
|
|
case .text(let a):
|
|
if choiceTap != nil {
|
|
FlowText(text: flowString(a))
|
|
} else {
|
|
Text(a)
|
|
.font(Fonts.font(ctx.size))
|
|
.foregroundStyle(ctx.color)
|
|
.lineSpacing(3)
|
|
.textSelection(.enabled)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
case .image(let source, let src):
|
|
if centerImages {
|
|
ByreImage(source: source, src: src)
|
|
.frame(maxWidth: .infinity)
|
|
} else {
|
|
ByreImage(source: source, src: src)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func flowString(_ a: AttributedString) -> NSAttributedString {
|
|
let s = NSMutableAttributedString(attributedString: try! NSAttributedString(a, including: \.appKit))
|
|
flowColor(s, NSColor(ctx.color))
|
|
let p = NSMutableParagraphStyle()
|
|
p.lineSpacing = 3
|
|
s.addAttribute(.paragraphStyle, value: p, range: NSRange(location: 0, length: s.length))
|
|
return s
|
|
}
|
|
}
|
|
|
|
private struct BlockView: View {
|
|
let block: MDBlock
|
|
let ctx: MDContext
|
|
|
|
var body: some View {
|
|
switch block {
|
|
case .paragraph(let inlines):
|
|
InlineView(inlines: inlines, ctx: ctx)
|
|
case .heading(let level, let inlines):
|
|
InlineView(inlines: [.strong(inlines)], ctx: MDContext(size: level == 3 ? 14 : 13, color: ctx.color, taskId: ctx.taskId))
|
|
.padding(.top, 4)
|
|
case .rule:
|
|
Rectangle()
|
|
.fill(Palette.border)
|
|
.frame(height: 1)
|
|
.padding(.vertical, 2)
|
|
case .quote(let blocks):
|
|
MarkdownView(blocks: blocks, ctx: MDContext(size: ctx.size, color: Palette.muted, taskId: ctx.taskId))
|
|
.padding(.leading, 13)
|
|
.overlay(alignment: .leading) {
|
|
Rectangle().fill(Palette.border).frame(width: 3)
|
|
}
|
|
case .list(let ordered, let start, let items, let grid):
|
|
ListBlock(ordered: ordered, start: start, items: items, grid: grid, ctx: ctx)
|
|
case .code(let code):
|
|
Copyable(text: code) {
|
|
ScrollView(.horizontal) {
|
|
Text(verbatim: code)
|
|
.font(Fonts.font(ctx.size))
|
|
.foregroundStyle(Palette.text)
|
|
.textSelection(.enabled)
|
|
.fixedSize()
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 6)
|
|
}
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Palette.surface, in: RoundedRectangle(cornerRadius: 6))
|
|
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
|
|
}
|
|
case .diff(let lines):
|
|
DiffBlock(lines: lines, size: ctx.size)
|
|
case .details(let summary, let body):
|
|
DetailsBlock(summary: summary, blocks: body, ctx: ctx)
|
|
case .diagram(let source):
|
|
DiagramView(source: source)
|
|
case .table(let head, let rows):
|
|
TableBlock(head: head, rows: rows, ctx: ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ListBlock: View {
|
|
let ordered: Bool
|
|
let start: Int
|
|
let items: [MDItem]
|
|
let grid: Bool
|
|
let ctx: MDContext
|
|
|
|
var body: some View {
|
|
if grid, let choice = ctx.choice {
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8, alignment: .top), count: ctx.gridColumns), spacing: 8) {
|
|
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
|
|
choice(item, true)
|
|
}
|
|
}
|
|
.environment(\.centerImages, true)
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
ForEach(Array(items.enumerated()), id: \.offset) { n, item in
|
|
if item.choice != nil, let choice = ctx.choice {
|
|
choice(item, n == 0)
|
|
} else {
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Text(verbatim: ordered ? "\(start + n)." : "•")
|
|
.font(Fonts.font(ctx.size))
|
|
.foregroundStyle(ctx.color)
|
|
ItemBody(item: item, ctx: ctx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ItemBody: View {
|
|
let item: MDItem
|
|
let ctx: MDContext
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
if !item.inline.isEmpty {
|
|
InlineView(inlines: item.inline, ctx: ctx)
|
|
}
|
|
if !item.blocks.isEmpty {
|
|
MarkdownView(blocks: item.blocks, ctx: ctx)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
private struct DiffBlock: View {
|
|
let lines: [String]
|
|
let size: CGFloat
|
|
@ViewState private var width: CGFloat = 0
|
|
|
|
var body: some View {
|
|
Copyable(text: lines.map { $0 + "\n" }.joined()) {
|
|
ScrollView(.horizontal) {
|
|
StretchColumn(minWidth: width) {
|
|
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
|
Text(verbatim: line.isEmpty ? " " : line)
|
|
.font(Fonts.font(size))
|
|
.foregroundStyle(line.hasPrefix("@@") || line.hasPrefix("+++") || line.hasPrefix("---") ? Palette.muted : Palette.text)
|
|
.textSelection(.enabled)
|
|
.fixedSize()
|
|
.padding(.horizontal, 8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(background(line))
|
|
}
|
|
}
|
|
.padding(.vertical, 6)
|
|
}
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 }
|
|
.background(Palette.surface, in: RoundedRectangle(cornerRadius: 6))
|
|
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
|
|
}
|
|
}
|
|
|
|
private func background(_ l: String) -> Color {
|
|
if l.hasPrefix("@@") {
|
|
return Palette.surface
|
|
}
|
|
if l.hasPrefix("+++") || l.hasPrefix("---") {
|
|
return .clear
|
|
}
|
|
if l.hasPrefix("+") {
|
|
return Palette.ok.opacity(0.14)
|
|
}
|
|
if l.hasPrefix("-") {
|
|
return Palette.danger.opacity(0.14)
|
|
}
|
|
return .clear
|
|
}
|
|
}
|
|
|
|
private struct DetailsBlock: View {
|
|
let summary: [MDInline]
|
|
let blocks: [MDBlock]
|
|
let ctx: MDContext
|
|
@ViewState private var open = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Button {
|
|
open.toggle()
|
|
} label: {
|
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
|
Text(open ? "▾" : "▸")
|
|
.foregroundStyle(Palette.accent)
|
|
InlineView(inlines: summary, ctx: MDContext(size: ctx.size, color: Palette.accent, taskId: ctx.taskId))
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
if open {
|
|
MarkdownView(blocks: blocks, ctx: ctx)
|
|
.padding(.leading, 11)
|
|
.overlay(alignment: .leading) {
|
|
Rectangle().fill(Palette.border).frame(width: 1)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
|
|
private struct TableBlock: View {
|
|
let head: [[MDInline]]
|
|
let rows: [[[MDInline]]]
|
|
let ctx: MDContext
|
|
|
|
var body: some View {
|
|
ViewThatFits(in: .horizontal) {
|
|
grid.fixedSize(horizontal: true, vertical: false)
|
|
grid
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private var grid: some View {
|
|
Grid(alignment: .topLeading, horizontalSpacing: 0, verticalSpacing: 0) {
|
|
GridRow {
|
|
ForEach(Array(head.enumerated()), id: \.offset) { _, cell in
|
|
cellView([.strong(cell)])
|
|
.background(tint(cell) ?? Palette.surface)
|
|
}
|
|
}
|
|
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
|
GridRow {
|
|
ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
|
|
cellView(cell)
|
|
.background(tint(cell) ?? .clear)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.overlay(Rectangle().strokeBorder(Palette.border))
|
|
}
|
|
|
|
private func tint(_ cell: [MDInline]) -> Color? {
|
|
guard case .mark(let mark, _) = cell.first else {
|
|
return nil
|
|
}
|
|
return mark.tint
|
|
}
|
|
|
|
private func cellView(_ cell: [MDInline]) -> some View {
|
|
InlineView(inlines: cell, ctx: ctx)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.overlay(Rectangle().stroke(Palette.border, lineWidth: 0.5))
|
|
}
|
|
}
|