Files
2026-09-08 20:06:58 +01:00

221 lines
7.3 KiB
Swift

import SwiftUI
struct HelpView: View {
@Environment(\.dismiss) private var dismiss
@State private var blocks: [HelpBlock] = [.paragraph("Loading help…")]
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
Image(systemName: "questionmark.circle.fill")
.font(.title2)
.foregroundStyle(Color.accentColor)
VStack(alignment: .leading, spacing: 1) {
Text("Rsync Studio Help")
.font(.headline)
Text("Instructions, examples and troubleshooting")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.cancelAction)
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background(.bar)
Divider()
ScrollView {
LazyVStack(alignment: .leading, spacing: 14) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
blockView(block)
}
}
.textSelection(.enabled)
.frame(maxWidth: 900, alignment: .leading)
.padding(.horizontal, 34)
.padding(.vertical, 28)
.frame(maxWidth: .infinity, alignment: .center)
}
}
.frame(minWidth: 780, idealWidth: 940, minHeight: 640, idealHeight: 780)
.task { loadHelp() }
}
@ViewBuilder
private func blockView(_ block: HelpBlock) -> some View {
switch block {
case .heading(let level, let text):
Text(text)
.font(headingFont(level))
.padding(.top, level == 1 ? 4 : 10)
.frame(maxWidth: .infinity, alignment: .leading)
case .paragraph(let text):
Text(markdown(text))
.font(.body)
.lineSpacing(4)
.frame(maxWidth: .infinity, alignment: .leading)
case .bullet(let text):
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text("•")
.font(.body.bold())
Text(markdown(text))
.font(.body)
.lineSpacing(3)
}
.padding(.leading, 6)
case .numbered(let number, let text):
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text("\(number).")
.font(.body.weight(.semibold))
.frame(width: 28, alignment: .trailing)
Text(markdown(text))
.font(.body)
.lineSpacing(3)
}
case .code(let text):
ScrollView(.horizontal) {
Text(text)
.font(.system(.body, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
}
.background(Color(nsColor: .textBackgroundColor).opacity(0.55), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(.quaternary, lineWidth: 1)
}
}
}
private func headingFont(_ level: Int) -> Font {
switch level {
case 1: return .largeTitle.bold()
case 2: return .title2.bold()
case 3: return .title3.weight(.semibold)
default: return .headline
}
}
private func markdown(_ text: String) -> AttributedString {
(try? AttributedString(
markdown: text,
options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
)) ?? AttributedString(text)
}
private func loadHelp() {
guard let url = Bundle.main.url(forResource: "Rsync Studio Help", withExtension: "md"),
let text = try? String(contentsOf: url, encoding: .utf8) else {
blocks = [.heading(1, "Help unavailable"), .paragraph("The bundled help document could not be loaded.")]
return
}
blocks = HelpMarkdownParser.parse(text)
}
}
private enum HelpBlock {
case heading(Int, String)
case paragraph(String)
case bullet(String)
case numbered(Int, String)
case code(String)
}
private enum HelpMarkdownParser {
static func parse(_ source: String) -> [HelpBlock] {
var result: [HelpBlock] = []
var paragraph: [String] = []
var codeLines: [String] = []
var insideCode = false
func flushParagraph() {
guard !paragraph.isEmpty else { return }
result.append(.paragraph(paragraph.joined(separator: " ")))
paragraph.removeAll(keepingCapacity: true)
}
func flushCode() {
guard !codeLines.isEmpty else { return }
result.append(.code(codeLines.joined(separator: "\n")))
codeLines.removeAll(keepingCapacity: true)
}
for rawLine in source.components(separatedBy: .newlines) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("```") {
if insideCode {
flushCode()
insideCode = false
} else {
flushParagraph()
insideCode = true
}
continue
}
if insideCode {
codeLines.append(rawLine)
continue
}
if line.isEmpty {
flushParagraph()
continue
}
if let heading = heading(from: line) {
flushParagraph()
result.append(.heading(heading.level, heading.text))
continue
}
if line.hasPrefix("- ") || line.hasPrefix("* ") {
flushParagraph()
result.append(.bullet(String(line.dropFirst(2))))
continue
}
if let numbered = numberedItem(from: line) {
flushParagraph()
result.append(.numbered(numbered.number, numbered.text))
continue
}
paragraph.append(line)
}
flushParagraph()
flushCode()
return result
}
private static func heading(from line: String) -> (level: Int, text: String)? {
let hashes = line.prefix { $0 == "#" }.count
guard hashes > 0, hashes <= 6 else { return nil }
let remainder = line.dropFirst(hashes)
guard remainder.first == " " else { return nil }
return (hashes, String(remainder.dropFirst()))
}
private static func numberedItem(from line: String) -> (number: Int, text: String)? {
guard let dot = line.firstIndex(of: ".") else { return nil }
let numberText = line[..<dot]
guard let number = Int(numberText) else { return nil }
let afterDot = line.index(after: dot)
guard afterDot < line.endIndex, line[afterDot] == " " else { return nil }
let textStart = line.index(after: afterDot)
return (number, String(line[textStart...]))
}
}