feat: render interactive markdown task checkboxes

This commit is contained in:
Valdemar Lorenzen
2026-10-19 11:22:38 +02:00
parent 8e1546d502
commit 655f4b4d98
3 changed files with 152 additions and 1 deletions
+2
View File
@@ -141,6 +141,8 @@ Actions opens the same searchable popup style used throughout Conjure, with **Br
Edit Markdown directly: headings, bold, italic, lists, links, inline code, and fenced code blocks are highlighted while the source remains visible. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and task lists at the same indentation, incrementing numbers and resetting task checkboxes. On an empty item, Return exits one nesting level; repeat to leave the list entirely. Nesting has no fixed depth limit. Prose and fenced code retain ordinary newlines. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note.
Task-list markers such as `- [ ]` and `- [x]` display as native checkboxes, including nested tasks. Click a checkbox to toggle completion; toggles autosave and support Undo/Redo without moving the text selection. The original Markdown remains in saved notes and copied text, rather than being replaced with image attachments. Task syntax inside fenced code or ordinary prose stays literal.
Fenced code uses [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) (Highlight.js) for language-aware token colors. Add a language after the opening backticks or tildes, for example `py`, `python`, `swift`, `js`, or `json`. Unlabeled fences use automatic detection; unknown languages remain plain monospace. Highlighting runs entirely offline.
Notes autosave locally as RTFD packages, including embedded images, under `~/Library/Application Support/Conjure/Notes`. No account, network access, or cloud sync is involved. Save failures appear below the editor and prevent switching, closing, or quitting until saving succeeds, preserving the unsaved buffer. The Back chevron restores the launcher search when no popup is open. Command-W dismisses Conjure, including any open popup; leaving Notes or switching destinations removes its popup completely.
+85 -1
View File
@@ -7,7 +7,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
private let browseButton = NSButton(title: "Browse Notes", target: nil, action: nil)
private let actionsButton = NSButton(title: "Actions", target: nil, action: nil)
private let actions = ApplicationActionsController()
private let editor = NSTextView()
private let editor = NotesTextView()
private let status = NSTextField(labelWithString: "")
private let defaults: UserDefaults
private var store: NotesStore?
@@ -116,6 +116,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
func textDidChange(_ notification: Notification) {
isDirty = true
MarkdownHighlighting.apply(to: editor)
editor.refreshTasks()
_ = save()
}
@@ -261,6 +262,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
editor.isEditable = true
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
MarkdownHighlighting.apply(to: editor)
editor.refreshTasks()
status.stringValue = "Saved locally · Markdown · Paste or drop images"
status.textColor = SunsetPalette.secondaryText
}
@@ -297,6 +299,88 @@ private final class NotesView: NSView {
}
}
@MainActor
final class NotesTextView: NSTextView {
private struct Task {
let marker: NSRange
let state: NSRange
let checked: Bool
let title: String
}
private static let taskPattern = try! NSRegularExpression(
pattern: "(?m)^[ \\t]*((?:[-+*]|[0-9]+[.)])[ \\t]+\\[([ xX])\\])(?=[ \\t]|\\r?$)"
)
private var tasks: [Task] = []
private var checkboxes: [NSButton] = []
// ponytail: scan the whole note on edits; incremental parsing if large task lists make typing slow.
func refreshTasks() {
guard let layoutManager else { return }
let source = string as NSString
let range = NSRange(location: 0, length: source.length)
layoutManager.removeTemporaryAttribute(.foregroundColor, forCharacterRange: range)
let code = MarkdownHighlighting.codeBlocks(in: string)
tasks = Self.taskPattern.matches(in: string, range: range).compactMap { match in
guard !code.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { return nil }
let end = NSMaxRange(source.lineRange(for: match.range))
let title = source.substring(with: NSRange(location: NSMaxRange(match.range), length: end - NSMaxRange(match.range)))
.trimmingCharacters(in: .whitespacesAndNewlines)
return Task(marker: match.range(at: 1), state: match.range(at: 2),
checked: source.substring(with: match.range(at: 2)) != " ", title: title)
}
while checkboxes.count > tasks.count {
let checkbox = checkboxes.removeLast()
if window?.firstResponder === checkbox { window?.makeFirstResponder(self) }
checkbox.removeFromSuperview()
}
while checkboxes.count < tasks.count {
let checkbox = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:)))
checkbox.controlSize = .small
checkbox.contentTintColor = SunsetPalette.accent
checkbox.sizeToFit()
checkboxes.append(checkbox)
addSubview(checkbox)
}
for (index, task) in tasks.enumerated() {
let checkbox = checkboxes[index]
checkbox.tag = index
checkbox.state = task.checked ? .on : .off
checkbox.isEnabled = isEditable
checkbox.setAccessibilityLabel(task.title.isEmpty ? "Task" : task.title)
checkbox.toolTip = task.title
layoutManager.addTemporaryAttribute(.foregroundColor, value: NSColor.clear, forCharacterRange: task.marker)
}
positionCheckboxes()
}
override func layout() {
super.layout()
positionCheckboxes()
}
private func positionCheckboxes() {
guard let layoutManager, let textContainer else { return }
layoutManager.ensureLayout(for: textContainer)
for (index, task) in tasks.enumerated() {
let glyphs = layoutManager.glyphRange(forCharacterRange: task.marker, actualCharacterRange: nil)
let rect = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer)
.offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y)
let checkbox = checkboxes[index]
checkbox.setFrameOrigin(NSPoint(x: rect.minX, y: rect.midY - checkbox.frame.height / 2))
}
}
@objc private func toggleTask(_ sender: NSButton) {
guard isEditable, tasks.indices.contains(sender.tag) else { return }
let task = tasks[sender.tag]
let selection = selectedRange()
breakUndoCoalescing()
insertText(task.checked ? " " : "x", replacementRange: task.state)
breakUndoCoalescing()
setSelectedRange(selection)
}
}
@MainActor
enum MarkdownHighlighting {
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
@@ -322,3 +322,68 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:)))
#expect(restored.notes.first { $0.id == id }?.contents.string == "7. saved\n8. ")
}
@Test @MainActor
func taskCheckboxesToggleMarkdownWithUndoPersistenceAndListContinuation() throws {
_ = NSApplication.shared
let suite = "NotesTasks.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
defer {
defaults.removePersistentDomain(forName: suite)
try? FileManager.default.removeItem(at: directory)
}
let controller = NotesController(defaults: defaults, directory: directory)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 390),
styleMask: .borderless, backing: .buffered, defer: false)
window.contentViewController = controller
defer { _ = controller.close(); window.orderOut(nil) }
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
window.makeFirstResponder(editor)
let before = "- [ ] buy 🦊\n - [X] nested\n```md\n- [ ] literal\n```\nprose - [ ] not a task"
editor.insertText(before, replacementRange: editor.selectedRange())
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
let boxes = editor.subviews.compactMap { $0 as? NSButton }
#expect(boxes.map(\.state) == [.off, .on])
#expect(boxes.first?.cell?.accessibilityRole() == .checkBox)
#expect(boxes.first?.accessibilityLabel() == "buy 🦊")
let selection = editor.selectedRange()
let first = try #require(boxes.first)
first.performClick(nil)
let checked = before.replacingOccurrences(of: "- [ ] buy", with: "- [x] buy")
#expect(editor.string == checked)
#expect(first.state == .on)
#expect(editor.selectedRange() == selection)
let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:)))
let saved = try #require(NotesStore(directory: directory).notes.first { $0.id == id })
#expect(saved.contents.string == checked)
#expect(saved.contents.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor != .clear)
editor.undoManager?.undo()
#expect(editor.string == before)
#expect(first.state == .off)
editor.undoManager?.redo()
#expect(editor.string == checked)
#expect(first.state == .on)
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
let board = NSPasteboard.withUniqueName()
defer { board.releaseGlobally() }
#expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes))
#expect(board.string(forType: .string) == checked)
let reopened = NotesController(defaults: defaults, directory: directory)
defer { _ = reopened.close() }
let restoredEditor = try #require(descendants(reopened.view).compactMap { $0 as? NotesTextView }.first)
#expect(restoredEditor.string == checked)
#expect(restoredEditor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.on, .on])
editor.insertText("- [x] finished", replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
let enter = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "\r",
charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36))
editor.keyDown(with: enter)
#expect(editor.string == "- [x] finished\n- [ ] ")
#expect(editor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.on, .off])
editor.keyDown(with: enter)
#expect(editor.string == "- [x] finished\n")
#expect(editor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.on])
}