chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app draws Markdown's tables
ios/Knotless/Support/Markdown.swift+65−1
| @@ -16,6 +16,7 @@ struct MarkdownView: View { | ||
case quote(String) | ||
case code(String) | ||
case rule | ||
case table(MarkdownTable) | ||
} | ||
| ||
var body: some View { | ||
| @@ -49,9 +50,57 @@ struct MarkdownView: View { | ||
.background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: Radius.medium)) | ||
case .rule: | ||
Divider() | ||
case .table(let table): | ||
self.table(table) | ||
} | ||
} | ||
| ||
/// As wide as its cells, scrolling sideways if that's wider than the page, as | ||
/// a block of code does. A long cell wraps, so that one doesn't make it all wide. | ||
private func table(_ table: MarkdownTable) -> some View { | ||
ScrollView(.horizontal, showsIndicators: false) { | ||
Grid(alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0) { | ||
GridRow { | ||
ForEach(Array(table.header.enumerated()), id: \.offset) { at, cell in | ||
// On one line, so that each is as tall and the fill runs along the row. | ||
self.cell(cell, table.alignments[at], wraps: false).fontWeight(.semibold).background(Color(.tertiarySystemFill)) | ||
} | ||
} | ||
ForEach(Array(table.rows.enumerated()), id: \.offset) { _, row in | ||
Divider().gridCellUnsizedAxes(.horizontal) | ||
GridRow { | ||
ForEach(Array(row.enumerated()), id: \.offset) { at, cell in | ||
self.cell(cell, table.alignments[at]) | ||
} | ||
} | ||
} | ||
} | ||
.overlay(RoundedRectangle(cornerRadius: Radius.medium).stroke(Color(.separator))) | ||
.clipShape(RoundedRectangle(cornerRadius: Radius.medium)) | ||
// Room for its border, which the scroll view would cut. | ||
.padding(Space.s0_5) | ||
} | ||
} | ||
| ||
private func cell(_ text: String, _ alignment: MarkdownTable.Alignment, wraps: Bool = true) -> some View { | ||
let (frame, line): (SwiftUI.Alignment, TextAlignment) = switch alignment { | ||
case .leading: (.leading, .leading) | ||
case .center: (.center, .center) | ||
case .trailing: (.trailing, .trailing) | ||
} | ||
// A width of its own for one that wraps, as a grid measures a cell's height | ||
// before any limit on its width, which would leave its row a line tall. | ||
let wide = wraps && Self.plain(text).count > Size.tableCellLetters | ||
return rich(text) | ||
.font(.subheadline) | ||
.multilineTextAlignment(line) | ||
.frame(width: wide ? Size.tableCell : nil, alignment: frame) | ||
.fixedSize(horizontal: false, vertical: true) | ||
.padding(.horizontal, Space.s2_5) | ||
.padding(.vertical, Space.s2) | ||
.frame(maxWidth: .infinity, alignment: frame) | ||
} | ||
| ||
private static func painted(_ text: String, _ ranges: String?) -> AttributedString { | ||
var whole = AttributedString() | ||
for (at, line) in Highlight.paint(lines: text.components(separatedBy: "\n"), ranges: ranges).enumerated() { | ||
| @@ -155,10 +204,13 @@ struct MarkdownView: View { | ||
var blocks: [Block] = [] | ||
var paragraph: [String] = [] | ||
var code: [String]? | ||
var table: MarkdownTable? | ||
| ||
func flush() { | ||
if !paragraph.isEmpty { blocks.append(.paragraph(paragraph.joined(separator: " "))) } | ||
paragraph = [] | ||
if let table { blocks.append(.table(table)) } | ||
table = nil | ||
} | ||
| ||
for raw in source.components(separatedBy: "\n") { | ||
| @@ -177,7 +229,19 @@ struct MarkdownView: View { | ||
code?.append(raw) | ||
continue | ||
} | ||
if trimmed.isEmpty { | ||
if table != nil { | ||
if trimmed.contains("|") { | ||
table?.add(trimmed) | ||
continue | ||
} | ||
flush() | ||
} | ||
if let header = paragraph.last, let started = MarkdownTable(header: header, dashes: trimmed) { | ||
// A line above it is a paragraph of its own. | ||
paragraph.removeLast() | ||
flush() | ||
table = started | ||
} else if trimmed.isEmpty { | ||
flush() | ||
} else if let hashes = trimmed.firstRange(of: #/^#{1,6} /#) { | ||
flush() | ||
ios/Knotless/Support/MarkdownTable.swiftnew+69−0
| @@ -0,0 +1,69 @@ | ||
import Foundation | ||
| ||
/// A table in Markdown, as GitHub's flavour has them: a line of cells, a line of | ||
/// dashes under it with one column to each, then a line of cells to each row. | ||
struct MarkdownTable: Hashable { | ||
enum Alignment: Hashable { case leading, center, trailing } | ||
| ||
let header: [String] | ||
let alignments: [Alignment] | ||
private(set) var rows: [[String]] = [] | ||
| ||
/// None when `dashes` isn't such a line, or doesn't match `header`'s columns, | ||
/// which leaves them both as words. | ||
init?(header line: String, dashes: String) { | ||
guard line.contains("|"), dashes.contains("|") else { return nil } | ||
let columns = Self.cells(dashes) | ||
guard columns.allSatisfy(Self.isDashes) else { return nil } | ||
header = Self.cells(line) | ||
guard header.count == columns.count else { return nil } | ||
alignments = columns.map { column in | ||
switch (column.hasPrefix(":"), column.hasSuffix(":")) { | ||
case (true, true): .center | ||
case (false, true): .trailing | ||
default: .leading | ||
} | ||
} | ||
} | ||
| ||
/// Dashes, with a colon at either end or both. | ||
private static func isDashes(_ column: String) -> Bool { | ||
var dashes = Substring(column) | ||
if dashes.hasPrefix(":") { dashes.removeFirst() } | ||
if dashes.hasSuffix(":") { dashes.removeLast() } | ||
return !dashes.isEmpty && dashes.allSatisfy { $0 == "-" } | ||
} | ||
| ||
/// A row has the table's columns, whatever its line had: those it lacks are | ||
/// empty, and those it has beyond them go. | ||
mutating func add(_ line: String) { | ||
let cells = Self.cells(line).prefix(header.count) | ||
rows.append(cells + Array(repeating: "", count: header.count - cells.count)) | ||
} | ||
| ||
/// What's between a line's pipes, but for one with a backslash before it, | ||
/// which is a pipe in the cell. Those at either end hold nothing. | ||
static func cells(_ line: String) -> [String] { | ||
var cells: [String] = [] | ||
var cell = "" | ||
var escaped = false | ||
for character in line { | ||
if escaped { | ||
cell += character == "|" ? "|" : "\\\(character)" | ||
escaped = false | ||
} else if character == "\\" { | ||
escaped = true | ||
} else if character == "|" { | ||
cells.append(cell) | ||
cell = "" | ||
} else { | ||
cell.append(character) | ||
} | ||
} | ||
cells.append(escaped ? cell + "\\" : cell) | ||
let trimmed = line.trimmingCharacters(in: .whitespaces) | ||
if trimmed.hasPrefix("|") { cells.removeFirst() } | ||
if trimmed.hasSuffix("|"), !trimmed.hasSuffix("\\|"), !cells.isEmpty { cells.removeLast() } | ||
return cells.map { $0.trimmingCharacters(in: .whitespaces) } | ||
} | ||
} | ||
ios/Knotless/Support/Theme.swift+3−0
| @@ -75,6 +75,9 @@ enum Size { | ||
} | ||
} | ||
| ||
/// The width of a Markdown table's cell with more letters than fit on a line of it. | ||
static let tableCell: CGFloat = 260 | ||
static let tableCellLetters = 36 | ||
/// The least height of a row that opens a screen. | ||
static let row: CGFloat = 56 | ||
static let fileHeader: CGFloat = 52 | ||
ios/ModelCheck/main.swift+9−0
| @@ -105,6 +105,15 @@ expect(place.pictures(blob).map(\.absoluteString) == [blob], "another's picture | ||
let answered = #"{"preferences":[{"$type":"dev.knotless.getPreferences#appearancePref","theme":"dark"},{"$type":"dev.knotless.getPreferences#notificationPref","reasons":[{"reason":"star","enabled":false}],"followedOnly":true},{"$type":"dev.knotless.later"}]}"# | ||
let heard = try JSONDecoder().decode(Preferences.self, from: Data(answered.utf8)).notifications | ||
expect(heard == NotificationChoices(reasons: [.init(reason: "star", enabled: false)], followedOnly: true), "the notifications weren't found among the preferences: \(String(describing: heard))") | ||
var table = MarkdownTable(header: "| a | b | c |", dashes: "|:--|--:|:-:|") | ||
expect(table?.header == ["a", "b", "c"] && table?.alignments == [.leading, .trailing, .center], "a table's head: \(String(describing: table))") | ||
table?.add("| one \\| two | `x` |") | ||
table?.add("| 1 | 2 | 3 | 4 |") | ||
expect(table?.rows == [["one | two", "`x`", ""], ["1", "2", "3"]], "a table's rows: \(String(describing: table?.rows))") | ||
expect(MarkdownTable(header: "a | b", dashes: "--- | ---") != nil, "a table with no pipes at its ends") | ||
expect(MarkdownTable(header: "| a | b |", dashes: "| --- |") == nil, "a table whose columns don't match") | ||
expect(MarkdownTable(header: "a | b", dashes: "not | dashes") == nil, "words as a table's dashes") | ||
expect(MarkdownTable(header: "just words", dashes: "---") == nil, "a rule as a table's dashes") | ||
if wrong > 0 { exit(1) } | ||
| ||
// Every write the app makes, for check-writes.mjs to hold to its lexicon. | ||
ios/check-models.sh+1−1
| @@ -6,6 +6,6 @@ set -eu | ||
cd "$(dirname "$0")/.." | ||
out=$(mktemp -d) | ||
trap 'rm -rf "$out"' EXIT | ||
swiftc -o "$out/check" ios/Knotless/API/Models.swift ios/Knotless/API/Writes.swift ios/Knotless/API/Standing.swift ios/Knotless/Support/Identicon.swift ios/Knotless/Support/MarkdownPlace.swift ios/Knotless/Support/MarkdownFences.swift ios/ModelCheck/main.swift | ||
swiftc -o "$out/check" ios/Knotless/API/Models.swift ios/Knotless/API/Writes.swift ios/Knotless/API/Standing.swift ios/Knotless/Support/Identicon.swift ios/Knotless/Support/MarkdownPlace.swift ios/Knotless/Support/MarkdownFences.swift ios/Knotless/Support/MarkdownTable.swift ios/ModelCheck/main.swift | ||
"$out/check" harness/shots/fixtures.json "$out/writes.json" | ||
node ios/check-writes.mjs "$out/writes.json" | ||