221 lines
7.3 KiB
Swift
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...]))
|
|
}
|
|
}
|