~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

The iOS app draws Markdown's tables

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:ios/markdown-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"