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[..