chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app draws Markdown's task lists, and their author ticks them
ios/Knotless/API/Writes.swift+5−0
| @@ -93,6 +93,11 @@ enum Writes { | ||
Write(method: "putPreferences", input: [:], notifications: choices) | ||
} | ||
| ||
/// A pull's, an issue's or a comment's body, which only who wrote it may change. | ||
static func edit(repo: RepoRef, subject: String, body: String) -> Write { | ||
Write(method: "editItem", input: ["repo": repo.uri, "subject": subject, "body": body]) | ||
} | ||
| ||
static func seen(at: String) -> Write { | ||
Write(method: "updateSeen", input: ["seenAt": at]) | ||
} | ||
ios/Knotless/Support/Markdown.swift+106−2
| @@ -8,6 +8,9 @@ struct MarkdownView: View { | ||
var html: String? | ||
/// Where it's written, to find the pictures it names. | ||
var place: MarkdownPlace? | ||
/// Its author's, to tick a task, which is by its number in `source`. Without | ||
/// it the boxes only say how each stands. | ||
var tick: ((Int, Bool) -> Void)? | ||
| ||
enum Block: Hashable { | ||
case heading(Int, String) | ||
| @@ -17,6 +20,7 @@ struct MarkdownView: View { | ||
case code(String) | ||
case rule | ||
case table(MarkdownTable) | ||
case task(String, number: Int, done: Bool, depth: Int) | ||
} | ||
| ||
var body: some View { | ||
| @@ -52,9 +56,34 @@ struct MarkdownView: View { | ||
Divider() | ||
case .table(let table): | ||
self.table(table) | ||
case .task(let text, let number, let done, let depth): | ||
HStack(alignment: .firstTextBaseline, spacing: Space.s2) { | ||
box(number: number, done: done, about: text) | ||
rich(text) | ||
.strikethrough(done) | ||
.foregroundStyle(done ? Color.secondary : Color.primary) | ||
} | ||
.padding(.leading, CGFloat(depth) * 16) | ||
} | ||
} | ||
| ||
private func box(number: Int, done: Bool, about text: String) -> some View { | ||
Button { tick?(number, !done) } label: { | ||
Image(systemName: done ? "checkmark.square.fill" : "square") | ||
.foregroundStyle(tick == nil ? Color.secondary : done ? Color.accent : Color.secondary) | ||
// A finger's room round it, which moves nothing around it. | ||
.padding(Space.s2) | ||
.contentShape(Rectangle()) | ||
.padding(-Space.s2) | ||
} | ||
.buttonStyle(.plain) | ||
.disabled(tick == nil) | ||
.accessibilityLabel(Self.plain(text)) | ||
.accessibilityValue(done ? "Done" : "Not done") | ||
.accessibilityAddTraits(.isToggle) | ||
.accessibilityIdentifier("task-\(number)") | ||
} | ||
| ||
/// 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 { | ||
| @@ -213,7 +242,8 @@ struct MarkdownView: View { | ||
table = nil | ||
} | ||
| ||
for raw in source.components(separatedBy: "\n") { | ||
let tasks = Dictionary(MarkdownTasks.all(source).map { ($0.line, $0) }) { first, _ in first } | ||
for (at, raw) in source.components(separatedBy: "\n").enumerated() { | ||
let trimmed = raw.trimmingCharacters(in: .whitespaces) | ||
if trimmed.hasPrefix("```") { | ||
if let lines = code { | ||
| @@ -236,7 +266,11 @@ struct MarkdownView: View { | ||
} | ||
flush() | ||
} | ||
if let header = paragraph.last, let started = MarkdownTable(header: header, dashes: trimmed) { | ||
if let task = tasks[at] { | ||
flush() | ||
let depth = raw.prefix { $0 == " " }.count / 2 | ||
blocks.append(.task(MarkdownTasks.words(raw, task), number: task.number, done: task.done, depth: depth)) | ||
} else 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() | ||
| @@ -259,6 +293,8 @@ struct MarkdownView: View { | ||
blocks.append(.bullet(String(trimmed[match.range.upperBound...]), marker: isBullet ? "•" : String(match.1), depth: depth)) | ||
} else if case .bullet(let text, let marker, let depth)? = blocks.last, paragraph.isEmpty, raw.hasPrefix(" ") { | ||
blocks[blocks.count - 1] = .bullet(text + " " + trimmed, marker: marker, depth: depth) | ||
} else if case .task(let text, let number, let done, let depth)? = blocks.last, paragraph.isEmpty, raw.hasPrefix(" ") { | ||
blocks[blocks.count - 1] = .task(text + " " + trimmed, number: number, done: done, depth: depth) | ||
} else { | ||
paragraph.append(trimmed) | ||
} | ||
| @@ -268,3 +304,71 @@ struct MarkdownView: View { | ||
return blocks | ||
} | ||
} | ||
| ||
/// Markdown its author can tick: each tick shows at once and is saved behind it, | ||
/// one write at a time as each says the whole body, and a late one mustn't undo | ||
/// a newer one. Once all are saved the thread is read anew. One that fails puts | ||
/// the boxes back. `limit` draws only the start of it, as a folded one does, | ||
/// while a tick still writes the whole. | ||
struct TickableMarkdown: View { | ||
let source: String | ||
var html: String? | ||
var place: MarkdownPlace? | ||
let repo: RepoRef | ||
let subject: String | ||
let author: String | ||
var limit: Int? | ||
let changed: () -> Void | ||
| ||
@Environment(Account.self) private var account | ||
@State private var shown: String? | ||
@State private var saved: String? | ||
@State private var saving = false | ||
@State private var failure: String? | ||
| ||
var body: some View { | ||
MarkdownView(source: drawn, html: html, place: place, tick: ticking) | ||
.onChange(of: source) { | ||
guard !saving else { return } | ||
shown = nil | ||
saved = nil | ||
} | ||
.alert("That didn't work", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) { | ||
Button("OK", role: .cancel) {} | ||
} message: { | ||
Text(failure ?? "") | ||
} | ||
} | ||
| ||
private var drawn: String { | ||
let whole = shown ?? source | ||
guard let limit else { return whole } | ||
return String(whole.prefix(limit)) | ||
} | ||
| ||
private var ticking: ((Int, Bool) -> Void)? { | ||
guard account.person?.did == author else { return nil } | ||
return { tick($0, $1) } | ||
} | ||
| ||
private func tick(_ number: Int, _ done: Bool) { | ||
guard let next = MarkdownTasks.ticked(shown ?? source, number, done: done) else { return } | ||
shown = next | ||
guard !saving else { return } | ||
saving = true | ||
Task { | ||
defer { saving = false } | ||
while let body = shown, body != (saved ?? source) { | ||
do { | ||
try await Client.shared.send(Writes.edit(repo: repo, subject: subject, body: body)) | ||
saved = body | ||
} catch { | ||
failure = error.localizedDescription | ||
shown = saved | ||
return | ||
} | ||
} | ||
changed() | ||
} | ||
} | ||
} | ||
ios/Knotless/Support/MarkdownTasks.swiftnew+95−0
| @@ -0,0 +1,95 @@ | ||
import Foundation | ||
| ||
/// The task lists in what someone wrote: `- [ ] a thing to do`. Its author ticks | ||
/// one by rewriting that mark. These are the tasks `web/src/lib/tasks.ts` finds, | ||
/// so a tick on either rewrites the same line, and never another. | ||
enum MarkdownTasks { | ||
/// A task's place in what's written: which it is, from 0, and on which line. | ||
struct Task: Hashable { | ||
let number: Int | ||
let line: Int | ||
let mark: Int | ||
let done: Bool | ||
} | ||
| ||
/// Each one in `text`, leaving out what's in fenced code. | ||
static func all(_ text: String) -> [Task] { | ||
var found: [Task] = [] | ||
var fence: [Character] = [] | ||
for (at, line) in text.components(separatedBy: "\n").enumerated() { | ||
let letters = Array(line) | ||
let opens = self.fence(letters) | ||
if !fence.isEmpty { | ||
if let opens, opens[0] == fence[0], opens.count >= fence.count { fence = [] } | ||
} else if let opens { | ||
fence = opens | ||
} else if let mark = self.mark(letters) { | ||
found.append(Task(number: found.count, line: at, mark: mark, done: letters[mark] != " ")) | ||
} | ||
} | ||
return found | ||
} | ||
| ||
/// `text` with its task `number` ticked or unticked, or nil with no such task. | ||
static func ticked(_ text: String, _ number: Int, done: Bool) -> String? { | ||
let found = all(text) | ||
guard found.indices.contains(number) else { return nil } | ||
let task = found[number] | ||
var lines = text.components(separatedBy: "\n") | ||
var letters = Array(lines[task.line]) | ||
letters[task.mark] = done ? "x" : " " | ||
lines[task.line] = String(letters) | ||
return lines.joined(separator: "\n") | ||
} | ||
| ||
/// What a task says, past its box. | ||
static func words(_ line: String, _ task: Task) -> String { | ||
String(line.dropFirst(task.mark + 2)).trimmingCharacters(in: .whitespaces) | ||
} | ||
| ||
private static func blank(_ c: Character) -> Bool { c == " " || c == "\t" } | ||
| ||
/// Past any quote marks and the space round them, as those lines can be nested. | ||
private static func pastQuotes(_ line: [Character]) -> Int { | ||
var at = 0 | ||
while true { | ||
var next = at | ||
while next < line.count, blank(line[next]) { next += 1 } | ||
guard next < line.count, line[next] == ">" else { return at } | ||
at = next + 1 | ||
} | ||
} | ||
| ||
/// The fence a line opens or closes: three or more of a backtick or tilde, | ||
/// under four spaces in. | ||
private static func fence(_ line: [Character]) -> [Character]? { | ||
var at = pastQuotes(line) | ||
var spaces = 0 | ||
while at < line.count, blank(line[at]), spaces < 3 { | ||
at += 1 | ||
spaces += 1 | ||
} | ||
guard at < line.count, line[at] == "`" || line[at] == "~" else { return nil } | ||
let run = line[at...].prefix { $0 == line[at] } | ||
return run.count >= 3 ? Array(run) : nil | ||
} | ||
| ||
/// Where a list item's box has its mark, if the line is one. | ||
private static func mark(_ line: [Character]) -> Int? { | ||
var at = pastQuotes(line) | ||
while at < line.count, blank(line[at]) { at += 1 } | ||
guard at < line.count else { return nil } | ||
if "-*+".contains(line[at]) { | ||
at += 1 | ||
} else { | ||
let digits = line[at...].prefix { $0.isASCII && $0.isNumber }.count | ||
guard (1...9).contains(digits), at + digits < line.count, ".)".contains(line[at + digits]) else { return nil } | ||
at += digits + 1 | ||
} | ||
let start = at | ||
while at < line.count, blank(line[at]) { at += 1 } | ||
guard at > start, at + 2 < line.count, line[at] == "[", line[at + 2] == "]", " xX".contains(line[at + 1]) else { return nil } | ||
guard at + 3 == line.count || blank(line[at + 3]) else { return nil } | ||
return at + 1 | ||
} | ||
} | ||
ios/Knotless/Views/ThreadView.swift+10−4
| @@ -198,7 +198,7 @@ struct ThreadView: View { | ||
.buttonStyle(.plain) | ||
.accessibilityIdentifier("thread-author") | ||
.padding(.top, Space.s1) | ||
body(thread.body, html: thread.html) | ||
body(thread.body, html: thread.html, author: thread.item.author.did) | ||
ReactionBar(repo: repo, subject: uri, reactions: thread.reactions ?? []) { edits += 1 } | ||
} | ||
.padding(Space.s4) | ||
| @@ -317,10 +317,10 @@ struct ThreadView: View { | ||
} | ||
} | ||
| ||
@ViewBuilder private func body(_ text: String?, html: String?) -> some View { | ||
@ViewBuilder private func body(_ text: String?, html: String?, author: String) -> some View { | ||
if let text, !text.isEmpty { | ||
let folded = !expanded && folds(text) | ||
MarkdownView(source: expanded ? text : String(text.prefix(Self.foldedLength)), html: html, place: MarkdownPlace(repo: repo, subject: uri)) | ||
TickableMarkdown(source: text, html: html, place: MarkdownPlace(repo: repo, subject: uri), repo: repo, subject: uri, author: author, limit: expanded ? nil : Self.foldedLength) { edits += 1 } | ||
// Its whole height and then cut, or its text is squeezed to | ||
// fit and truncates, rather than reading as it does opened. | ||
.fixedSize(horizontal: false, vertical: true) | ||
| @@ -506,7 +506,13 @@ struct CommentRow: View { | ||
if let lines = comment.line, comment.reply == nil { | ||
LineQuote(at: lines, resolved: comment.resolved == true) | ||
} | ||
if let body = comment.body, !body.isEmpty { MarkdownView(source: body, html: comment.html, place: MarkdownPlace(repo: repo, subject: comment.uri)) } | ||
if let body = comment.body, !body.isEmpty { | ||
if let uri = comment.uri { | ||
TickableMarkdown(source: body, html: comment.html, place: MarkdownPlace(repo: repo, subject: uri), repo: repo, subject: uri, author: comment.author.did, changed: changed) | ||
} else { | ||
MarkdownView(source: body, html: comment.html, place: MarkdownPlace(repo: repo, subject: comment.uri)) | ||
} | ||
} | ||
if let uri = comment.uri { | ||
ReactionBar(repo: repo, subject: uri, reactions: comment.reactions ?? [], changed: changed) | ||
} | ||
ios/ModelCheck/main.swift+7−0
| @@ -114,6 +114,12 @@ expect(MarkdownTable(header: "a | b", dashes: "--- | ---") != nil, "a table with | ||
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") | ||
// The tasks web/src/lib/tasks.ts finds, each ticked on its own line. | ||
let written = "- [ ] one\n* [x] two\n3. [X] three\n4) [ ] four\n> - [ ] quoted\n - [ ] under\n-[ ] no\n- [ ]no\n- [y] no\n```\n- [ ] fenced\n```\n~~~\n- [x] fenced\n~~~\n- [ ]" | ||
expect(MarkdownTasks.all(written).map(\.done) == [false, true, true, false, false, false, false], "the tasks found: \(MarkdownTasks.all(written))") | ||
expect(MarkdownTasks.ticked("- [ ] a\n```\n- [ ] b\n```\n- [ ] c", 1, done: true) == "- [ ] a\n```\n- [ ] b\n```\n- [x] c", "a tick rewrote another line") | ||
expect(MarkdownTasks.ticked("é\n- [x] 🦊 a\r\n", 0, done: false) == "é\n- [ ] 🦊 a\r\n", "an untick changed more than its mark") | ||
expect(MarkdownTasks.ticked("- [ ] a", 1, done: true) == nil, "a tick of no task") | ||
if wrong > 0 { exit(1) } | ||
| ||
// Every write the app makes, for check-writes.mjs to hold to its lexicon. | ||
| @@ -143,6 +149,7 @@ let writes: [Write] = [ | ||
Writes.follow(did: repo.did, undo: false), | ||
Writes.follow(did: repo.did, undo: true), | ||
Writes.seen(at: "2026-11-05T12:00:00.000Z"), | ||
Writes.edit(repo: repo, subject: pull, body: "- [x] Done"), | ||
Writes.registerPush(token: "0a1b", environment: "sandbox"), | ||
Writes.unregisterPush(token: "0a1b"), | ||
Writes.report(subject: repo.did, reason: "spam", text: ""), | ||
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/Knotless/Support/MarkdownTable.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/Knotless/Support/MarkdownTasks.swift ios/ModelCheck/main.swift | ||
"$out/check" harness/shots/fixtures.json "$out/writes.json" | ||
node ios/check-writes.mjs "$out/writes.json" | ||