diff --git a/GiteaPR/Detail/PRDetailView.swift b/GiteaPR/Detail/PRDetailView.swift index b69067f..69310d6 100644 --- a/GiteaPR/Detail/PRDetailView.swift +++ b/GiteaPR/Detail/PRDetailView.swift @@ -195,8 +195,8 @@ struct PRDetailView: View { DragGesture(minimumDistance: 20) .onEnded { value in let tabs = DetailTab.allCases - guard let current = selectedTab ?? .overview, - let idx = tabs.firstIndex(of: current) else { return } + let current = selectedTab ?? .overview + guard let idx = tabs.firstIndex(of: current) else { return } if value.translation.width < -40, idx < tabs.count - 1 { withAnimation { selectedTab = tabs[idx + 1] } } else if value.translation.width > 40, idx > 0 { diff --git a/GiteaPR/Detail/Tabs.swift b/GiteaPR/Detail/Tabs.swift index f665dce..8fc8b0e 100644 --- a/GiteaPR/Detail/Tabs.swift +++ b/GiteaPR/Detail/Tabs.swift @@ -179,49 +179,75 @@ struct MarkdownText: View { self.markdown = markdown } + /// One markdown source line with its render type resolved upfront, so the + /// code-block state doesn't have to be mutated inside the ViewBuilder. + private struct Line: Identifiable { + enum Kind { case code, heading, bullet, text, blank } + let id = UUID() + let kind: Kind + let text: String + let level: Int // heading level + } + + private var lines: [Line] { + let raw = markdown.components(separatedBy: "\n") + var out: [Line] = [] + var inCodeBlock = false + for line in raw { + if line.hasPrefix("```") { + inCodeBlock.toggle() + continue + } + if inCodeBlock { + out.append(Line(kind: .code, text: line, level: 0)) + } else if line.hasPrefix("# ") || line.hasPrefix("## ") || line.hasPrefix("### ") { + let level = line.prefix(while: { $0 == "#" }).count + out.append(Line(kind: .heading, + text: String(line.dropFirst(level)).trimmingCharacters(in: .whitespaces), + level: level)) + } else if line.hasPrefix("- ") || line.hasPrefix("* ") { + out.append(Line(kind: .bullet, + text: String(line.dropFirst(2)), + level: 0)) + } else if line.trimmingCharacters(in: .whitespaces).isEmpty { + out.append(Line(kind: .blank, text: "", level: 0)) + } else { + out.append(Line(kind: .text, text: line, level: 0)) + } + } + return out + } + var body: some View { VStack(alignment: .leading, spacing: 6) { - let lines = markdown.components(separatedBy: "\n") - var inCodeBlock = false - ForEach(Array(lines.enumerated()), id: \.offset) { _, line in - if line.hasPrefix("```") { - inCodeBlock.toggle() - } else if inCodeBlock { - Text(line) + ForEach(lines) { line in + switch line.kind { + case .code: + Text(line.text) .font(.caption.monospaced()) .frame(maxWidth: .infinity, alignment: .leading) .padding(4) .background(Color(.secondarySystemBackground)) - } else if line.hasPrefix("# ") || line.hasPrefix("## ") || line.hasPrefix("### ") { - heading(line) - } else if line.hasPrefix("- ") || line.hasPrefix("* ") { - bullet(line) - } else if line.trimmingCharacters(in: .whitespaces).isEmpty { + case .heading: + Text(line.text) + .font(line.level == 1 ? .title2.bold() : line.level == 2 ? .title3.bold() : .headline) + .frame(maxWidth: .infinity, alignment: .leading) + case .bullet: + HStack(alignment: .top, spacing: 6) { + Text("•") + inline(line.text) + } + .frame(maxWidth: .infinity, alignment: .leading) + case .blank: Spacer(minLength: 4) - } else { - inline(line) + case .text: + inline(line.text) } } } .frame(maxWidth: .infinity, alignment: .leading) } - private func heading(_ line: String) -> some View { - let level = line.prefix(while: { $0 == "#" }).count - let text = line.dropFirst(level).trimmingCharacters(in: .whitespaces) - return Text(text) - .font(level == 1 ? .title2.bold() : level == 2 ? .title3.bold() : .headline) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private func bullet(_ line: String) -> some View { - HStack(alignment: .top, spacing: 6) { - Text("•") - inline(String(line.dropFirst(2))) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - private func inline(_ text: String) -> some View { if let attributed = try? AttributedString(markdown: text) { Text(attributed)