~ 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 task lists, and their author ticks them

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