feat: render interactive markdown task checkboxes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user