refactor: use structured rich text for Notes editing

This commit is contained in:
2026-10-22 13:13:31 +02:00
parent 5803a00944
commit 751f22f6b1
20 changed files with 2155 additions and 2583 deletions
+19 -1
View File
@@ -1,5 +1,5 @@
{
"originHash" : "fc870f54e8ae0c2fe51ba1e87667691472a20f609075b305c4117df7d7db9336",
"originHash" : "ca20de906df51b7d817aeab261f0e858854d41b957c0169d04e413adab8a05bf",
"pins" : [
{
"identity" : "highlighterswift",
@@ -9,6 +9,24 @@
"revision" : "fe7aae9c9b31d3b296fd3d2dd575e1a207bb29e0",
"version" : "3.1.0"
}
},
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark.git",
"state" : {
"revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a",
"version" : "0.9.0"
}
},
{
"identity" : "swift-markdown",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-markdown",
"state" : {
"revision" : "ea79e83c8744d2b50b0dc2d5bbd1e857e1253bf9",
"version" : "0.6.0"
}
}
],
"version" : 3
+7 -2
View File
@@ -5,9 +5,14 @@ let package = Package(
name: "Conjure",
platforms: [.macOS(.v14)],
products: [.executable(name: "Conjure", targets: ["Conjure"])],
dependencies: [.package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.1.0")],
dependencies: [
.package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.1.0"),
.package(url: "https://github.com/swiftlang/swift-markdown", exact: "0.6.0"),
],
targets: [
.target(name: "ConjureKit", resources: [.process("Resources")]),
.target(name: "ConjureKit", dependencies: [
.product(name: "Markdown", package: "swift-markdown"),
], resources: [.process("Resources")]),
.executableTarget(name: "Conjure", dependencies: [
"ConjureKit", .product(name: "Highlighter", package: "HighlighterSwift"),
]),
+13 -17
View File
@@ -141,7 +141,7 @@ Commands explicitly declare `Application.Command.presentsView`. The shared dispa
Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it a global shortcut in Settings. Notes starts with a compact 570 × 404 layout inspired by Raycast Notes, with rounded corners and a centered muted note title, while using Conjure’s shared warm gradient background, cream text, and amber list markers and caret. Drag a window edge or corner to resize it; Notes remembers its size across reopening and app restarts, independently of other launcher views. The minimum size is 570 × 260, and restored dimensions fit the current screen’s visible area. The title bar offers Close; the bottom-right action pill offers **Browse Notes** and **Actions**, with **New Note** in Actions and on Command-N. Browse Notes (Command-P) opens Conjure’s styled, scrollable searchable picker upwards from the footer, shared with translation language selection—not a native menu. Fuzzy-search note titles, then select with Up/Down, Control-J/K, and Return or by clicking a note. A checkmark marks the current note, which is selected and scrolled into view on opening. Reopening Notes restores the last selection. Each note’s first nonempty line becomes its title; duplicate titles remain independently selectable.
Notes uses a standard macOS window with system-inherited appearance and only the native Close button visible; minimize and zoom are hidden. The editor uses 15-point monospaced body text, including bold, italic, and code, while retaining Conjure’s warm palette. Heading sizes decrease from H1 through H6 (26, 23, 21, 19, 17, and 15 points); inline emphasis retains the heading’s size, and fenced code is not treated as headings. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless with their existing dark appearance.
Notes uses a standard macOS window with system-inherited appearance and only the native Close button visible; minimize and zoom are hidden. The editor uses 15-point monospaced body text, including bold, italic, and code, while retaining Conjure’s warm palette. Heading sizes decrease from H1 through H6 (26, 23, 21, 19, 17, and 15 points); inline emphasis retains the heading’s size, and code-block contents remain literal. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless with their existing dark appearance.
Actions (Command-K) opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P), **New Note** (Command-N), and **Delete Note**. Delete Note asks for confirmation before permanently deleting the currently selected note and its embedded images. Delete is focused by default: Return confirms and Escape cancels; deletion cannot be undone. After deletion, Notes selects a remaining note or creates a fresh blank note if none remain. Save or deletion failures preserve the current note and editor buffer. Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its title-bar controls are covered. Escape or clicking outside closes an open popup and returns focus to the editor without leaving Notes; another Escape dismisses the launcher. Control-J/K navigates only while a popup is open, preserving normal editor commands when it is closed.
@@ -149,9 +149,9 @@ Notes actions are ordered Browse Notes, New Note, Copy as Markdown, Export as Ma
Notes Actions also includes **Copy as Markdown** and **Export as Markdown…**. Both export the whole note, regardless of the selection, without modifying the editor or RTFD autosaves. Clipboard exports replace images with inline-code filename placeholders such as `<photo.png>`. File exports use a native save dialog: notes without images become a single `.md` file; notes with images become a new folder containing `note.md` and the original image files, linked with relative paths. Unsafe or duplicate image filenames are made safe and unique. Image folders must not already exist; a failed export does not leave a partially written folder. Unsupported or unreadable images report an error rather than being dropped.
The bottom strip applies Markdown headings, bold, italic, underline, strikethrough, inline code, links, fenced code blocks, quotes, numbered lists, bullet lists, and checklists. Underline uses inline HTML `<u>…</u>` so it survives Markdown export and RTFD autosaves. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type removes its marker. The formatting strip is always visible. Successful autosaves stay unobtrusive; only save, load, deletion, or export errors appear below the editor.
The bottom strip applies headings, bold, italic, underline, strikethrough, inline code, links, code blocks, quotes, numbered lists, bullet lists, and checklists. Formatting is stored as native attributed text and paragraph metadata, not hidden Markdown characters. Underline exports as `<u>…</u>`. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type returns the paragraphs to body text. Successful autosaves stay unobtrusive; only save, load, deletion, or export errors appear below the editor.
Markdown highlighting and list alignment update in one formatting transaction per edit, so typing does not expose an intermediate unformatted layout.
Typing, toolbar actions, and Vim share one attributed-document edit and Undo path. Completed shortcuts publish their final formatting and list layout before autosaving; edits do not serialize and reconstruct the document as Markdown.
Note text fades over 16 points at the editor’s scroll edges rather than clipping sharply above the formatting strip. Matching 16-point scroll insets keep the first and last lines clear of the fade when scrolled to either end. The 36-point title header and footer meet the editor without extra vertical gaps.
@@ -172,27 +172,23 @@ Formatting shortcuts use the same actions as the bottom strip. They apply to the
| Bullet list | Command-Shift-L |
| Checklist | Command-Shift-T |
Edit Markdown directly: headings, bold, italic, underline, strikethrough, links, quotes, and inline code render without their recognized formatting delimiters. Heading `#` prefixes, emphasis markers, underline tags, link wrappers and destinations, and quote prefixes are hidden rather than deleted. Code-block fences and their language labels stay visible, with syntax highlighting inside the block. Inline code uses Conjure’s orange accent text on a lightly tinted inline container; this background is display-only and stays out of saved contents. Unmatched or escaped punctuation and literal code remain visible. Copying a selection containing hidden syntax and saving notes preserve the original Markdown. Arrow navigation and typing skip hidden syntax, while the formatting strip still toggles it. Bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and native checkbox markers share a marker column, so item text aligns at the same nesting depth even with long numbers; wrapped lines align with the item text, not the marker. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues 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.
Notes uses Notion-style Markdown input shortcuts: completing `**bold**`, `*italic*`, `~~strike~~`, inline backticks, `<u>underline</u>`, or `[label](url)` consumes the syntax and creates formatting on the visible text. At paragraph start, `# ` through `###### ` set the absolute heading level; `> `, `- `, `1. `, and `- [ ] ` create quotes and lists. There are no hidden delimiter positions for the caret to enter or accidentally delete. Unmatched syntax stays literal. Plain-text paste stays literal rather than being parsed as Markdown; rich-text copy/paste between Notes editors preserves semantic formatting and image bytes. Whole-note Markdown export generates normalized syntax rather than preserving the original delimiter spelling.
Typing `# ` through `###### ` at the visible start of an existing heading replaces its level rather than adding to it. Backspace at a heading's visible start removes the preceding newline, leaving a heading at the start of the document unchanged. Removing an empty line above preserves the heading; joining onto nonempty text inherits the preceding paragraph's type (`Text\n## Heading` becomes `TextHeading`). Return at a heading's visible start inserts an empty line above it, keeping its hidden prefix with the heading text, including after Backspace. Both commands keep the caret on the heading's visible line rather than the preceding line. These edits support Undo and retain Markdown serialization inside the existing RTFD autosaves.
Backspace at a heading's visible start removes the preceding newline, leaving a heading at the document start unchanged. Removing an empty line above preserves its heading type; joining onto nonempty prose inherits the preceding paragraph's type. Return at a heading's start inserts an empty line above it; Return within or at the end of a heading creates a body paragraph. These operations use the same visible text positions as native selection and support Undo.
Tab anywhere on a bullet, numbered, or task item indents that line by two spaces, preserving the caret or selection within the line. Shift-Tab removes one leading tab or up to two spaces; at the root it does nothing and never removes the item. These commands change only the current line’s indentation, not nested children. Moving an item into or out of a numbered list updates the affected sibling sequences: a new nested sequence starts at 1, an existing sequence continues from its preceding sibling, and later siblings are renumbered in the saved and copied Markdown too. Existing standalone starting numbers and each marker’s delimiter are preserved. Indentation and numbering changes undo together. Return on a whitespace-only item outdents to its actual parent, or removes one indent unit when there is no parent; only Return on an empty root item removes the marker. Repeated Return therefore exits even a standalone nested list one level at a time.
Tab on a bullet, numbered, or task item increases its nesting depth; Shift-Tab decreases it, stopping at the root. Carets and Unicode selections remain at the same text offsets. A new nested numbered sequence starts at 1 and following peers are renumbered. Return continues a nonempty list item, resets a new task to unchecked, and outdents an empty item one level before returning a root-level empty item to body text.
Checkbox conversion waits for a space or tab after the closing bracket: type `- [ ] `, `- [] `, or `- [x] `. A closing bracket alone remains literal, so typing directly after it does not briefly create and then remove a checkbox. Both `[]` and `[ ]` mean unchecked; toggling a compact `[]` task on produces `[x]`, and toggling it off produces `[ ]`.
Type a space after `[ ]`, `[]`, or `[x]` at a bullet's start to make a task. Checklist buttons toggle semantic checked state without changing text or moving the caret. Bullets and numbers are drawn in the paragraph gutter, not stored as text or synthetic attachments. Arrow keys, selections, typing, and Vim operate on the actual item body. Plain copying copies visible text; **Copy as Markdown** includes the list syntax.
When typing a list marker before existing whitespace on an otherwise blank line, the marker stays literal until you explicitly type Space or Tab immediately after it. Existing whitespace does not complete the marker for you. Tab completes a still-literal marker; on an already rendered list item it indents the line. Loading or pasting valid list Markdown still renders it directly.
Bullets and checkboxes are centered in their shared gutter; numbered markers are right-aligned on the body text baseline. Wrapped lines share the same text column, including nested items and gutters widened for longer numbers.
List markers are atomic inline tokens, including nested bullets and numbers; task markers such as `- [ ]` and `- [x]` use native checkboxes. Left/Right crosses a marker and its single required separator (a space or tab) in one visible step, between the marker's start and the item body. Only that separator is hidden: additional spaces and tabs are visible body text with normal cursor stops. Shift-Left/Right uses the same boundaries and preserves the selection anchor when extending or reversing direction. Visible indentation and body whitespace retain normal character stops. Selecting only a token copies only its original marker; a keyboard selection across the marker also includes its required separator. Click a checkbox to toggle completion without moving the selection. Backspace at the start of the body expands the original marker and deletes its final character, preserving the separator; forward Delete immediately before the token expands the marker and deletes its first character. Each edit is one Undo step. Removing an item's preceding newline after prose makes its marker literal inline text; joining onto an empty or indentation-only line keeps a valid list token. Saved notes and copied text retain the original Markdown spelling, numbers, case, and spacing, alongside embedded images; alignment formatting is display-only. Thematic breaks and list syntax inside fenced code or ordinary prose stay literal.
Unordered markers display as `•`; bullets and checkboxes are centered in the shared marker column. Checkboxes align vertically with the text’s cap-height center, using its baseline rather than the attachment frame. Numbered markers display their original number and delimiter, right-aligned before the item text and resting on the same text baseline. Item text and wrapped lines retain their shared alignment, including nested lists and columns widened by longer numbers. The stored and copied Markdown retains the original bullet spelling.
Typing, pasting, and Return at the visible start of an item use the position after its preserved Markdown separator. Continuing to type after a marker converts therefore keeps the item rendered, rather than inserting characters into the marker syntax.
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.
Type three backticks, optionally followed by a language such as `swift` or `python`, then Return to enter a code block. The input fence is consumed; code content stays literal and uses [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) for offline token colors. Return on an empty code paragraph returns to body text; the Code block button also toggles the paragraph type. Unspecified languages use automatic detection, and unknown languages remain plain monospace. Markdown export regenerates fences and language labels.
Embedded images display with 8-point rounded corners. Rounding only clips their rendering in Notes; saving and copying retain the original image data.
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 Close button and Command-W dismiss Conjure, including any open popup; leaving Notes or switching destinations removes its popup completely and restores the launcher’s original dimensions.
Paste images or drag image files into the editor to embed them locally. Command-Z undoes edits; Command-Shift-Z redoes them. Switching notes clears Undo history so edits cannot spill into another note.
Notes autosave locally as RTFD packages, including embedded images, under `~/Library/Application Support/Conjure/Notes`. Each package also contains versioned `ConjureNote.json` metadata for paragraph types and inline marks that RTFD does not preserve by itself. Metadata is validated against the stored text; corrupt or unsupported metadata reports an error instead of being interpreted as legacy Markdown. Older Markdown-backed RTFD notes are converted in memory on load. Their first save preserves the original package in `LegacyBackups/<note-id>.rtfd` before atomically writing the structured package; backup failures prevent migration. No account, network access, or cloud sync is involved. Save failures preserve the unsaved buffer and prevent switching, closing, or quitting until saving succeeds. The Close button and Command-W dismiss Conjure, including open popups; leaving Notes removes its popup and restores the launcher’s original dimensions.
### Vim editing
@@ -216,7 +212,7 @@ Normal mode uses a translucent, one-character-wide block cursor. Insert mode and
| Native Undo / Redo | `u` / Control-R; Command-Z / Command-Shift-Z remain available |
| Return to Normal (from Insert/Visual) | Escape or Control-[ |
Counts work before motions and operators, including `3j`, `2dd`, and `2d3w`. Vertical movement uses logical lines rather than wrapped display rows and retains the desired column across shorter lines. Character motions and selections respect composed Unicode characters and skip hidden Markdown syntax. Whole-token operators carry recognized inline formatting delimiters with their visible text; linewise operations include heading and list prefixes. Yank/delete/change use a local unnamed register, preserving attributed text and embedded images for `p`/`P` without replacing the system clipboard. Editing and Undo still use the native text view and RTFD autosaves.
Counts work before motions and operators, including `3j`, `2dd`, and `2d3w`. Vertical movement uses logical lines rather than wrapped display rows and retains the desired column across shorter lines. Character motions respect composed Unicode characters and use the actual attributed text offsets. Yank/delete/change use a local unnamed register that preserves formatting and embedded images for `p`/`P` without replacing the system clipboard. A change command and its replacement typing undo together across real key events; focus changes, mode changes, and Command shortcuts finish the insertion group. Vim and ordinary editing use the same document and Undo manager.
Escape closes an open picker first. With Vim enabled, Escape returns Insert or Visual mode to Normal; in Normal mode it dismisses Notes, just as when Vim is disabled. Command-W still dismisses the window. Command shortcuts, formatting controls, and native Insert-mode input remain available. Pending commands cancel on focus changes and Command shortcuts. This is a supported Vim subset, not an embedded Vim: search, text objects, named registers, macros, and dot-repeat are not implemented.
+7 -429
View File
@@ -1,6 +1,5 @@
import AppKit
import ConjureKit
import Highlighter
import UniformTypeIdentifiers
@MainActor
@@ -236,66 +235,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
@objc private func formatNote(_ sender: NSButton) {
guard editor.isEditable else { return }
let contents = editor.markdownContents
let source = contents.string as NSString
var selection = editor.markdownInsertionRange(for: editor.selectedRange())
if sender.tag == 6 {
selection = source.lineRange(for: selection)
if selection.length > 0, source.character(at: NSMaxRange(selection) - 1) == 10 { selection.length -= 1 }
}
let wrappers = [1: ("**", "**"), 2: ("*", "*"), 3: ("~~", "~~"), 4: ("`", "`"),
5: ("[", "](url)"), 6: ("```\n", "\n```"), 11: ("<u>", "</u>")]
if let (open, close) = wrappers[sender.tag] {
let before = selection.location - open.utf16.count
let after = NSMaxRange(selection)
var stars = 0
if open == "*" {
while selection.location > stars, after + stars < source.length,
source.character(at: selection.location - stars - 1) == 42,
source.character(at: after + stars) == 42 { stars += 1 }
}
if before >= 0, after + close.utf16.count <= source.length,
source.substring(with: NSRange(location: before, length: open.utf16.count)) == open,
source.substring(with: NSRange(location: after, length: close.utf16.count)) == close,
open != "*" || stars % 2 == 1 {
let body = contents.attributedSubstring(from: selection)
selection = NSRange(location: before, length: selection.length + open.utf16.count + close.utf16.count)
replaceMarkdown(body, range: selection, selection: NSRange(location: before, length: body.length))
} else {
let body = NSMutableAttributedString(string: open, attributes: editor.typingAttributes)
body.append(contents.attributedSubstring(from: selection))
body.append(NSAttributedString(string: close, attributes: editor.typingAttributes))
replaceMarkdown(body, range: selection,
selection: NSRange(location: selection.location + open.utf16.count, length: selection.length))
}
} else {
let prefixes = [0: "# ", 7: "> ", 8: "1. ", 9: "- ", 10: "- [ ] "]
guard let prefix = prefixes[sender.tag] else { return }
let length = selection.length > 0 && source.character(at: NSMaxRange(selection) - 1) == 10
? selection.length - 1 : selection.length
let lines = source.lineRange(for: NSRange(location: selection.location, length: length))
let body = NSMutableAttributedString(attributedString: contents.attributedSubstring(from: lines))
let rows = body.string.components(separatedBy: "\n")
var offset = body.length, firstDelta = 0
for (index, line) in rows.enumerated().reversed() {
offset -= line.utf16.count + (index < rows.count - 1 ? 1 : 0)
if index == rows.count - 1, index > 0, line.isEmpty { continue }
let indent = String(line.prefix { $0 == " " || $0 == "\t" })
let content = String(line.dropFirst(indent.count))
let item = Self.listMatch(in: content as NSString)
let kind = item.map { $0.range(at: 6).location != NSNotFound ? 10 : $0.range(at: 3).location != NSNotFound ? 8 : 9 }
let removing = kind == sender.tag || (item == nil && content.hasPrefix(prefix))
let oldPrefix = item?.range.length ?? (removing ? prefix.utf16.count : 0)
let newPrefix = removing ? "" : sender.tag == 8 ? "\(index + 1). " : prefix
body.replaceCharacters(in: NSRange(location: offset + indent.utf16.count, length: oldPrefix),
with: NSAttributedString(string: newPrefix, attributes: editor.typingAttributes))
if index == 0 { firstDelta = newPrefix.utf16.count - oldPrefix }
}
let start = max(lines.location, selection.location + firstDelta)
let end = max(start, NSMaxRange(selection) + body.length - lines.length)
replaceMarkdown(body, range: lines, selection: NSRange(location: start, length: end - start))
}
editor.format(sender.tag)
focusEditor()
}
@@ -306,203 +246,12 @@ final class NotesController: NSViewController, NSTextViewDelegate {
_ = save()
}
private static let listItem = try! NSRegularExpression(
pattern: "^([ \\t]*)([-+*]|([0-9]+)([.)]))([ \\t]+|$)(?:\\[([ xX]?)\\]([ \\t]+|$))?"
)
private static func listMatch(in line: NSString) -> NSTextCheckingResult? {
guard let item = listItem.firstMatch(in: line as String, range: NSRange(location: 0, length: line.length)) else { return nil }
let marker = line.substring(with: item.range(at: 2))
// Spaced thematic breaks are not list items or list ancestors.
if marker == "-" || marker == "*" {
let characters = (line as String).lazy.filter { !$0.isWhitespace }
let bullet = marker.first!
if characters.count >= 3, characters.allSatisfy({ $0 == bullet }) { return nil }
}
return item
}
private static func indentationWidth(_ indentation: String) -> Int {
indentation.reduce(0) { $1 == "\t" ? $0 + 4 - $0 % 4 : $0 + 1 }
}
private static func outdentLength(_ indentation: String) -> Int {
indentation.hasPrefix("\t") ? 1 : indentation.prefix(2).prefix { $0 == " " }.count
}
private static func continuedPrefix(_ item: NSTextCheckingResult, in line: NSString) -> String? {
var marker = line.substring(with: item.range(at: 2))
if item.range(at: 3).location != NSNotFound {
guard let number = UInt64(line.substring(with: item.range(at: 3))), number < UInt64.max else { return nil }
marker = "\(number + 1)" + line.substring(with: item.range(at: 4))
}
var prefix = line.substring(with: item.range(at: 1)) + marker + line.substring(with: item.range(at: 5))
if item.range(at: 6).location != NSNotFound {
prefix += "[ ]" + line.substring(with: item.range(at: 7))
}
return prefix
}
private func setSelectionWithUndo(_ selection: NSRange) {
let previous = editor.selectedRange()
editor.undoManager?.registerUndo(withTarget: self) { $0.setSelectionWithUndo(previous) }
editor.setSelectedRange(selection)
}
private func replaceListMarkdown(_ replacement: String, range: NSRange, selection: NSRange) {
replaceMarkdown(NSAttributedString(string: replacement, attributes: editor.typingAttributes), range: range, selection: selection)
}
private func replaceMarkdown(_ replacement: NSAttributedString, range: NSRange, selection: NSRange) {
editor.undoManager?.beginUndoGrouping()
// Native text undo restores its diff boundary, not the user's selection.
setSelectionWithUndo(editor.selectedRange())
editor.replaceMarkdown(replacement, range: range)
setSelectionWithUndo(editor.renderedRange(for: selection))
editor.undoManager?.endUndoGrouping()
}
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
let newline = commandSelector == #selector(NSResponder.insertNewline(_:))
let indenting = commandSelector == #selector(NSResponder.insertTab(_:))
let outdenting = commandSelector == #selector(NSResponder.insertBacktab(_:))
guard textView === editor, editor.isEditable, newline || indenting || outdenting else { return false }
let source = editor.markdownString as NSString
let selection = editor.markdownInsertionRange(for: editor.selectedRange())
var start = 0, end = 0
source.getLineStart(&start, end: nil, contentsEnd: &end,
for: NSRange(location: selection.location, length: 0))
let line = source.substring(with: NSRange(location: start, length: end - start)) as NSString
guard let item = Self.listMatch(in: line), NSMaxRange(selection) <= end else { return false }
let codeBlocks = MarkdownHighlighting.codeBlocks(in: source as String)
guard !codeBlocks.contains(where: {
NSIntersectionRange($0.range, NSRange(location: start, length: end - start)).length > 0
}) else { return false }
let indent = line.substring(with: item.range(at: 1))
if indenting {
let marker = editor.renderedRange(for:
NSRange(location: start + item.range(at: 2).location, length: 0)).location
// A literal marker still needs native Tab to complete its separator.
guard editor.textStorage?.attribute(.attachment, at: marker, effectiveRange: nil) is NSTextAttachment else { return false }
}
if indenting || outdenting {
let removed = outdenting ? Self.outdentLength(indent) : 0
guard indenting || removed > 0 else { return true }
let replacement = indenting ? " " : ""
let delta = replacement.utf16.count - removed
var selectedStart = max(start, selection.location + delta)
var selectedEnd = max(start, NSMaxRange(selection) + delta)
func isListLine(_ range: NSRange) -> Bool {
Self.listMatch(in: source.substring(with: range) as NSString) != nil
&& !codeBlocks.contains { NSIntersectionRange($0.range, range).length > 0 }
}
var block = source.lineRange(for: NSRange(location: start, length: 0))
while block.location > 0 {
let previous = source.lineRange(for: NSRange(location: block.location - 1, length: 0))
guard isListLine(previous) else { break }
block = NSUnionRange(previous, block)
}
while NSMaxRange(block) < source.length {
let next = source.lineRange(for: NSRange(location: NSMaxRange(block), length: 0))
guard isListLine(next) else { break }
block = NSUnionRange(block, next)
}
let body = NSMutableAttributedString(attributedString: editor.markdownContents.attributedSubstring(from: block))
body.replaceCharacters(in: NSRange(location: start - block.location, length: removed), with: replacement)
let changed = body.string as NSString
let oldDepth = Self.indentationWidth(indent)
let newDepth = Self.indentationWidth(replacement + (indent as NSString).substring(from: removed))
var ancestors: [(depth: Int, number: UInt64?, delimiter: String)] = []
var edits: [(range: NSRange, number: String)] = []
var oldLevel = true, newLevel = true
var offset = 0
while offset < changed.length {
let row = changed.lineRange(for: NSRange(location: offset, length: 0))
let text = changed.substring(with: row) as NSString
guard let match = Self.listMatch(in: text) else { break }
let depth = Self.indentationWidth(text.substring(with: match.range(at: 1)))
if offset + block.location > start {
if depth < oldDepth { oldLevel = false }
if depth < newDepth { newLevel = false }
}
while let last = ancestors.last, last.depth > depth { ancestors.removeLast() }
let peer = ancestors.last?.depth == depth ? ancestors.removeLast() : nil
var number: UInt64?
var delimiter = ""
if match.range(at: 3).location != NSNotFound {
number = UInt64(text.substring(with: match.range(at: 3)))
delimiter = text.substring(with: match.range(at: 4))
if offset + block.location >= start,
(oldLevel && depth == oldDepth) || (newLevel && depth == newDepth) {
if let peer, peer.delimiter == delimiter, let previous = peer.number, previous < UInt64.max {
number = previous + 1
} else if !ancestors.isEmpty || offset + block.location > start {
number = 1
}
if let number, UInt64(text.substring(with: match.range(at: 3))) != number {
let digits = match.range(at: 3)
edits.append((NSRange(location: offset + digits.location, length: digits.length), String(number)))
}
}
}
ancestors.append((depth, number, delimiter))
offset = NSMaxRange(row)
}
for edit in edits.reversed() {
body.replaceCharacters(in: edit.range, with: edit.number)
let location = block.location + edit.range.location
let difference = edit.number.utf16.count - edit.range.length
func adjusted(_ position: Int) -> Int {
if position <= location { return position }
if position < location + edit.range.length { return location + min(position - location, edit.number.utf16.count) }
return position + difference
}
selectedStart = adjusted(selectedStart)
selectedEnd = adjusted(selectedEnd)
}
replaceMarkdown(body, range: block,
selection: NSRange(location: selectedStart, length: selectedEnd - selectedStart))
return true
}
let markerEnd = item.range(at: 6).location == NSNotFound
? NSMaxRange(item.range(at: 2)) : NSMaxRange(item.range(at: 6)) + 1
guard selection.location >= start + markerEnd else { return false }
let body = line.substring(from: item.range.length)
if body.trimmingCharacters(in: .whitespaces).isEmpty {
var replacement = indent.isEmpty ? "" : line.substring(from: Self.outdentLength(indent))
var previous = start
let depth = Self.indentationWidth(indent)
while depth > 0, previous > 0 {
let range = source.lineRange(for: NSRange(location: previous - 1, length: 0))
previous = range.location
let ancestor = source.substring(with: range) as NSString
if let parent = Self.listMatch(in: ancestor),
!codeBlocks.contains(where: { NSIntersectionRange($0.range, range).length > 0 }) {
let parentIndent = ancestor.substring(with: parent.range(at: 1))
if Self.indentationWidth(parentIndent) < depth {
replacement = Self.continuedPrefix(parent, in: ancestor) ?? ""
break
}
} else if !(ancestor as String).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let leading = (ancestor as String).prefix { $0 == " " || $0 == "\t" }
if Self.indentationWidth(String(leading)) < depth { break }
}
}
replaceListMarkdown(replacement, range: NSRange(location: start, length: end - start),
selection: NSRange(location: start + replacement.utf16.count, length: 0))
} else {
guard let prefix = Self.continuedPrefix(item, in: line) else { return false }
replaceListMarkdown("\n" + prefix, range: selection,
selection: NSRange(location: selection.location + 1 + prefix.utf16.count, length: 0))
}
return true
}
@discardableResult
func save() -> Bool {
guard isDirty, let store, let selectedID else { return true }
do {
try store.update(id: selectedID, contents: editor.markdownContents)
try store.update(id: selectedID, contents: editor.documentContents)
isDirty = false
noteTitle.stringValue = store.notes.first { $0.id == selectedID }?.title ?? "Untitled Note"
status.isHidden = true
@@ -530,7 +279,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
func handleCommandKey(_ event: NSEvent) -> Bool {
guard store != nil, view.window?.attachedSheet == nil else { return false }
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
if modifiers.contains(.command) { editor.clearVimCommand() }
if modifiers.contains(.command) { editor.endInsertionSession(); editor.clearVimCommand() }
let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
if let index = Self.formattingKeys.firstIndex(where: { $0.key == key && $0.modifiers == modifiers }) {
guard editor.isEditable, !actions.isShown, view.window?.firstResponder === editor,
@@ -571,7 +320,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
private func copyAsMarkdown() {
actions.close(animated: false)
do {
let export = try NoteMarkdownExport(contents: editor.markdownContents)
let export = try NoteMarkdownExport(contents: editor.documentContents)
let clipboard = NSPasteboard.general
clipboard.clearContents()
guard clipboard.setString(export.clipboardText, forType: .string) else {
@@ -584,7 +333,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
guard let window = view.window, window.attachedSheet == nil else { return }
actions.close(animated: false)
do {
let export = try NoteMarkdownExport(contents: editor.markdownContents)
let export = try NoteMarkdownExport(contents: editor.documentContents)
let panel = NSSavePanel()
panel.title = "Export as Markdown"
panel.prompt = "Export"
@@ -635,7 +384,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
try store.delete(id: id)
self.selectedID = nil
self.defaults.removeObject(forKey: "selectedNote")
self.editor.setMarkdownContents(NSAttributedString(string: ""))
self.editor.setDocumentContents(NSAttributedString(string: ""))
self.editor.undoManager?.removeAllActions()
self.editor.isEditable = false
self.noteTitle.stringValue = "Untitled Note"
@@ -654,7 +403,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
guard let note = store?.notes.first(where: { $0.id == id }) else { return }
selectedID = id
defaults.set(id.uuidString, forKey: "selectedNote")
editor.setMarkdownContents(note.contents)
editor.setDocumentContents(note.contents)
editor.undoManager?.removeAllActions()
editor.isEditable = true
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
@@ -694,174 +443,3 @@ private final class NotesView: NSView {
}
}
private final class RoundedImageAttachmentCell: NSTextAttachmentCell {
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
NSBezierPath(roundedRect: cellFrame, xRadius: 8, yRadius: 8).addClip()
super.draw(withFrame: cellFrame, in: controlView)
}
}
@MainActor
enum MarkdownHighlighting {
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
static let hiddenMarkup = NSAttributedString.Key("Conjure.hiddenMarkup")
static let codeBackground = NSAttributedString.Key("Conjure.codeBackground")
private static let inlineCode = try! NSRegularExpression(pattern: "(?<![\\\\`])(?<marker>`+)(?<body>[^`\\n]+)\\k<marker>(?!`)")
private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>\\*{2,3}|_{2,3})(?<body>\\S(?:[^\\n]*?\\S)?)\\k<marker>(?![*_])"), [.font: NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize, weight: .bold)]),
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>[*_])(?<body>[^\\s*_](?:[^*_\\n]*?[^\\s*_])?)\\k<marker>(?![*_])"), [.font: NSFontManager.shared.convert(NotesAppearance.font, toHaveTrait: .italicFontMask)]),
(try! NSRegularExpression(pattern: "(?<!\\\\)!?\\[(?<body>[^\\]\\n]+)\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]),
(try! NSRegularExpression(pattern: "(?<!\\\\)~~(?<body>\\S(?:[^\\n]*?\\S)?)~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]),
(try! NSRegularExpression(pattern: "(?<!\\\\)<u>(?<body>[^\\n]+?)</u>"), [.underlineStyle: NSUnderlineStyle.single.rawValue]),
(try! NSRegularExpression(pattern: "(?m)^(?:>[ \\t]*)+(?<body>[^\\n]+)"), [:]),
]
private static let heading = try! NSRegularExpression(pattern: "(?m)^(#{1,6}) (?<body>.+)$")
private static let headingFonts = [11.0, 8, 6, 4, 2, 0].map {
NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize + $0, weight: .semibold)
}
private static let highlighter: Highlighter = {
guard let value = Highlighter() else { preconditionFailure("Missing bundled syntax highlighter") }
value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: NotesAppearance.font.pointSize)
return value
}()
private static let openingFence = try! NSRegularExpression(pattern: "(?m)^ {0,3}(`{3,}|~{3,})([^\\r\\n]*)\\r?\\n")
static func codeBlocks(in text: String) -> [(range: NSRange, body: NSRange, language: String?)] {
let source = text as NSString
var blocks: [(NSRange, NSRange, String?)] = []
var cursor = 0
while let opening = openingFence.firstMatch(in: text, range: NSRange(location: cursor, length: source.length - cursor)) {
let fence = source.substring(with: opening.range(at: 1))
let marker = NSRegularExpression.escapedPattern(for: String(fence.first!))
let closing = try! NSRegularExpression(pattern: "(?m)^ {0,3}\(marker){\(fence.count),}[ \\t]*\\r?$")
let start = NSMaxRange(opening.range)
let end = closing.firstMatch(in: text, range: NSRange(location: start, length: source.length - start))
let bodyEnd = end?.range.location ?? source.length
cursor = end.map { NSMaxRange($0.range) } ?? source.length
let language = source.substring(with: opening.range(at: 2)).split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
blocks.append((NSRange(location: opening.range.location, length: cursor - opening.range.location),
NSRange(location: start, length: bodyEnd - start), language))
if cursor == source.length { break }
}
return blocks
}
static func removeCodeBackground(from contents: NSMutableAttributedString) {
let range = NSRange(location: 0, length: contents.length)
contents.enumerateAttribute(codeBackground, in: range) { value, run, _ in
if value as? Bool == true { contents.removeAttribute(.backgroundColor, range: run) }
}
contents.removeAttribute(codeBackground, range: range)
}
// Whole visible tokens carry their delimiters through Vim delete/yank/change operations.
static func completeInlineRange(_ selection: NSRange, in contents: NSAttributedString) -> NSRange {
var range = selection
let full = NSRange(location: 0, length: contents.length)
let matches = ([inlineCode] + rules.map(\.0)).flatMap { $0.matches(in: contents.string, range: full) }
var previous: NSRange
repeat {
previous = range
for match in matches {
let body = match.range(withName: "body")
guard body.location != NSNotFound, body.length > 0,
body.location > match.range.location, NSMaxRange(body) < NSMaxRange(match.range),
range.location <= body.location, NSMaxRange(range) >= NSMaxRange(body),
contents.attribute(hiddenMarkup, at: match.range.location, effectiveRange: nil) as? Bool == true
else { continue }
range = NSUnionRange(range, match.range)
}
} while range != previous
return range
}
static func apply(to editor: NSTextView) {
guard let storage = editor.textStorage else { return }
let text = editor.string
let range = NSRange(location: 0, length: text.utf16.count)
storage.beginEditing()
defer { storage.endEditing() }
storage.enumerateAttribute(.attachment, in: range) { value, _, _ in
guard let attachment = value as? NSTextAttachment,
let cell = attachment.attachmentCell as? NSTextAttachmentCell,
let image = cell.image else { return }
let size = image.size
let scale = min(1, 460 / max(size.width, 1), 240 / max(size.height, 1))
if scale < 1 { image.size = NSSize(width: size.width * scale, height: size.height * scale) }
if !(cell is RoundedImageAttachmentCell) {
attachment.attachmentCell = RoundedImageAttachmentCell(imageCell: image)
}
}
storage.removeAttribute(.strikethroughStyle, range: range)
storage.removeAttribute(.underlineStyle, range: range)
removeCodeBackground(from: storage)
storage.addAttribute(codeBackground, value: false, range: range)
storage.addAttribute(hiddenMarkup, value: false, range: range)
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
.paragraphStyle: NotesAppearance.paragraph], range: range)
let blocks = codeBlocks(in: text)
func hideSyntax(_ match: NSTextCheckingResult) {
let body = match.range(withName: "body")
guard body.location != NSNotFound else { return }
if body.location > match.range.location {
storage.addAttribute(hiddenMarkup, value: true,
range: NSRange(location: match.range.location, length: body.location - match.range.location))
}
if NSMaxRange(body) < NSMaxRange(match.range) {
storage.addAttribute(hiddenMarkup, value: true,
range: NSRange(location: NSMaxRange(body), length: NSMaxRange(match.range) - NSMaxRange(body)))
}
}
let codeSpans = inlineCode.matches(in: text, range: range).filter { match in
!blocks.contains { NSIntersectionRange($0.range, match.range).length > 0 }
}
for match in codeSpans {
let body = match.range(withName: "body")
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.accent,
.backgroundColor: SunsetPalette.selectionFill, codeBackground: true], range: body)
hideSyntax(match)
}
for (expression, attributes) in rules {
for match in expression.matches(in: text, range: range) {
guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }),
!codeSpans.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
storage.addAttributes(attributes, range: match.range)
if match.range(withName: "marker").length == 3,
let font = attributes[.font] as? NSFont {
storage.addAttribute(.font, value: NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask),
range: match.range)
}
hideSyntax(match)
}
}
for match in heading.matches(in: text, range: range) {
guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
hideSyntax(match)
let font = headingFonts[match.range(at: 1).length - 1]
storage.enumerateAttribute(.font, in: match.range) { value, run, _ in
let traits = (value as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? []
storage.addAttribute(.font, value: NSFontManager.shared.convert(font, toHaveTrait: traits), range: run)
}
}
for block in blocks {
storage.addAttributes([.font: NotesAppearance.font,
.foregroundColor: SunsetPalette.secondaryText], range: block.range)
storage.addAttribute(.foregroundColor, value: SunsetPalette.text, range: block.body)
let code = (text as NSString).substring(with: block.body)
guard let highlighted = highlighter.highlight(code, as: block.language),
highlighted.string == code else { continue }
highlighted.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: highlighted.length)) { color, range, _ in
if let color {
storage.addAttribute(.foregroundColor, value: color,
range: NSRange(location: block.body.location + range.location, length: range.length))
}
}
}
editor.typingAttributes = [.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
.paragraphStyle: NotesAppearance.paragraph]
}
}
File diff suppressed because it is too large Load Diff
+41 -42
View File
@@ -17,7 +17,7 @@ struct NotesVimState {
var linewiseRegister = false
}
// ponytail: project the whole note for each command; cache visible offsets if large notes make motions slow.
// ponytail: scan the whole note for each command; cache character offsets if large notes make motions slow.
@MainActor
private struct VimBuffer {
var characters: [Character] = []
@@ -29,12 +29,8 @@ private struct VimBuffer {
var offset = 0
for character in contents.string {
let size = String(character).utf16.count
let hidden = contents.attribute(MarkdownHighlighting.hiddenMarkup, at: offset, effectiveRange: nil) as? Bool == true
|| contents.attribute(listWhitespace, at: offset, effectiveRange: nil) as? Bool == true
if !hidden {
characters.append(character)
ranges.append(NSRange(location: offset, length: size))
}
characters.append(character)
ranges.append(NSRange(location: offset, length: size))
offset += size
}
}
@@ -98,10 +94,12 @@ extension NotesTextView {
var vimMode: NotesVimMode { vimState.mode }
func resetVim() {
endInsertionSession()
let register = vimState.register, linewise = vimState.linewiseRegister
vimState = NotesVimState()
vimState.register = register
vimState.linewiseRegister = linewise
needsDisplay = true
vimModeChanged?()
}
@@ -113,19 +111,22 @@ extension NotesTextView {
}
private func setVimMode(_ mode: NotesVimMode) {
if vimState.mode == .insert { endInsertionSession() }
breakUndoCoalescing()
clearVimCommand()
vimState.mode = mode
vimState.anchor = nil
vimState.head = nil
vimState.column = nil
if mode == .insert { beginInsertionSession() }
needsDisplay = true
vimModeChanged?()
}
@discardableResult
func handleVimEscape() -> Bool {
guard vimEnabled, vimState.mode != .normal, !hasMarkedText() else { return false }
let buffer = VimBuffer(attributedString())
let buffer = VimBuffer(documentContents)
var head = vimState.head ?? buffer.index(selectedRange().location)
if vimState.mode == .insert && head > buffer.lineStart(head) { head -= 1 }
setVimMode(.normal)
@@ -192,11 +193,12 @@ extension NotesTextView {
vimState.count = min(9999, vimState.count * 10 + digit)
return true
}
let buffer = VimBuffer(attributedString())
let buffer = VimBuffer(documentContents)
if vimState.mode == .normal, selectedRange().length > 0 {
vimState.mode = .visual
vimState.anchor = buffer.index(selectedRange().location)
vimState.head = max(vimState.anchor!, buffer.index(NSMaxRange(selectedRange())) - 1)
needsDisplay = true
vimModeChanged?()
}
let origin = buffer.normal(vimState.head ?? buffer.index(selectedRange().location))
@@ -213,7 +215,7 @@ extension NotesTextView {
if key == "g" { vimState.awaitingG = true; return true }
if "dcy".contains(key) {
if visual {
let range = vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine)
let range = selectedRange()
applyVimOperator(key, range: range, linewise: vimState.mode == .visualLine)
} else if vimState.pending == key {
var end = buffer.lineEnd(origin)
@@ -305,22 +307,22 @@ extension NotesTextView {
setVimCaret(target, buffer: buffer)
insertNewline(nil)
if key == "O" {
let updated = VimBuffer(attributedString())
let updated = VimBuffer(documentContents)
let caret = updated.index(selectedRange().location)
setVimCaret(updated.lineStart(max(0, caret - 1)), buffer: updated)
}
case "x":
if visual { applyVimOperator("d", range: vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine), linewise: vimState.mode == .visualLine) }
if visual { applyVimOperator("d", range: selectedRange(), linewise: vimState.mode == .visualLine) }
else {
let end = min(buffer.lineEnd(origin), origin + count)
let startOffset = buffer.offset(origin), endOffset = buffer.offset(end)
if end > origin { applyVimOperator("d", range: vimSourceRange(for: NSRange(location: startOffset, length: endOffset - startOffset)), linewise: false) }
if end > origin { applyVimOperator("d", range: NSRange(location: startOffset, length: endOffset - startOffset), linewise: false) }
}
case "D", "C":
var end = buffer.lineEnd(origin)
for _ in 1..<count where buffer.newline(end) { end = buffer.lineEnd(end + 1) }
let startOffset = buffer.offset(origin), endOffset = buffer.offset(end)
applyVimOperator(key == "D" ? "d" : "c", range: vimSourceRange(for: NSRange(location: startOffset, length: endOffset - startOffset)), linewise: false)
applyVimOperator(key == "D" ? "d" : "c", range: NSRange(location: startOffset, length: endOffset - startOffset), linewise: false)
case "p", "P": pasteVimRegister(before: key == "P", count: count, origin: origin, buffer: buffer)
case "u":
setVimMode(.normal)
@@ -348,7 +350,7 @@ extension NotesTextView {
let high = min(buffer.count, max(origin, target) + (inclusive ? 1 : 0))
let start = buffer.offset(low), end = buffer.offset(high)
// A motion against a boundary is a no-op, not a character deletion.
if end > start { applyVimOperator(operation, range: vimSourceRange(for: NSRange(location: start, length: end - start)), linewise: false) }
if end > start { applyVimOperator(operation, range: NSRange(location: start, length: end - start), linewise: false) }
else { clearVimCommand() }
}
} else {
@@ -358,24 +360,21 @@ extension NotesTextView {
}
}
private func vimSourceRange(for rendered: NSRange, linewise: Bool = false) -> NSRange {
let range = markdownRange(for: MarkdownHighlighting.completeInlineRange(rendered, in: attributedString()))
return linewise ? (markdownString as NSString).lineRange(for: range) : range
}
private func applyVimLineOperator(_ operation: Character, start: Int, end: Int, buffer: VimBuffer) {
let source = markdownString as NSString
let startOffset = markdownRange(for: NSRange(location: buffer.offset(buffer.lineStart(start)), length: 0)).location
let endOffset = markdownRange(for: NSRange(location: buffer.offset(buffer.lineEnd(end)), length: 0)).location
let first = source.lineRange(for: NSRange(location: startOffset, length: 0)).location
let last = NSMaxRange(source.lineRange(for: NSRange(location: endOffset, length: 0)))
let first = buffer.offset(buffer.lineStart(start))
let lastLineEnd = buffer.lineEnd(end)
let last = buffer.offset(lastLineEnd + (buffer.newline(lastLineEnd) ? 1 : 0))
applyVimOperator(operation, range: NSRange(location: first, length: last - first), linewise: true)
}
private func applyVimOperator(_ operation: Character, range: NSRange, linewise: Bool) {
let source = markdownContents
let source = documentContents
let register = NSMutableAttributedString(attributedString: source.attributedSubstring(from: range))
if linewise && !register.string.hasSuffix("\n") { register.append(NSAttributedString(string: "\n")) }
if linewise && !register.string.hasSuffix("\n") {
var attributes = register.length > 0 ? register.attributes(at: register.length - 1, effectiveRange: nil) : [:]
attributes[.attachment] = nil
register.append(NSAttributedString(string: "\n", attributes: attributes))
}
vimState.register = register
vimState.linewiseRegister = linewise
let originalCaret = selectedRange().location
@@ -386,12 +385,14 @@ extension NotesTextView {
return
}
var deletion = range
var replacement = ""
var replacement = NSAttributedString(string: "")
var attributes = range.location < source.length ? source.attributes(at: range.location, effectiveRange: nil) : typingAttributes
attributes[.attachment] = nil
if linewise {
let text = source.string as NSString
if operation == "c" {
// cc keeps the following line separate while opening a blank line for insertion.
if NSMaxRange(range) < text.length { replacement = "\n" }
if NSMaxRange(range) < text.length { replacement = NSAttributedString(string: "\n", attributes: attributes) }
} else if NSMaxRange(range) == text.length, range.location > 0 {
let previous = text.rangeOfComposedCharacterSequence(at: range.location - 1)
if text.substring(with: previous).allSatisfy(\.isNewline) {
@@ -400,10 +401,9 @@ extension NotesTextView {
}
}
}
replaceMarkdown(replacement, range: deletion)
if operation == "c" {
setSelectedRange(renderedRange(for: NSRange(location: deletion.location, length: 0)))
} else { normalizeVimCaret() }
replaceDocument(replacement, range: deletion, selection: NSRange(location: deletion.location, length: 0))
if operation == "c" { typingAttributes = attributes }
else { normalizeVimCaret() }
}
private func pasteVimRegister(before: Bool, count: Int, origin: Int, buffer: VimBuffer) {
@@ -412,11 +412,10 @@ extension NotesTextView {
for _ in 0..<count { insertion.append(register) }
var range: NSRange
if vimState.mode == .visual || vimState.mode == .visualLine {
range = vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine)
range = selectedRange()
} else if vimState.linewiseRegister {
let source = markdownString as NSString
let position = markdownRange(for: NSRange(location: buffer.offset(origin), length: 0)).location
let line = source.lineRange(for: NSRange(location: position, length: 0))
let source = documentContents.string as NSString
let line = source.lineRange(for: NSRange(location: buffer.offset(origin), length: 0))
let offset = before ? line.location : NSMaxRange(line)
range = NSRange(location: offset, length: 0)
if offset == source.length, offset == 0 || !source.substring(from: offset - 1).allSatisfy(\.isNewline) {
@@ -427,20 +426,20 @@ extension NotesTextView {
}
} else {
let index = before ? origin : min(buffer.lineEnd(origin), origin + 1)
range = markdownInsertionRange(for: NSRange(location: buffer.offset(index), length: 0))
range = NSRange(location: buffer.offset(index), length: 0)
}
setVimMode(.normal)
replaceMarkdown(insertion, range: range)
replaceDocument(insertion, range: range)
let caret = vimState.linewiseRegister ? range.location + (insertion.string.hasPrefix("\n") ? 1 : 0) : range.location + insertion.length
setSelectedRange(renderedRange(for: NSRange(location: caret, length: 0)))
setSelectedRange(NSRange(location: caret, length: 0))
if !vimState.linewiseRegister {
let updated = VimBuffer(attributedString())
let updated = VimBuffer(documentContents)
setVimCaret(updated.normal(max(0, updated.index(selectedRange().location) - 1)), buffer: updated)
} else { normalizeVimCaret() }
}
private func normalizeVimCaret() {
let buffer = VimBuffer(attributedString())
let buffer = VimBuffer(documentContents)
setVimCaret(buffer.normal(buffer.index(selectedRange().location)), buffer: buffer)
vimState.anchor = nil
vimState.head = nil
+555
View File
@@ -0,0 +1,555 @@
import AppKit
import CryptoKit
import Markdown
public enum NoteAttributes {
public static let block = NSAttributedString.Key("ConjureNote.block")
public static let headingLevel = NSAttributedString.Key("ConjureNote.headingLevel")
public static let depth = NSAttributedString.Key("ConjureNote.depth")
public static let number = NSAttributedString.Key("ConjureNote.number")
public static let checked = NSAttributedString.Key("ConjureNote.checked")
public static let language = NSAttributedString.Key("ConjureNote.language")
public static let inlineCode = NSAttributedString.Key("ConjureNote.inlineCode")
public static let bold = NSAttributedString.Key("ConjureNote.bold")
public static let italic = NSAttributedString.Key("ConjureNote.italic")
}
/// The attributed string is the document; Markdown is only an import/export format.
@MainActor
public enum NoteDocument {
static let metadataFilename = "ConjureNote.json"
public static func importMarkdown(_ source: NSAttributedString) -> NSAttributedString {
MarkdownImporter(source).contents()
}
public static func markdown(_ contents: NSAttributedString) -> NSAttributedString {
let result = NSMutableAttributedString(string: "")
let text = contents.string as NSString
var cursor = 0
while cursor < contents.length {
let range = text.paragraphRange(for: NSRange(location: cursor, length: 0))
let attributes = contents.attributes(at: cursor, effectiveRange: nil)
let block = attributes[NoteAttributes.block] as? String
if block == "code" {
let language = attributes[NoteAttributes.language] as? String ?? ""
var end = NSMaxRange(range)
while end < contents.length,
contents.attribute(NoteAttributes.block, at: end, effectiveRange: nil) as? String == "code",
(contents.attribute(NoteAttributes.language, at: end, effectiveRange: nil) as? String ?? "") == language {
end = NSMaxRange(text.paragraphRange(for: NSRange(location: end, length: 0)))
}
let code = contents.attributedSubstring(from: NSRange(location: cursor, length: end - cursor))
let fence = String(repeating: "`", count: max(3, longestRun(of: "`", in: code.string) + 1))
result.append(NSAttributedString(string: fence + language.replacingOccurrences(of: "\n", with: " ") + "\n"))
result.append(code)
if !code.string.hasSuffix("\n") { result.append(NSAttributedString(string: "\n")) }
result.append(NSAttributedString(string: fence))
if code.string.hasSuffix("\n") || end < contents.length { result.append(NSAttributedString(string: "\n")) }
cursor = end
continue
}
let depth = max(0, attributes[NoteAttributes.depth] as? Int ?? 0)
var prefix = ""
switch block {
case "heading": prefix = String(repeating: "#", count: min(6, max(1, attributes[NoteAttributes.headingLevel] as? Int ?? 1))) + " "
case "quote": prefix = String(repeating: "> ", count: depth + 1)
case "bullet": prefix = String(repeating: " ", count: depth) + "- "
case "ordered": prefix = String(repeating: " ", count: depth) + "\(attributes[NoteAttributes.number] as? Int ?? 1). "
case "task": prefix = String(repeating: " ", count: depth) + ((attributes[NoteAttributes.checked] as? Bool ?? false) ? "- [x] " : "- [ ] ")
default: break
}
result.append(NSAttributedString(string: prefix))
var bodyEnd = NSMaxRange(range)
while bodyEnd > range.location, [10, 13, 0x2028, 0x2029].contains(text.character(at: bodyEnd - 1)) { bodyEnd -= 1 }
appendInline(contents, range: NSRange(location: cursor, length: bodyEnd - cursor), to: result)
if bodyEnd < NSMaxRange(range) {
result.append(contents.attributedSubstring(from: NSRange(location: bodyEnd, length: NSMaxRange(range) - bodyEnd)))
}
cursor = NSMaxRange(range)
}
return NSAttributedString(attributedString: result)
}
public static func fileWrapper(_ contents: NSAttributedString) throws -> FileWrapper {
let wrapper = try contents.fileWrapper(
from: NSRange(location: 0, length: contents.length),
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
)
guard wrapper.fileWrappers?[metadataFilename] == nil else { throw DocumentError.reservedMetadataFilename }
// Bind metadata to the text AppKit actually stores (RTF normalizes line endings).
guard let stored = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil) else {
throw DocumentError.corruptMetadata
}
guard stored.length == contents.length, stored.string == contents.string else {
throw DocumentError.textChangedDuringEncoding
}
let metadata = Metadata(version: 1, length: stored.length, digest: digest(stored.string), runs: runs(in: contents))
let json = FileWrapper(regularFileWithContents: try JSONEncoder().encode(metadata))
json.preferredFilename = metadataFilename
wrapper.addFileWrapper(json)
return wrapper
}
public static func contents(from wrapper: FileWrapper) throws -> NSAttributedString {
guard wrapper.isDirectory else { throw DocumentError.corruptMetadata }
try validate(wrapper)
guard let file = wrapper.fileWrappers?[metadataFilename], file.isRegularFile,
let data = file.regularFileContents else { throw DocumentError.corruptMetadata }
let metadata: Metadata
do { metadata = try JSONDecoder().decode(Metadata.self, from: data) }
catch { throw DocumentError.corruptMetadata }
guard metadata.version == 1 else { throw DocumentError.unsupportedVersion(metadata.version) }
guard let decoded = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil),
metadata.length == decoded.length, metadata.digest == digest(decoded.string) else {
throw DocumentError.corruptMetadata
}
let result = NSMutableAttributedString(attributedString: decoded)
var end = 0
for run in metadata.runs {
guard run.location == end, run.length > 0, run.location >= 0,
run.location <= result.length, run.length <= result.length - run.location,
run.values.isValid else { throw DocumentError.corruptMetadata }
result.addAttributes(run.values.attributes, range: NSRange(location: run.location, length: run.length))
end += run.length
}
guard end == result.length else { throw DocumentError.corruptMetadata }
return NSAttributedString(attributedString: result)
}
private static func validate(_ wrapper: FileWrapper) throws {
guard !wrapper.isSymbolicLink else { throw DocumentError.corruptMetadata }
if wrapper.isDirectory {
guard let children = wrapper.fileWrappers else { throw DocumentError.corruptMetadata }
for (name, child) in children {
guard !name.isEmpty, name != ".", name != "..",
!name.contains("/"), !name.contains("\\"),
child.preferredFilename.map({ !$0.isEmpty && $0 != "." && $0 != ".." && !$0.contains("/") && !$0.contains("\\") }) ?? true else { throw DocumentError.corruptMetadata }
try validate(child)
}
} else if !wrapper.isRegularFile { throw DocumentError.corruptMetadata }
}
public enum DocumentError: LocalizedError {
case corruptMetadata
case unsupportedVersion(Int)
case textChangedDuringEncoding
case reservedMetadataFilename
public var errorDescription: String? {
switch self {
case .corruptMetadata: "This note's structured metadata is missing, corrupt, or does not match its text. The original note was not changed."
case .unsupportedVersion(let version): "This note uses unsupported structured metadata version \(version). The original note was not changed."
case .textChangedDuringEncoding: "The note could not be saved without changing its text. The original note was not changed."
case .reservedMetadataFilename: "An attachment uses the reserved filename ConjureNote.json. Rename the attachment before saving. The original note was not changed."
}
}
}
private static func digest(_ string: String) -> String {
SHA256.hash(data: Data(string.utf8)).map { String(format: "%02x", $0) }.joined()
}
private struct Metadata: Codable {
let version: Int
let length: Int
let digest: String
let runs: [Run]
}
private struct Run: Codable {
let location: Int
let length: Int
let values: Values
}
private struct Values: Codable {
var block: String?
var headingLevel: Int?
var depth: Int?
var number: Int?
var checked: Bool?
var language: String?
var inlineCode: Bool?
var bold: Bool?
var italic: Bool?
var underline: Int?
var strike: Int?
var link: String?
init(_ attributes: [NSAttributedString.Key: Any]) {
block = attributes[NoteAttributes.block] as? String
headingLevel = attributes[NoteAttributes.headingLevel] as? Int
depth = attributes[NoteAttributes.depth] as? Int
number = attributes[NoteAttributes.number] as? Int
checked = attributes[NoteAttributes.checked] as? Bool
language = attributes[NoteAttributes.language] as? String
inlineCode = attributes[NoteAttributes.inlineCode] as? Bool
bold = attributes[NoteAttributes.bold] as? Bool
italic = attributes[NoteAttributes.italic] as? Bool
underline = attributes[.underlineStyle] as? Int
strike = attributes[.strikethroughStyle] as? Int
link = (attributes[.link] as? URL)?.absoluteString ?? attributes[.link] as? String
}
var isValid: Bool {
(block == nil || ["heading", "quote", "bullet", "ordered", "task", "code"].contains(block!))
&& (headingLevel == nil || (1...6).contains(headingLevel!))
&& (depth == nil || depth! >= 0)
&& (number == nil || number! >= 0)
}
var attributes: [NSAttributedString.Key: Any] {
var result: [NSAttributedString.Key: Any] = [:]
result[NoteAttributes.block] = block
result[NoteAttributes.headingLevel] = headingLevel
result[NoteAttributes.depth] = depth
result[NoteAttributes.number] = number
result[NoteAttributes.checked] = checked
result[NoteAttributes.language] = language
result[NoteAttributes.inlineCode] = inlineCode
result[NoteAttributes.bold] = bold
result[NoteAttributes.italic] = italic
result[.underlineStyle] = underline
result[.strikethroughStyle] = strike
result[.link] = link
return result
}
}
private static func runs(in contents: NSAttributedString) -> [Run] {
var result: [Run] = []
contents.enumerateAttributes(in: NSRange(location: 0, length: contents.length)) { attributes, range, _ in
result.append(Run(location: range.location, length: range.length, values: Values(attributes)))
}
return result
}
private static func longestRun(of character: Character, in text: String) -> Int {
var longest = 0
var count = 0
for value in text {
count = value == character ? count + 1 : 0
longest = max(longest, count)
}
return longest
}
private static func appendInline(_ contents: NSAttributedString, range: NSRange, to output: NSMutableAttributedString) {
let semantic = NSMutableAttributedString(string: (contents.string as NSString).substring(with: range))
contents.enumerateAttributes(in: range) { attributes, run, _ in
let traits = (attributes[.font] as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? []
var marks: [NSAttributedString.Key: Any] = [:]
if attributes[NoteAttributes.bold] as? Bool ?? traits.contains(.boldFontMask) { marks[NoteAttributes.bold] = true }
if attributes[NoteAttributes.italic] as? Bool ?? traits.contains(.italicFontMask) { marks[NoteAttributes.italic] = true }
if attributes[NoteAttributes.inlineCode] as? Bool ?? false { marks[NoteAttributes.inlineCode] = true }
if (attributes[.underlineStyle] as? Int ?? 0) != 0 { marks[.underlineStyle] = 1 }
if (attributes[.strikethroughStyle] as? Int ?? 0) != 0 { marks[.strikethroughStyle] = 1 }
marks[.link] = (attributes[.link] as? URL)?.absoluteString ?? attributes[.link] as? String
semantic.addAttributes(marks, range: NSRange(location: run.location - range.location, length: run.length))
}
// Markdown emphasis cannot open/close against whitespace. Trim each semantic
// span, not each display-font/color run; underline and links retain spaces.
let string = semantic.string as NSString
for key in [NoteAttributes.bold, NoteAttributes.italic, .strikethroughStyle] {
var edges: [NSRange] = []
semantic.enumerateAttribute(key, in: NSRange(location: 0, length: semantic.length)) { value, span, _ in
guard value != nil else { return }
var start = span.location
var end = NSMaxRange(span)
while start < end, string.character(at: start) == 32 || string.character(at: start) == 9 { start += 1 }
while end > start, string.character(at: end - 1) == 32 || string.character(at: end - 1) == 9 { end -= 1 }
if start > span.location { edges.append(NSRange(location: span.location, length: start - span.location)) }
if end < NSMaxRange(span) { edges.append(NSRange(location: end, length: NSMaxRange(span) - end)) }
}
for edge in edges { semantic.removeAttribute(key, range: edge) }
}
var active: [(open: String, close: String)] = []
semantic.enumerateAttributes(in: NSRange(location: 0, length: semantic.length)) { attributes, run, _ in
let value = contents.attributedSubstring(from: NSRange(location: range.location + run.location, length: run.length))
let text = value.string
let code = attributes[NoteAttributes.inlineCode] as? Bool ?? false
var desired: [(open: String, close: String)] = []
if let link = attributes[.link] as? String {
let destination = link.replacingOccurrences(of: "\\", with: "%5C").replacingOccurrences(of: " ", with: "%20").replacingOccurrences(of: "<", with: "%3C").replacingOccurrences(of: ">", with: "%3E").replacingOccurrences(of: "\n", with: "%0A")
desired.append(("[", "](<\(destination)>)"))
}
for (enabled, delimiter) in [(attributes[NoteAttributes.bold] as? Bool ?? false, "**"), (attributes[NoteAttributes.italic] as? Bool ?? false, "*"), ((attributes[.underlineStyle] as? Int ?? 0) != 0, "<u>"), ((attributes[.strikethroughStyle] as? Int ?? 0) != 0, "~~")] where enabled {
desired.append((delimiter, delimiter == "<u>" ? "</u>" : delimiter))
}
var common = 0
while common < min(active.count, desired.count),
active[common].open == desired[common].open, active[common].close == desired[common].close { common += 1 }
for mark in active.dropFirst(common).reversed() { output.append(NSAttributedString(string: mark.close)) }
for mark in desired.dropFirst(common) { output.append(NSAttributedString(string: mark.open)) }
active = desired
if code {
let fence = String(repeating: "`", count: max(1, longestRun(of: "`", in: text) + 1))
let pad = text.hasPrefix("`") || text.hasSuffix("`") || (text.hasPrefix(" ") && text.hasSuffix(" ") && !text.allSatisfy({ $0 == " " }))
output.append(NSAttributedString(string: fence + (pad ? " " : "")))
output.append(value)
output.append(NSAttributedString(string: (pad ? " " : "") + fence))
} else {
var index = 0
for character in text {
let length = String(character).utf16.count
if length == 1, "\\`*_{}[]<>()#+-.!|>~&".contains(character) { output.append(NSAttributedString(string: "\\")) }
output.append(value.attributedSubstring(from: NSRange(location: index, length: length)))
index += length
}
}
}
for mark in active.reversed() { output.append(NSAttributedString(string: mark.close)) }
}
}
@MainActor
private final class MarkdownImporter {
private let source: NSAttributedString
private let text: NSString
private let bytes: [UInt8]
private let lineStarts: [Int]
private let utf16Offsets: [Int]
private let result: NSMutableAttributedString
private var deletions: [NSRange] = []
private var replacements: [(NSRange, NSAttributedString)] = []
init(_ source: NSAttributedString) {
self.source = source
text = source.string as NSString
bytes = Array(source.string.utf8)
var starts = [0]
for (index, byte) in bytes.enumerated() where byte == 10 { starts.append(index + 1) }
lineStarts = starts
var offsets = Array(repeating: 0, count: bytes.count + 1)
var byteOffset = 0
var utf16Offset = 0
for scalar in source.string.unicodeScalars {
for index in 0..<scalar.utf8.count { offsets[byteOffset + index] = utf16Offset }
byteOffset += scalar.utf8.count
utf16Offset += scalar.utf16.count
}
offsets[bytes.count] = utf16Offset
utf16Offsets = offsets
result = NSMutableAttributedString(attributedString: source)
}
func contents() -> NSAttributedString {
let document = Document(parsing: source.string)
visit(document)
var merged: [NSRange] = []
for range in deletions.filter({ $0.length > 0 }).sorted(by: { $0.location < $1.location }) {
if let last = merged.last, NSMaxRange(last) >= range.location {
merged[merged.count - 1] = NSUnionRange(last, range)
} else { merged.append(range) }
}
var edits = replacements
for range in merged where !replacements.contains(where: { NSIntersectionRange($0.0, range).length > 0 }) {
edits.append((range, NSAttributedString(string: "")))
}
for (range, value) in edits.sorted(by: { $0.0.location > $1.0.location }) {
result.replaceCharacters(in: range, with: value)
}
return NSAttributedString(attributedString: result)
}
private func offset(_ location: SourceLocation) -> Int {
guard location.line > 0, location.line <= lineStarts.count else { return text.length }
let byte = min(bytes.count, lineStarts[location.line - 1] + max(0, location.column - 1))
return utf16Offsets[byte]
}
private func range(_ markup: any Markup) -> NSRange? {
guard let range = markup.range else { return nil }
let start = offset(range.lowerBound)
let end = offset(range.upperBound)
guard end >= start else { return nil }
return NSRange(location: start, length: end - start)
}
private func mark(_ range: NSRange, _ attributes: [NSAttributedString.Key: Any], paragraph: Bool = false) {
let target = paragraph ? text.paragraphRange(for: range) : range
result.addAttributes(attributes, range: target)
}
private func inlineContents(_ node: any Markup, sourceRange: NSRange) -> NSAttributedString {
var cursor = sourceRange.location
var attachments: [NSTextAttachment] = []
source.enumerateAttribute(.attachment, in: sourceRange) { value, _, _ in
if let attachment = value as? NSTextAttachment { attachments.append(attachment) }
}
var attachmentIndex = 0
func literal(_ string: String) -> NSMutableAttributedString {
let remaining = NSRange(location: cursor, length: max(0, NSMaxRange(sourceRange) - cursor))
let match = text.range(of: string, options: [], range: remaining)
let value: NSMutableAttributedString
if !string.isEmpty, match.location != NSNotFound {
value = NSMutableAttributedString(attributedString: result.attributedSubstring(from: match))
cursor = NSMaxRange(match)
} else {
let attributes = cursor < result.length ? result.attributes(at: cursor, effectiveRange: nil) : [:]
value = NSMutableAttributedString(string: string, attributes: attributes)
}
let characters = string as NSString
for index in 0..<characters.length where characters.character(at: index) == 0xfffc {
if attachmentIndex < attachments.count {
value.addAttribute(.attachment, value: attachments[attachmentIndex], range: NSRange(location: index, length: 1))
}
attachmentIndex += 1
}
return value
}
func children(_ container: any Markup) -> NSMutableAttributedString {
let output = NSMutableAttributedString(string: "")
var underline: [(offset: Int, literal: String)] = []
for child in container.children {
if let html = child as? InlineHTML {
if html.rawHTML.lowercased() == "<u>" { underline.append((output.length, html.rawHTML)); continue }
if html.rawHTML.lowercased() == "</u>", let start = underline.popLast() {
output.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: start.offset, length: output.length - start.offset))
continue
}
}
output.append(render(child))
}
// Unmatched HTML is unsupported literal content, not a disappearing delimiter.
for start in underline.reversed() { output.insert(NSAttributedString(string: start.literal), at: start.offset) }
return output
}
func render(_ child: any Markup) -> NSMutableAttributedString {
switch child {
case let value as Text: return literal(value.string)
case let value as InlineCode:
let output = literal(value.code)
output.addAttribute(NoteAttributes.inlineCode, value: true, range: NSRange(location: 0, length: output.length))
return output
case is SoftBreak, is LineBreak:
let remaining = NSRange(location: cursor, length: max(0, NSMaxRange(sourceRange) - cursor))
let newline = text.rangeOfCharacter(from: .newlines, options: [], range: remaining)
let output = NSMutableAttributedString(string: "")
if newline.location != NSNotFound {
var start = newline.location
while start > cursor, text.character(at: start - 1) == 32 || text.character(at: start - 1) == 9 { start -= 1 }
if start < newline.location { output.append(result.attributedSubstring(from: NSRange(location: start, length: newline.location - start))) }
cursor = newline.location
}
output.append(literal("\n"))
return output
case let html as InlineHTML: return literal(html.rawHTML)
case is Strong, is Emphasis, is Strikethrough, is Link:
let output = children(child)
let range = NSRange(location: 0, length: output.length)
if child is Strong { output.addAttribute(NoteAttributes.bold, value: true, range: range) }
if child is Emphasis { output.addAttribute(NoteAttributes.italic, value: true, range: range) }
if child is Strikethrough { output.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range) }
if let link = child as? Link, let destination = link.destination { output.addAttribute(.link, value: destination, range: range) }
return output
default: return literal(child.format())
}
}
let output = children(node)
// The parser deliberately omits terminal Markdown hard-break whitespace.
// It is still native note text and must survive migration/import.
var suffix = NSMaxRange(sourceRange)
while suffix > sourceRange.location, text.character(at: suffix - 1) == 32 || text.character(at: suffix - 1) == 9 { suffix -= 1 }
let rendered = output.string as NSString
var renderedSuffix = rendered.length
while renderedSuffix > 0, rendered.character(at: renderedSuffix - 1) == 32 || rendered.character(at: renderedSuffix - 1) == 9 { renderedSuffix -= 1 }
let missing = NSMaxRange(sourceRange) - suffix - (rendered.length - renderedSuffix)
if missing > 0 { output.append(result.attributedSubstring(from: NSRange(location: NSMaxRange(sourceRange) - missing, length: missing))) }
return output
}
private func visit(_ node: any Markup, listDepth: Int = 0) {
guard let whole = range(node) else {
for child in node.children { visit(child, listDepth: listDepth) }
return
}
switch node {
case let heading as Heading:
mark(whole, [NoteAttributes.block: "heading", NoteAttributes.headingLevel: heading.level], paragraph: true)
replacements.append((whole, inlineContents(node, sourceRange: whole)))
return
case let code as CodeBlock:
let full = text.paragraphRange(for: whole)
var value = code.code
if NSMaxRange(full) == text.length, !source.string.hasSuffix("\n"), value.hasSuffix("\n") { value.removeLast() }
// An empty code paragraph still needs a real character on which semantics can live.
if value.isEmpty { value = "\n" }
var attributes: [NSAttributedString.Key: Any] = [NoteAttributes.block: "code"]
if let language = code.language { attributes[NoteAttributes.language] = language }
let valueContents = NSMutableAttributedString(string: value, attributes: whole.location < result.length ? result.attributes(at: whole.location, effectiveRange: nil) : [:])
valueContents.addAttributes(attributes, range: NSRange(location: 0, length: valueContents.length))
var attachmentIndex = 0
var attachments: [NSTextAttachment] = []
source.enumerateAttribute(.attachment, in: full) { value, _, _ in
if let attachment = value as? NSTextAttachment { attachments.append(attachment) }
}
let characters = value as NSString
for index in 0..<characters.length where characters.character(at: index) == 0xfffc {
if attachmentIndex < attachments.count { valueContents.addAttribute(.attachment, value: attachments[attachmentIndex], range: NSRange(location: index, length: 1)) }
attachmentIndex += 1
}
replacements.append((full, valueContents))
return
case is BlockQuote:
var quoteDepth = 0
var ancestor = node.parent
while let current = ancestor {
if current is BlockQuote { quoteDepth += 1 }
ancestor = current.parent
}
mark(whole, [NoteAttributes.block: "quote", NoteAttributes.depth: quoteDepth], paragraph: true)
var cursor = text.paragraphRange(for: NSRange(location: whole.location, length: 0)).location
while cursor < NSMaxRange(whole) {
let line = text.paragraphRange(for: NSRange(location: cursor, length: 0))
var end = cursor
while end < NSMaxRange(line), text.character(at: end) == 32 { end += 1 }
while end < NSMaxRange(line), text.character(at: end) == 62 {
end += 1
if end < NSMaxRange(line), text.character(at: end) == 32 { end += 1 }
}
if end > cursor { deletions.append(NSRange(location: cursor, length: end - cursor)) }
cursor = NSMaxRange(line)
}
case is OrderedList, is UnorderedList:
let ordered = node as? OrderedList
var number = Int(ordered?.startIndex ?? 1)
for child in node.children {
guard let item = child as? ListItem else { continue }
for block in item.children {
if block is OrderedList || block is UnorderedList {
visit(block, listDepth: listDepth + 1)
} else {
if let itemRange = range(item), let body = range(block) {
var attributes: [NSAttributedString.Key: Any] = [NoteAttributes.block: ordered == nil ? "bullet" : "ordered", NoteAttributes.depth: listDepth]
if ordered != nil { attributes[NoteAttributes.number] = number }
if let checked = item.checkbox {
attributes[NoteAttributes.block] = "task"
switch checked { case .checked: attributes[NoteAttributes.checked] = true; case .unchecked: attributes[NoteAttributes.checked] = false }
}
mark(body, attributes, paragraph: true)
if body.location > itemRange.location, block.indexInParent == 0 {
let start = text.paragraphRange(for: NSRange(location: itemRange.location, length: 0)).location
deletions.append(NSRange(location: start, length: body.location - start))
}
}
visit(block, listDepth: listDepth)
}
}
number += 1
}
return
case is Paragraph:
replacements.append((whole, inlineContents(node, sourceRange: whole)))
return
case is Document, is ListItem:
break
default:
// Tables, HTML, remote image syntax and other unsupported constructs stay literal.
return
}
for child in node.children { visit(child, listDepth: listDepth) }
}
}
@@ -10,6 +10,7 @@ public struct NoteMarkdownExport {
private let images: [(name: String, data: Data)]
public init(contents: NSAttributedString) throws {
let contents = NoteDocument.markdown(contents)
let source = contents.string as NSString
var markdown = ""
var clipboard = ""
+36 -10
View File
@@ -24,6 +24,7 @@ public struct Note: Identifiable {
public final class NotesStore {
public private(set) var notes: [Note] = []
private let directory: URL
private var legacyIDs: Set<UUID> = []
public init(directory: URL = FileManager.default.urls(
for: .applicationSupportDirectory, in: .userDomainMask
@@ -34,16 +35,26 @@ public final class NotesStore {
at: directory, includingPropertiesForKeys: nil
).filter { $0.pathExtension.lowercased() == "rtfd" }
.sorted { $0.lastPathComponent < $1.lastPathComponent }
notes = try files.map { url in
var loaded: [Note] = []
for url in files {
guard let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent) else {
throw CocoaError(.fileReadCorruptFile)
}
let contents = try NSAttributedString(
url: url, options: [.documentType: NSAttributedString.DocumentType.rtfd],
documentAttributes: nil
)
return Note(id: id, contents: contents)
let wrapper = try FileWrapper(url: url, options: .immediate)
guard wrapper.isDirectory else { throw CocoaError(.fileReadCorruptFile) }
let contents: NSAttributedString
if wrapper.fileWrappers?[NoteDocument.metadataFilename] != nil {
contents = try NoteDocument.contents(from: wrapper)
} else {
guard let legacy = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil) else {
throw CocoaError(.fileReadCorruptFile)
}
contents = NoteDocument.importMarkdown(legacy)
legacyIDs.insert(id)
}
loaded.append(Note(id: id, contents: contents))
}
notes = loaded
}
public func create() throws -> UUID {
@@ -69,14 +80,29 @@ public final class NotesStore {
}
try FileManager.default.removeItem(at: directory.appendingPathComponent(id.uuidString + ".rtfd"))
notes.remove(at: index)
legacyIDs.remove(id)
}
private func save(_ note: Note) throws {
let wrapper = try note.contents.fileWrapper(
from: NSRange(location: 0, length: note.contents.length),
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
)
let wrapper = try NoteDocument.fileWrapper(note.contents)
let url = directory.appendingPathComponent(note.id.uuidString + ".rtfd", isDirectory: true)
if legacyIDs.contains(note.id) {
let backups = directory.appendingPathComponent("LegacyBackups", isDirectory: true)
try FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true)
let backup = backups.appendingPathComponent(note.id.uuidString + ".rtfd", isDirectory: true)
if !FileManager.default.fileExists(atPath: backup.path) {
// copyItem leaves the live legacy package untouched if backup creation fails.
try FileManager.default.copyItem(at: url, to: backup)
} else {
// A previous failed save may already have preserved the original package.
let original = try FileWrapper(url: backup, options: .immediate)
guard original.isDirectory,
NSAttributedString(rtfdFileWrapper: original, documentAttributes: nil) != nil else {
throw CocoaError(.fileWriteFileExists)
}
}
}
try wrapper.write(to: url, options: .atomic, originalContentsURL: url)
legacyIDs.remove(note.id)
}
}
+6 -2
View File
@@ -12,7 +12,7 @@
- typing `#{1,6} ` at the visible paragraph start sets the absolute heading level, replacing any existing level; fenced code remains literal.
- Backspace at heading start deletes the preceding newline, not the heading prefix; at document start it does nothing.
- joining onto nonempty prose inherits the preceding paragraph's type: `Text\n## Heading` becomes `TextHeading`; removing an empty line above preserves the heading.
- keep native attributed-text editing, Undo, images, and Markdown serialization; retain RTFD autosaves rather than introducing a full document-model rewrite.
- keep native attributed-text editing, Undo, images, Markdown export, and RTFD autosaves; structured paragraph metadata is stored alongside RTFD content.
- [x] add whole-note Markdown export actions to the Notes Actions menu:
- Copy as Markdown: replace images with inline-code placeholders such as `<photo.png>`.
- Export as Markdown…: save a single `.md` file without images; with images, create a folder containing the Markdown and image files, using relative links.
@@ -21,10 +21,14 @@
- [x] keep the caret on the heading's visible line after Backspace and Return, not on the preceding line.
- [x] improve:
- add cmd+shift+s for strikethrough and cmd+shift+u for underline
- don't hide the backticks surrounding a codeblock
- code-block fences were made visible in source editing; superseded by the structured editing cutover below.
- give inline codeblocks (e.g. `some code snippet`) an inline container and an orange text color matching the color scheme of the conjure application
- [x] feature: vim motions
- opt-in Normal/Insert/Visual editing, counted motions, delete/change/yank, rich-text paste, and native Undo/Redo.
- [x] use Notion-style structured editing instead of hidden Markdown source:
- Markdown input shortcuts become attributed marks and paragraph types; native editing and Vim share visible offsets and Undo.
- preserve image bytes and versioned semantic metadata in RTFD packages; back up legacy packages before their first structured save.
- generate normalized Markdown for export; plain-text paste stays literal.
- [ ] feature: configurable color scheme
- general design should stay consistent
- only colors should be configurable; not the way the colors are used/presented (e.g. frosted glass effect with color blobs behind shouldn't change)
@@ -1,17 +1,18 @@
import AppKit
import ConjureKit
import Testing
@testable import Conjure
@Test @MainActor func fencedCodeHighlightsTokensWithoutChangingSourceOrApplyingMarkdown() throws {
@Test @MainActor
func codeBlocksHighlightTokensWithoutInterpretingTheirLiteralMarkdown() throws {
_ = NSApplication.shared
for fence in ["```", "~~~~"] {
let text = "# Heading 🦊\n\(fence)py\ndef main():\n # comment\n return \"**not bold**\"\n\(fence)\nAfter\n```unknown-language\n# literal\n```"
let editor = NSTextView()
editor.string = text
MarkdownHighlighting.apply(to: editor)
let source = "# Heading 🦊\n\(fence)py\ndef main():\n # comment\n return \"**not bold**\"\n\(fence)\nAfter\n```unknown-language\n# literal\n```"
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 500))
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
let storage = try #require(editor.textStorage)
func attributes(_ token: String) -> [NSAttributedString.Key: Any] {
storage.attributes(at: (text as NSString).range(of: token).location, effectiveRange: nil)
storage.attributes(at: (editor.string as NSString).range(of: token).location, effectiveRange: nil)
}
let keyword = try #require(attributes("def")[.foregroundColor] as? NSColor)
let name = try #require(attributes("main")[.foregroundColor] as? NSColor)
@@ -19,20 +20,23 @@ import Testing
#expect(keyword != name)
#expect(comment != keyword)
#expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true)
#expect(storage.string == text)
editor.string = "```python\ndef main():\n pass"
MarkdownHighlighting.apply(to: editor)
#expect(attributes("**not bold**")[NoteAttributes.bold] as? Bool != true)
#expect(attributes("# literal")[NoteAttributes.headingLevel] == nil)
#expect(attributes("# literal")[NoteAttributes.block] as? String == "code")
#expect(editor.string == "Heading 🦊\ndef main():\n # comment\n return \"**not bold**\"\nAfter\n# literal")
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "```python\ndef main():\n pass")))
let unfinished = try #require(editor.textStorage)
#expect(unfinished.attribute(.foregroundColor, at: 10, effectiveRange: nil) as? NSColor == keyword)
#expect(unfinished.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == keyword)
#expect(editor.string == "def main():\n pass")
}
}
@Test @MainActor func noteHeadingsShrinkByLevelWithoutChangingMarkdownOrCode() throws {
@Test @MainActor
func headingLevelsUseDescendingSizesAndCodeRemainsBodySized() throws {
_ = NSApplication.shared
let editor = NSTextView()
let text = "# One\n## Two\n### Three\n#### Four\n##### Five\n###### Six\nBody\n## **Bold** and *italic*\n```text\n# Literal\n```\n####### Not a heading"
editor.string = text
MarkdownHighlighting.apply(to: editor)
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 600))
let source = "# One\n## Two\n### Three\n#### Four\n##### Five\n###### Six\nBody\n## **Bold** and *italic*\n```text\n# Literal\n```\n####### Not a heading"
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
let storage = try #require(editor.textStorage)
func font(_ token: String) throws -> NSFont {
try #require(storage.attribute(.font, at: (editor.string as NSString).range(of: token).location,
@@ -41,13 +45,14 @@ import Testing
let sizes = try ["One", "Two", "Three", "Four", "Five", "Six"].map { try font($0).pointSize }
for (larger, smaller) in zip(sizes, sizes.dropFirst()) { #expect(larger > smaller) }
#expect(try font("Six").pointSize >= font("Body").pointSize)
#expect(try font("**Bold**").pointSize == font("Two").pointSize)
#expect(try font("*italic*").pointSize == font("Two").pointSize)
#expect(try font("Bold").pointSize == font("Two").pointSize)
#expect(try font("italic").pointSize == font("Two").pointSize)
#expect(try font("Literal").pointSize == font("Body").pointSize)
#expect(try font("Not a heading").pointSize == font("Body").pointSize)
#expect(try font("Three").isFixedPitch)
#expect(editor.string == text)
storage.replaceCharacters(in: (editor.string as NSString).range(of: "### Three"), with: "Three")
MarkdownHighlighting.apply(to: editor)
let three = (editor.string as NSString).range(of: "Three")
editor.setSelectedRange(three)
for _ in 0..<4 { editor.format(0) }
#expect(editor.string.contains("Three"))
#expect(try font("Three").pointSize == font("Body").pointSize)
}
@@ -0,0 +1,184 @@
import AppKit
import ConjureKit
import Testing
@Test @MainActor
func markdownImportProducesVisibleTextAndSemanticFormatting() throws {
let markdown = "# Title 🦊\n**bold** *italic* ***both*** <u>under</u> ~~gone~~ `let x = 1` [site](https://example.com)\n\n> quote\n\n- [ ] todo\n- [x] done\n - nested\n\n3. third\n4. fourth\n\n```swift\n# literal **code**\n```\n"
let document = NoteDocument.importMarkdown(NSAttributedString(string: markdown))
#expect(document.string == "Title 🦊\nbold italic both under gone let x = 1 site\n\nquote\n\ntodo\ndone\nnested\n\nthird\nfourth\n\n# literal **code**\n")
func attribute(_ key: NSAttributedString.Key, _ text: String) throws -> Any? {
let range = (document.string as NSString).range(of: text)
#expect(range.location != NSNotFound)
return document.attribute(key, at: range.location, effectiveRange: nil)
}
#expect(try attribute(NoteAttributes.block, "Title") as? String == "heading")
#expect(try attribute(NoteAttributes.headingLevel, "Title") as? Int == 1)
#expect(try attribute(NoteAttributes.bold, "bold") as? Bool == true)
#expect(try attribute(NoteAttributes.italic, "italic") as? Bool == true)
#expect(try attribute(NoteAttributes.bold, "both") as? Bool == true)
#expect(try attribute(NoteAttributes.italic, "both") as? Bool == true)
#expect(try attribute(.underlineStyle, "under") as? Int == NSUnderlineStyle.single.rawValue)
#expect(try attribute(.strikethroughStyle, "gone") as? Int == NSUnderlineStyle.single.rawValue)
#expect(try attribute(NoteAttributes.inlineCode, "let x") as? Bool == true)
#expect(try attribute(.link, "site") as? String == "https://example.com")
#expect(try attribute(NoteAttributes.block, "quote") as? String == "quote")
#expect(try attribute(NoteAttributes.checked, "todo") as? Bool == false)
#expect(try attribute(NoteAttributes.checked, "done") as? Bool == true)
#expect(try attribute(NoteAttributes.depth, "nested") as? Int == 1)
#expect(try attribute(NoteAttributes.number, "third") as? Int == 3)
#expect(try attribute(NoteAttributes.number, "fourth") as? Int == 4)
#expect(try attribute(NoteAttributes.block, "# literal") as? String == "code")
#expect(try attribute(NoteAttributes.language, "# literal") as? String == "swift")
#expect(try attribute(NoteAttributes.bold, "**code**") == nil)
let restored = NoteDocument.importMarkdown(NoteDocument.markdown(document))
#expect(restored.string == document.string)
}
@Test @MainActor
func markdownImportHandlesEveryHeadingEscapesAndUnsupportedLiteralContent() {
for level in 1...6 {
let document = NoteDocument.importMarkdown(NSAttributedString(string: String(repeating: "#", count: level) + " Heading"))
#expect(document.string == "Heading")
#expect(document.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level)
}
let escaped = NoteDocument.importMarkdown(NSAttributedString(string: "\\*literal\\* &amp; café 🦊"))
#expect(escaped.string == "*literal* & café 🦊")
#expect(escaped.attribute(NoteAttributes.italic, at: 0, effectiveRange: nil) == nil)
for literal in ["![remote](https://example.com/image.png)", "<div>literal **content**</div>", "| A | B |\n| - | - |\n| x | y |", "---"] {
#expect(NoteDocument.importMarkdown(NSAttributedString(string: literal)).string == literal)
}
}
@Test @MainActor
func markdownConversionPreservesNativeAttachmentsAndLiteralCode() throws {
let attachment = NSTextAttachment()
let source = NSMutableAttributedString(string: "**fox ")
source.append(NSAttributedString(attachment: attachment))
source.append(NSAttributedString(string: "**\n```text\n`literal` **not bold**\n```"))
let document = NoteDocument.importMarkdown(source)
#expect(document.string == "fox \u{fffc}\n`literal` **not bold**")
#expect(document.attribute(.attachment, at: 4, effectiveRange: nil) as? NSTextAttachment === attachment)
let markdown = NoteDocument.markdown(document)
#expect(markdown.string.contains("```text\n`literal` **not bold**\n```"))
let location = (markdown.string as NSString).range(of: "\u{fffc}").location
#expect(markdown.attribute(.attachment, at: location, effectiveRange: nil) as? NSTextAttachment === attachment)
}
@Test @MainActor
func markdownExportRecognizesNativeMarksAndEscapesLiteralSyntax() {
let native = NSMutableAttributedString(string: "bold italic under strike link literal *stars* #tag")
let text = native.string as NSString
native.addAttribute(.font, value: NSFont.boldSystemFont(ofSize: 14), range: text.range(of: "bold"))
native.addAttribute(.font, value: NSFontManager.shared.convert(NSFont.systemFont(ofSize: 14), toHaveTrait: .italicFontMask), range: text.range(of: "italic"))
native.addAttribute(.underlineStyle, value: 1, range: text.range(of: "under"))
native.addAttribute(.strikethroughStyle, value: 1, range: text.range(of: "strike"))
native.addAttribute(.link, value: URL(string: "https://example.com")!, range: text.range(of: "link"))
let output = NoteDocument.markdown(native)
#expect(output.string == "**bold** *italic* <u>under</u> ~~strike~~ [link](<https://example.com>) literal \\*stars\\* \\#tag")
#expect(NoteDocument.importMarkdown(output).string == native.string)
native.addAttribute(NoteAttributes.bold, value: false, range: text.range(of: "bold"))
#expect(NoteDocument.markdown(native).string.hasPrefix("bold "))
}
@Test @MainActor
func structuredPackageRoundTripsAllSemanticAttributesIncludingFalseAndZero() throws {
let document = NSMutableAttributedString(attributedString: NoteDocument.importMarkdown(NSAttributedString(string: "###### Heading\n- [ ] todo\n 0. nested\n```swift\nlet x = 1\n```\n**bold** *italic* <u>under</u> ~~strike~~ `code` [link](https://example.com)")))
document.addAttributes([NoteAttributes.bold: false, NoteAttributes.italic: false, NoteAttributes.inlineCode: false, NoteAttributes.depth: 0], range: NSRange(location: 0, length: 1))
let wrapper = try NoteDocument.fileWrapper(document)
let serialized = try #require(wrapper.serializedRepresentation)
let received = try #require(FileWrapper(serializedRepresentation: serialized))
let restored = try NoteDocument.contents(from: received)
#expect(restored.string == document.string)
let keys: [NSAttributedString.Key] = [NoteAttributes.block, NoteAttributes.headingLevel, NoteAttributes.depth, NoteAttributes.number, NoteAttributes.checked, NoteAttributes.language, NoteAttributes.inlineCode, NoteAttributes.bold, NoteAttributes.italic, .underlineStyle, .strikethroughStyle, .link]
for index in 0..<document.length {
for key in keys {
let expected = document.attribute(key, at: index, effectiveRange: nil)
let actual = restored.attribute(key, at: index, effectiveRange: nil)
if let expected { #expect((expected as? NSObject)?.isEqual(actual) == true) }
else { #expect(actual == nil || (key == .underlineStyle || key == .strikethroughStyle) && actual as? Int == 0) }
}
}
}
@Test @MainActor
func structuredPackagesRejectMissingCorruptMismatchedAndUnsafeMetadata() throws {
let document = NSAttributedString(string: "safe text")
for mutation in ["version", "length", "digest", "runs"] {
let wrapper = try NoteDocument.fileWrapper(document)
let original = try #require(wrapper.fileWrappers?["ConjureNote.json"])
let data = try #require(original.regularFileContents)
var metadata = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
switch mutation {
case "version": metadata[mutation] = 999
case "length": metadata[mutation] = 999
case "digest": metadata[mutation] = "not the text digest"
default: metadata[mutation] = [["location": 1, "length": 9, "values": [:]]]
}
wrapper.removeFileWrapper(original)
let replacement = FileWrapper(regularFileWithContents: try JSONSerialization.data(withJSONObject: metadata))
replacement.preferredFilename = "ConjureNote.json"
wrapper.addFileWrapper(replacement)
#expect(throws: NoteDocument.DocumentError.self) { try NoteDocument.contents(from: wrapper) }
}
let missing = try document.fileWrapper(from: NSRange(location: 0, length: document.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd])
#expect(throws: NoteDocument.DocumentError.self) { try NoteDocument.contents(from: missing) }
let unsafe = try NoteDocument.fileWrapper(document)
let link = FileWrapper(symbolicLinkWithDestinationURL: URL(fileURLWithPath: "/etc/passwd"))
link.preferredFilename = "outside"
unsafe.addFileWrapper(link)
#expect(throws: NoteDocument.DocumentError.self) { try NoteDocument.contents(from: unsafe) }
}
@Test @MainActor
func structuredPackagesRejectNonPackageRootsAndReservedAttachmentFilenames() throws {
#expect(throws: NoteDocument.DocumentError.self) {
try NoteDocument.contents(from: FileWrapper(regularFileWithContents: Data()))
}
let attachment = FileWrapper(regularFileWithContents: Data("attachment".utf8))
attachment.preferredFilename = "ConjureNote.json"
#expect(throws: NoteDocument.DocumentError.self) {
try NoteDocument.fileWrapper(NSAttributedString(attachment: NSTextAttachment(fileWrapper: attachment)))
}
}
@Test @MainActor
func markdownLazyContinuationUsesASTTextWithoutDuplicatingSourceOrLosingLinebreaks() {
for (source, expected) in [
("- [ ] task\nnext", "task\nnext"),
("- [ ] task\n**bold** and `code`\nlast", "task\nbold and code\nlast"),
("> quote\n[link](https://example.com)\nnext", "quote\nlink\nnext"),
("- item\n***both*** <u>under</u>\nlast", "item\nboth under\nlast"),
] {
let contents = NoteDocument.importMarkdown(NSAttributedString(string: source))
#expect(contents.string == expected)
}
let attachment = NSTextAttachment()
let source = NSMutableAttributedString(string: "# Heading\n- [ ] task\n**bold** and `code`\n")
source.append(NSAttributedString(attachment: attachment))
let contents = NoteDocument.importMarkdown(source)
#expect(contents.string == "Heading\ntask\nbold and code\n\u{fffc}")
#expect(contents.attribute(.attachment, at: contents.length - 1, effectiveRange: nil) as? NSTextAttachment === attachment)
let bold = (contents.string as NSString).range(of: "bold")
#expect(contents.attribute(NoteAttributes.bold, at: bold.location, effectiveRange: nil) as? Bool == true)
let code = (contents.string as NSString).range(of: "code")
#expect(contents.attribute(NoteAttributes.inlineCode, at: code.location, effectiveRange: nil) as? Bool == true)
}
@Test @MainActor
func markdownExportGroupsSemanticMarksAcrossDisplayRunsAndKeepsWhitespace() {
let contents = NSMutableAttributedString(string: "fox 🦊 \n")
let marked = NSRange(location: 0, length: (contents.string as NSString).range(of: " \n").location)
contents.addAttributes([.underlineStyle: 1, NoteAttributes.bold: false, NoteAttributes.italic: false], range: NSRange(location: 0, length: contents.length))
contents.addAttribute(.font, value: NSFont.systemFont(ofSize: 14), range: NSRange(location: 0, length: 4))
contents.addAttributes([.font: NSFont.systemFont(ofSize: 18), .foregroundColor: NSColor.red], range: NSRange(location: 4, length: marked.length - 4))
#expect(NoteDocument.markdown(contents).string == "<u>fox 🦊 </u>\n")
contents.addAttribute(NoteAttributes.bold, value: true, range: marked)
#expect(NoteDocument.markdown(contents).string == "**<u>fox 🦊</u>**<u> </u>\n")
for source in ["- [ ] todo \n", "first \nsecond \t\n", "trailing "] {
let imported = NoteDocument.importMarkdown(NSAttributedString(string: source))
let expected = source.hasPrefix("- [ ] ") ? String(source.dropFirst(6)) : source
#expect(imported.string == expected)
}
}
@@ -7,7 +7,7 @@ func markdownExportPreservesWholeTextAndSnapshotsBeforeWriting() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
let text = "\n # 日本語 🦊 \r\n**bold** and `code`\n- [ ] task\n\u{fffc}\n"
let text = "\n日本語 🦊 \r\nplain and code\nordinary task\n\u{fffc}\n"
let source = NSMutableAttributedString(string: text)
source.addAttribute(.attachment, value: NSTextAttachment(), range: NSRange(location: 0, length: 1))
let export = try NoteMarkdownExport(contents: source)
@@ -32,13 +32,13 @@ func markdownExportWritesOriginalImageBytesAndEscapedLinksWithoutChangingSource(
wrapper.preferredFilename = name
let originalFilename = wrapper.filename
let attachment = NSTextAttachment(fileWrapper: wrapper)
let source = NSMutableAttributedString(string: "\n # 🦊\n")
let source = NSMutableAttributedString(attributedString: NoteDocument.importMarkdown(NSAttributedString(string: "\n# 🦊\n")))
source.append(NSAttributedString(attachment: attachment))
source.append(NSAttributedString(string: "\n- [ ] todo \n"))
source.append(NoteDocument.importMarkdown(NSAttributedString(string: "\n- [ ] todo \n")))
let before = NSAttributedString(attributedString: source)
let export = try NoteMarkdownExport(contents: source)
#expect(export.hasImages)
#expect(export.clipboardText == "\n # 🦊\n``<pixel [x] `1` #%.png>``\n- [ ] todo \n")
#expect(export.clipboardText == "\n# 🦊\n``<pixel [x] `1` #%.png>``\n- [ ] todo \n")
#expect(source.isEqual(to: before))
#expect(wrapper.preferredFilename == name)
#expect(wrapper.filename == originalFilename)
@@ -46,7 +46,7 @@ func markdownExportWritesOriginalImageBytesAndEscapedLinksWithoutChangingSource(
source.mutableString.setString("Changed")
wrapper.preferredFilename = "changed.png"
try export.write(to: directory)
let markdown = "\n # 🦊\n![pixel \\[x\\] \\`1\\` #%.png](pixel%20%5Bx%5D%20%601%60%20%23%25.png)\n- [ ] todo \n"
let markdown = "\n# 🦊\n![pixel \\[x\\] \\`1\\` #%.png](pixel%20%5Bx%5D%20%601%60%20%23%25.png)\n- [ ] todo \n"
#expect(try String(contentsOf: directory.appendingPathComponent("note.md"), encoding: .utf8) == markdown)
#expect(try Data(contentsOf: directory.appendingPathComponent(name)) == png)
#expect(Set(try FileManager.default.contentsOfDirectory(atPath: directory.path)) == ["note.md", name])
+1 -1
View File
@@ -8,7 +8,7 @@ func vimNormalCursorIsWideButInsertAndDisabledCursorsStayThin() throws {
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 50))
editor.font = NotesAppearance.font
editor.drawsBackground = false
editor.setMarkdownContents(NSAttributedString(string: "text"))
editor.setDocumentContents(NSAttributedString(string: "text"))
func paintedWidth() throws -> Int {
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 100, pixelsHigh: 50,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
File diff suppressed because it is too large Load Diff
+49 -78
View File
@@ -4,7 +4,7 @@ import Testing
@testable import Conjure
@Test @MainActor
func headingsFollowVisibleParagraphEditingAndRoundTrip() throws {
func headingShortcutsConsumeSyntaxAndPersistAllSixLevels() throws {
_ = NSApplication.shared
let suite = "NotesHeadings.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
@@ -21,90 +21,60 @@ func headingsFollowVisibleParagraphEditingAndRoundTrip() throws {
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)
func reset(_ source: String) {
editor.setMarkdownContents(NSAttributedString(string: source))
MarkdownHighlighting.apply(to: editor)
editor.refreshTasks()
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
let title = (editor.string as NSString).range(of: "Title")
editor.setSelectedRange(NSRange(location: title.location, length: 0))
}
for (before, after) in [
("## Title", "## Title"),
("\n## Title", "## Title"),
("Text\n## Title", "TextTitle"),
("# Text\n## Title", "# TextTitle"),
("Text\n\n## Title", "Text\n## Title")
] {
reset(before)
editor.undoManager?.beginUndoGrouping()
editor.deleteBackward(nil)
editor.undoManager?.endUndoGrouping()
#expect(editor.markdownString == after)
if before != after {
editor.undoManager?.undo()
#expect(editor.markdownString == before)
editor.undoManager?.redo()
#expect(editor.markdownString == after)
}
}
for before in ["\n# Title", "\n## Title", "Text\n\n###### Title", "- [ ] 🦊\n\n## Title"] {
reset(before)
editor.undoManager?.beginUndoGrouping()
editor.doCommand(by: #selector(NSResponder.deleteBackward(_:)))
editor.undoManager?.endUndoGrouping()
let joined = editor.markdownString
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
editor.undoManager?.beginUndoGrouping()
editor.doCommand(by: #selector(NSResponder.insertNewline(_:)))
editor.undoManager?.endUndoGrouping()
#expect(editor.markdownString == before)
#expect(editor.selectedRange() == NSRange(location: (editor.string as NSString).range(of: "Title").location, length: 0))
editor.undoManager?.undo()
#expect(editor.markdownString == joined)
editor.undoManager?.redo()
#expect(editor.markdownString == before)
#expect(controller.save())
#expect(try NotesStore(directory: directory).notes.first?.contents.string == before)
}
for (before, position, after) in [
("## Title", 3, "\n## Title"),
("## Title", 5, "## Ti\ntle"),
("## Title", 8, "## Title\n"),
("Text\n## Title", 4, "Text\n\n## Title"),
("```text\n## Title\n```", 11, "```text\n## \nTitle\n```")
] {
reset(before)
editor.setSelectedRange(NSRange(location: position, length: 0))
editor.doCommand(by: #selector(NSResponder.insertNewline(_:)))
#expect(editor.markdownString == after)
}
for level in 1...6 {
reset("### Title")
editor.undoManager?.beginUndoGrouping()
editor.setDocumentContents(NSAttributedString(string: "Title"))
editor.setSelectedRange(NSRange(location: 0, length: 0))
editor.beginInsertionSession()
for character in String(repeating: "#", count: level) + " " {
editor.insertText(String(character), replacementRange: editor.selectedRange())
}
editor.undoManager?.endUndoGrouping()
let expected = String(repeating: "#", count: level) + " Title"
#expect(editor.markdownString == expected)
let title = (editor.string as NSString).range(of: "Title")
#expect(editor.selectedRange().location == title.location)
let sizes: [CGFloat] = [26, 23, 21, 19, 17, 15]
#expect((editor.textStorage?.attribute(.font, at: title.location, effectiveRange: nil) as? NSFont)?.pointSize == sizes[level - 1])
editor.endInsertionSession()
#expect(editor.string == "Title")
#expect(editor.selectedRange() == NSRange(location: 0, length: 0))
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "heading")
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level)
editor.undoManager?.undo()
#expect(editor.markdownString == "### Title")
#expect(editor.string == "Title")
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) == nil)
editor.undoManager?.redo()
#expect(editor.markdownString == expected)
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level)
#expect(controller.save())
let reloaded = try #require(NotesStore(directory: directory).notes.first)
#expect(reloaded.contents.string == expected)
#expect(reloaded.contents.string == "Title")
#expect(reloaded.contents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level)
}
reset("```text\n## Title\n```")
editor.insertText("# ", replacementRange: editor.selectedRange())
#expect(editor.markdownString == "```text\n## # Title\n```")
}
@Test @MainActor
func headingReturnSplitsVisibleParagraphAndUndoRestoresItsSemantics() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 500))
editor.allowsUndo = true
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
window.contentView = editor
window.makeFirstResponder(editor)
defer { window.orderOut(nil) }
for position in [2, 5] {
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "## Title")))
editor.setSelectedRange(NSRange(location: position, length: 0))
editor.undoManager?.removeAllActions()
editor.insertNewline(nil)
#expect(editor.string == (position == 2 ? "Ti\ntle" : "Title\n"))
#expect(editor.selectedRange() == NSRange(location: position + 1, length: 0))
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == 2)
editor.insertText("body", replacementRange: editor.selectedRange())
#expect(editor.documentContents.attribute(NoteAttributes.block, at: position + 1, effectiveRange: nil) == nil)
editor.undoManager?.undo()
editor.undoManager?.undo()
#expect(editor.string == "Title")
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == 2)
}
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "```text\n## Title\n```")))
editor.setSelectedRange(NSRange(location: 0, length: 0))
for character in "# " { editor.insertText(String(character), replacementRange: editor.selectedRange()) }
#expect(editor.string == "# ## Title")
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "code")
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) == nil)
}
@Test @MainActor
@@ -116,7 +86,8 @@ func headingCommandsKeepCaretOnTheVisibleBodyLine() throws {
window.makeFirstResponder(editor)
defer { window.orderOut(nil) }
for source in ["\n# Title", "Text\n\n## Title", "Text\n\n\n###### Title"] {
editor.setMarkdownContents(NSAttributedString(string: source))
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
let before = editor.string
editor.setSelectedRange(NSRange(location: (editor.string as NSString).range(of: "Title").location, length: 0))
for command in [#selector(NSResponder.deleteBackward(_:)), #selector(NSResponder.insertNewline(_:))] {
editor.doCommand(by: command)
@@ -128,6 +99,6 @@ func headingCommandsKeepCaretOnTheVisibleBodyLine() throws {
#expect(caretRect.origin == titleRect.origin)
#expect(caretRect.height == titleRect.height)
}
#expect(editor.markdownString == source)
#expect(editor.string == before)
}
}
+139 -166
View File
@@ -4,92 +4,98 @@ import Testing
@testable import Conjure
@Test @MainActor
func noteFormattingHidesSyntaxButPreservesMarkdownAndLiteralCode() throws {
func importedFormattingBecomesVisibleTextAndNativeSemanticMarks() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 800))
let source = "# Heading\n## Subheading\n*italic* **bold** _emphasis_ __strong__ ~~gone~~ <u>underlined</u> `*literal*`\n> Quote\n[Label](https://example.com)\n```text\n# Code *literal*\n```\nUnmatched * and \\*escaped*"
editor.setMarkdownContents(NSAttributedString(string: source))
MarkdownHighlighting.apply(to: editor)
editor.refreshTasks()
let layout = try #require(editor.layoutManager)
let container = try #require(editor.textContainer)
layout.ensureLayout(for: container)
func visibleText() -> String {
var result = ""
let text = editor.string as NSString
for index in 0..<text.length {
let glyph = layout.glyphIndexForCharacter(at: index)
if !layout.propertyForGlyph(at: glyph).contains(.null) {
result += text.substring(with: NSRange(location: index, length: 1))
}
}
return result
let source = "# Heading\n## Subheading\n*italic* **bold** _emphasis_ __strong__ ~~gone~~ <u>underlined</u> `*literal*`\n> Quote\n[Label](https://example.com)\n```text\n# Code *literal*\n```"
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
#expect(editor.string == "Heading\nSubheading\nitalic bold emphasis strong gone underlined *literal*\nQuote\nLabel\n# Code *literal*")
func attribute(_ key: NSAttributedString.Key, in token: String) -> Any? {
editor.documentContents.attribute(key, at: (editor.string as NSString).range(of: token).location, effectiveRange: nil)
}
#expect(visibleText() == "Heading\nSubheading\nitalic bold emphasis strong gone underlined *literal*\nQuote\nLabel\n```text\n# Code *literal*\n```\nUnmatched * and \\*escaped*")
#expect(editor.markdownString == source)
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
let pasteboard = NSPasteboard.withUniqueName()
defer { pasteboard.releaseGlobally() }
#expect(editor.writeSelection(to: pasteboard, types: editor.writablePasteboardTypes))
#expect(pasteboard.string(forType: .string) == source)
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let store = try NotesStore(directory: directory)
let id = try store.create()
try store.update(id: id, contents: editor.markdownContents)
let reloaded = try #require(NotesStore(directory: directory).notes.first)
#expect(reloaded.contents.string == source)
#expect(attribute(NoteAttributes.headingLevel, in: "Heading") as? Int == 1)
#expect(attribute(NoteAttributes.headingLevel, in: "Subheading") as? Int == 2)
for token in ["italic", "emphasis"] { #expect(attribute(NoteAttributes.italic, in: token) as? Bool == true) }
for token in ["bold", "strong"] { #expect(attribute(NoteAttributes.bold, in: token) as? Bool == true) }
#expect(attribute(.strikethroughStyle, in: "gone") as? Int == NSUnderlineStyle.single.rawValue)
#expect(attribute(.underlineStyle, in: "underlined") as? Int == NSUnderlineStyle.single.rawValue)
#expect(attribute(NoteAttributes.inlineCode, in: "*literal*") as? Bool == true)
#expect(attribute(NoteAttributes.block, in: "Quote") as? String == "quote")
#expect(String(describing: try #require(attribute(.link, in: "Label"))) == "https://example.com")
#expect(attribute(NoteAttributes.block, in: "# Code") as? String == "code")
#expect(attribute(NoteAttributes.bold, in: "# Code") as? Bool != true)
#expect(attribute(NoteAttributes.headingLevel, in: "# Code") == nil)
}
private final class NotesLayoutProbe: NSLayoutManager {
var observeFormatting: ((NSTextStorage) -> Void)?
override func processEditing(for textStorage: NSTextStorage, edited editMask: NSTextStorageEditActions,
range newCharRange: NSRange, changeInLength delta: Int,
invalidatedRange invalidatedCharRange: NSRange) {
observeFormatting?(textStorage)
super.processEditing(for: textStorage, edited: editMask, range: newCharRange,
changeInLength: delta, invalidatedRange: invalidatedCharRange)
@Test @MainActor
func completedInlineShortcutsConsumeDelimitersAndKeepUndoableNativeMarks() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
editor.allowsUndo = true
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
window.contentView = editor
window.makeFirstResponder(editor)
defer { window.orderOut(nil) }
for (source, key) in [("**fox 🦊**", NoteAttributes.bold), ("*fox 🦊*", NoteAttributes.italic),
("`fox 🦊`", NoteAttributes.inlineCode), ("~~fox 🦊~~", .strikethroughStyle),
("<u>fox 🦊</u>", .underlineStyle), ("[fox 🦊](https://example.com)", .link)] {
editor.setDocumentContents(NSAttributedString(string: ""))
editor.setSelectedRange(NSRange(location: 0, length: 0))
editor.beginInsertionSession()
for character in source { editor.insertText(String(character), replacementRange: editor.selectedRange()) }
editor.endInsertionSession()
#expect(editor.string == "fox 🦊")
#expect(editor.selectedRange() == NSRange(location: "fox 🦊".utf16.count, length: 0))
let mark = try #require(editor.documentContents.attribute(key, at: 0, effectiveRange: nil))
if key == .link {
#expect(String(describing: mark) == "https://example.com")
} else if key == .underlineStyle || key == .strikethroughStyle {
#expect(mark as? Int == NSUnderlineStyle.single.rawValue)
} else {
#expect(mark as? Bool == true)
}
editor.undoManager?.undo()
#expect(editor.string.isEmpty)
editor.undoManager?.redo()
#expect(editor.string == "fox 🦊")
#expect(editor.documentContents.attribute(key, at: 0, effectiveRange: nil) != nil)
}
}
@Test @MainActor
func typingDoesNotExposeUnformattedExistingTextToLayout() throws {
func richCopyPasteKeepsNativeMarksUnicodeAndImageBytesWithoutMarkdownParsing() throws {
_ = NSApplication.shared
let suite = "NotesFlashing.\(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 store = try NotesStore(directory: directory)
let id = try store.create()
try store.update(id: id, contents: NSAttributedString(string: "# Heading\n- [ ] Task\nPlain"))
defaults.set(id.uuidString, forKey: "selectedNote")
let controller = NotesController(defaults: defaults, directory: directory)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 570, height: 404),
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)
let layout = NotesLayoutProbe()
try #require(editor.textContainer).replaceLayoutManager(layout)
editor.refreshTasks()
window.makeFirstResponder(editor)
layout.observeFormatting = { storage in
let text = storage.string as NSString
let heading = text.range(of: "Heading")
let task = text.range(of: "Task")
#expect((storage.attribute(.font, at: heading.location, effectiveRange: nil) as? NSFont)?.pointSize == 26)
#expect((storage.attribute(.paragraphStyle, at: task.location, effectiveRange: nil) as? NSParagraphStyle)?.headIndent == 22)
#expect(storage.attribute(MarkdownHighlighting.hiddenMarkup, at: 0, effectiveRange: nil) as? Bool == true)
}
defer { layout.observeFormatting = nil }
editor.insertText("!", replacementRange: NSRange(location: editor.string.utf16.count, length: 0))
#expect(editor.markdownString == "# Heading\n- [ ] Task\nPlain!")
#expect(try NotesStore(directory: directory).notes.first?.contents.string == editor.markdownString)
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
bitmap.setColor(.red, atX: 0, y: 0)
let png = try #require(bitmap.representation(using: .png, properties: [:]))
let file = FileWrapper(regularFileWithContents: png)
file.preferredFilename = "pixel.png"
let contents = NSMutableAttributedString(attributedString:
NoteDocument.importMarkdown(NSAttributedString(string: "- [x] **🦊 café** ")))
contents.append(NSAttributedString(attachment: NSTextAttachment(fileWrapper: file)))
let source = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200))
source.setDocumentContents(contents)
source.setSelectedRange(NSRange(location: 0, length: source.string.utf16.count))
let board = NSPasteboard.withUniqueName()
defer { board.releaseGlobally() }
#expect(source.writeSelection(to: board, types: source.writablePasteboardTypes))
#expect(board.string(forType: .string) == source.string)
let destination = NotesTextView(frame: source.frame)
destination.allowsUndo = true
#expect(destination.readSelection(from: board))
#expect(destination.string == source.string)
#expect(destination.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
#expect(destination.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "task")
#expect(destination.documentContents.attribute(NoteAttributes.checked, at: 0, effectiveRange: nil) as? Bool == true)
let image = try #require(destination.documentContents.attribute(.attachment,
at: destination.string.utf16.count - 1, effectiveRange: nil) as? NSTextAttachment)
#expect(image.fileWrapper?.regularFileContents == png)
destination.undoManager?.undo()
#expect(destination.string.isEmpty)
destination.undoManager?.redo()
#expect(destination.string == source.string)
}
@Test @MainActor
@@ -112,7 +118,7 @@ func noteImagesRenderRoundedWithoutChangingSavedImageData() throws {
#expect(editor.readSelection(from: pasteboard))
func checkRoundedDrawing() throws {
MarkdownHighlighting.apply(to: editor)
editor.applyDocumentStyle()
let layout = try #require(editor.layoutManager)
layout.ensureLayout(for: try #require(editor.textContainer))
let output = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 200, pixelsHigh: 120,
@@ -146,60 +152,40 @@ func noteImagesRenderRoundedWithoutChangingSavedImageData() throws {
defer { try? FileManager.default.removeItem(at: directory) }
let store = try NotesStore(directory: directory)
let id = try store.create()
try store.update(id: id, contents: editor.markdownContents)
try store.update(id: id, contents: editor.documentContents)
let reloaded = try #require(NotesStore(directory: directory).notes.first)
let attachment = try #require(reloaded.contents.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment)
#expect(attachment.fileWrapper?.regularFileContents == png)
editor.setMarkdownContents(reloaded.contents)
editor.setDocumentContents(reloaded.contents)
try checkRoundedDrawing()
}
@Test @MainActor
func hiddenNoteMarkupKeepsTypingDeletionFormattingAndUndoAtVisibleBoundaries() throws {
func nativeFormattingSurvivesVisibleBoundaryEditingAndUndo() throws {
_ = NSApplication.shared
let suite = "NotesMarkup.\(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: 570, height: 404),
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)
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
editor.allowsUndo = true
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
window.contentView = editor
window.makeFirstResponder(editor)
editor.insertText("# Title\n*word*", replacementRange: NSRange(location: 0, length: 0))
editor.setSelectedRange(NSRange(location: 0, length: 0))
editor.insertText("A", replacementRange: editor.selectedRange())
#expect(editor.markdownString == "# ATitle\n*word*")
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
editor.undoManager?.beginUndoGrouping()
defer { window.orderOut(nil) }
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "# Title\n*word*")))
editor.insertText("A", replacementRange: NSRange(location: 0, length: 0))
#expect(editor.string == "ATitle\nword")
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == 1)
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
editor.undoManager?.removeAllActions()
editor.deleteBackward(nil)
editor.undoManager?.endUndoGrouping()
#expect(editor.markdownString == "# ATitle\n*wor*")
#expect(editor.string == "ATitle\nwor")
#expect(editor.documentContents.attribute(NoteAttributes.italic, at: "ATitle\n".utf16.count, effectiveRange: nil) as? Bool == true)
editor.undoManager?.undo()
#expect(editor.markdownString == "# ATitle\n*word*")
#expect(editor.string == "ATitle\nword")
let word = (editor.string as NSString).range(of: "word")
editor.setSelectedRange(NSRange(location: word.location, length: 0))
editor.moveRight(nil)
#expect(editor.selectedRange().location == word.location + 1)
editor.setSelectedRange(NSRange(location: NSMaxRange(word), length: 0))
editor.moveRight(nil)
#expect(editor.selectedRange().location == editor.string.utf16.count)
editor.setSelectedRange(NSRange(location: word.location, length: 0))
let italic = try #require(descendants(controller.view).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Italic" })
// Selecting the body and toggling formatting must remove its hidden delimiters.
editor.setSelectedRange(word)
italic.performClick(nil)
#expect(editor.markdownString == "# ATitle\nword")
#expect(controller.save())
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "# ATitle\nword")
editor.format(2)
#expect(editor.string == "ATitle\nword")
#expect(editor.selectedRange() == word)
#expect(editor.documentContents.attribute(NoteAttributes.italic, at: word.location, effectiveRange: nil) as? Bool != true)
}
@Test @MainActor
@@ -234,62 +220,51 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
}
editor.insertText("fox 🦊", replacementRange: editor.selectedRange())
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
let selection = editor.selectedRange()
#expect(try shortcut("b"))
#expect(editor.markdownString == "**fox 🦊**")
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
#expect(try shortcut("i"))
#expect(editor.markdownString == "***fox 🦊***")
let font = try #require(editor.textStorage?.attribute(.font, at: editor.selectedRange().location,
effectiveRange: nil) as? NSFont)
#expect(editor.documentContents.attribute(NoteAttributes.italic, at: 0, effectiveRange: nil) as? Bool == true)
let font = try #require(editor.textStorage?.attribute(.font, at: 0, effectiveRange: nil) as? NSFont)
let traits = NSFontManager.shared.traits(of: font)
#expect(traits.contains(.boldFontMask) && traits.contains(.italicFontMask))
#expect(try shortcut("i"))
#expect(editor.markdownString == "**fox 🦊**")
#expect(try shortcut("b"))
#expect(editor.markdownString == "fox 🦊")
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true)
#expect(editor.documentContents.attribute(NoteAttributes.italic, at: 0, effectiveRange: nil) as? Bool != true)
#expect(try shortcut("s", shift: true))
#expect(editor.markdownString == "~~fox 🦊~~")
#expect(editor.textStorage?.attribute(.strikethroughStyle, at: editor.selectedRange().location,
effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
#expect(editor.documentContents.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
#expect(try shortcut("s", shift: true))
#expect(editor.markdownString == "fox 🦊")
editor.breakUndoCoalescing()
#expect(editor.documentContents.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) == nil)
editor.undoManager?.removeAllActions()
editor.undoManager?.beginUndoGrouping()
#expect(try shortcut("u", shift: true))
editor.undoManager?.endUndoGrouping()
#expect(editor.markdownString == "<u>fox 🦊</u>")
#expect(editor.textStorage?.attribute(.underlineStyle, at: editor.selectedRange().location,
effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
#expect(editor.documentContents.attribute(.underlineStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
#expect(editor.string == "fox 🦊")
#expect(editor.selectedRange() == selection)
#expect(controller.save())
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "<u>fox 🦊</u>")
#expect(try NoteMarkdownExport(contents: editor.markdownContents).clipboardText == "<u>fox 🦊</u>")
let saved = try #require(NotesStore(directory: directory).notes.first)
#expect(saved.contents.string == "fox 🦊")
#expect(saved.contents.attribute(.underlineStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
#expect(try NoteMarkdownExport(contents: editor.documentContents).clipboardText == "<u>fox 🦊</u>")
editor.undoManager?.undo()
#expect(editor.markdownString == "fox 🦊")
#expect(try shortcut("u", shift: true))
#expect(try shortcut("u", shift: true))
#expect(editor.markdownString == "fox 🦊")
#expect(editor.textStorage?.attribute(.underlineStyle, at: 0, effectiveRange: nil) == nil)
#expect(editor.documentContents.attribute(.underlineStyle, at: 0, effectiveRange: nil) == nil)
#expect(try shortcut("l", shift: true))
#expect(editor.markdownString == "- fox 🦊")
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "bullet")
#expect(try shortcut("l", shift: true))
#expect(editor.markdownString == "fox 🦊")
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) == nil)
#expect(!(try shortcut("b", option: true)))
#expect(editor.markdownString == "fox 🦊")
editor.breakUndoCoalescing()
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true)
editor.undoManager?.removeAllActions()
editor.undoManager?.beginUndoGrouping()
#expect(try shortcut("e"))
editor.undoManager?.endUndoGrouping()
#expect(editor.markdownString == "`fox 🦊`")
#expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: 0, effectiveRange: nil) as? Bool == true)
editor.undoManager?.undo()
#expect(editor.markdownString == "fox 🦊")
#expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: 0, effectiveRange: nil) as? Bool != true)
#expect(try shortcut("k"))
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == "Search notes actions" })
#expect(window.firstResponder !== editor)
_ = try shortcut("b")
#expect(editor.markdownString == "fox 🦊")
#expect(editor.string == "fox 🦊")
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true)
#expect(search.stringValue.isEmpty)
#expect(controller.handle(#selector(NSResponder.cancelOperation(_:))))
#expect(controller.save())
@@ -297,30 +272,28 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
}
@Test @MainActor
func inlineCodeContainerStaysWithinCodeAndDisappearsWhenDelimitersAreRemoved() throws {
func inlineCodeContainerIsLimitedToItsMarkAndDisappearsWhenToggledOff() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 300))
let source = "Before `some *literal* code 🦊` after\n```text\n`not inline` <u>literal</u>\n```"
editor.setMarkdownContents(NSAttributedString(string: source))
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
let storage = try #require(editor.textStorage)
let layout = try #require(editor.layoutManager)
layout.ensureLayout(for: try #require(editor.textContainer))
let body = (source as NSString).range(of: "some *literal* code 🦊")
let body = (editor.string as NSString).range(of: "some *literal* code 🦊")
for index in body.location..<NSMaxRange(body) {
#expect(storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.accent)
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.selectionFill)
}
for token in ["Before", "after", "not inline"] {
let index = (source as NSString).range(of: token).location
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) == nil)
let index = (editor.string as NSString).range(of: token).location
#expect(storage.attribute(NoteAttributes.inlineCode, at: index, effectiveRange: nil) as? Bool != true)
}
let literalUnderline = (source as NSString).range(of: "<u>literal</u>")
let literalUnderline = (editor.string as NSString).range(of: "<u>literal</u>")
#expect(storage.attribute(.underlineStyle, at: literalUnderline.location, effectiveRange: nil) == nil)
#expect(editor.markdownString == source)
#expect(editor.markdownContents.attribute(.backgroundColor, at: body.location, effectiveRange: nil) == nil)
editor.replaceMarkdown("some *literal* code 🦊",
range: NSRange(location: body.location - 1, length: body.length + 2))
let plain = (editor.string as NSString).range(of: "some")
#expect(storage.attribute(.backgroundColor, at: plain.location, effectiveRange: nil) == nil)
#expect(storage.attribute(.foregroundColor, at: plain.location, effectiveRange: nil) as? NSColor == SunsetPalette.text)
let before = editor.string
editor.setSelectedRange(body)
editor.format(4)
#expect(editor.string == before)
#expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: body.location, effectiveRange: nil) as? Bool != true)
#expect(storage.attribute(.backgroundColor, at: body.location, effectiveRange: nil) == nil)
#expect(storage.attribute(.foregroundColor, at: body.location, effectiveRange: nil) as? NSColor == SunsetPalette.text)
}
+87 -5
View File
@@ -22,30 +22,30 @@ func notesRoundTripIndependentMarkdownUnicodeAndImageContents() throws {
let imageFile = FileWrapper(regularFileWithContents: png)
imageFile.preferredFilename = "pixel.png"
let attachment = NSTextAttachment(fileWrapper: imageFile)
let first = NSMutableAttributedString(string: "\n # 日本語 🦊 \n**bold** and `code`\n")
let first = NSMutableAttributedString(attributedString: NoteDocument.importMarkdown(NSAttributedString(string: "\n # 日本語 🦊 \n**bold** and `code`\n")))
first.append(NSAttributedString(attachment: attachment))
let expectedFirstText = first.string
try store.update(id: firstID, contents: first)
first.mutableString.setString("Mutated caller buffer")
#expect(store.notes.first { $0.id == firstID }?.contents.string == expectedFirstText)
#expect(store.notes.first { $0.id == firstID }?.title == "# 日本語 🦊")
#expect(store.notes.first { $0.id == firstID }?.title == "日本語 🦊")
try store.update(id: secondID, contents: NSAttributedString(string: "Second note"))
try store.update(id: secondID, contents: NSAttributedString(string: "## Edited\n- café\n- Ελληνικά"))
try store.update(id: secondID, contents: NoteDocument.importMarkdown(NSAttributedString(string: "## Edited\n- café\n- Ελληνικά")))
#expect(store.notes.first { $0.id == firstID }?.contents.string == expectedFirstText)
let reloaded = try NotesStore(directory: directory)
#expect(Set(reloaded.notes.map(\.id)) == Set([firstID, secondID]))
let restoredFirst = try #require(reloaded.notes.first { $0.id == firstID })
#expect(restoredFirst.contents.string == expectedFirstText)
#expect(restoredFirst.title == "# 日本語 🦊")
#expect(restoredFirst.title == "日本語 🦊")
let restoredAttachment = try #require(restoredFirst.contents.attribute(
.attachment, at: restoredFirst.contents.length - 1, effectiveRange: nil
) as? NSTextAttachment)
let restoredPNG = try #require(restoredAttachment.fileWrapper?.regularFileContents)
#expect(restoredPNG == png)
#expect(NSImage(data: restoredPNG) != nil)
#expect(reloaded.notes.first { $0.id == secondID }?.contents.string == "## Edited\n- café\n- Ελληνικά")
#expect(reloaded.notes.first { $0.id == secondID }?.contents.string == "Edited\ncafé\nΕλληνικά")
}
@Test @MainActor
@@ -131,3 +131,85 @@ func deletingNotesPersistsWithoutChangingOtherNotesAndPreservesMemoryOnFailure()
#expect(store.notes.isEmpty)
#expect(try NotesStore(directory: directory).notes.isEmpty)
}
@Test @MainActor
func legacyNotesConvertWithoutWritingAndBackUpOriginalPackageOnlyOnFirstSave() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let id = UUID()
let url = directory.appendingPathComponent(id.uuidString + ".rtfd")
let legacy = NSMutableAttributedString(string: "# Heading\n- [ ] task\n**bold** and `code`\n")
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
bitmap.setColor(.blue, atX: 0, y: 0)
let png = try #require(bitmap.representation(using: .png, properties: [:]))
let image = FileWrapper(regularFileWithContents: png)
image.preferredFilename = "legacy.png"
legacy.append(NSAttributedString(attachment: NSTextAttachment(fileWrapper: image)))
let wrapper = try legacy.fileWrapper(from: NSRange(location: 0, length: legacy.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd])
try wrapper.write(to: url, options: .atomic, originalContentsURL: nil)
let originalRTF = try Data(contentsOf: url.appendingPathComponent("TXT.rtf"))
let store = try NotesStore(directory: directory)
let note = try #require(store.notes.first)
#expect(note.contents.string == "Heading\ntask\nbold and code\n\u{fffc}")
#expect(note.title == "Heading")
#expect(try Data(contentsOf: url.appendingPathComponent("TXT.rtf")) == originalRTF)
#expect(!FileManager.default.fileExists(atPath: url.appendingPathComponent("ConjureNote.json").path))
#expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent("LegacyBackups").path))
try store.update(id: id, contents: note.contents)
let backup = directory.appendingPathComponent("LegacyBackups").appendingPathComponent(id.uuidString + ".rtfd")
#expect(try Data(contentsOf: backup.appendingPathComponent("TXT.rtf")) == originalRTF)
#expect(try Data(contentsOf: backup.appendingPathComponent("legacy.png")) == png)
#expect(!FileManager.default.fileExists(atPath: backup.appendingPathComponent("ConjureNote.json").path))
#expect(FileManager.default.fileExists(atPath: url.appendingPathComponent("ConjureNote.json").path))
let reloaded = try NotesStore(directory: directory)
#expect(reloaded.notes.first?.contents.string == note.contents.string)
let restored = try #require(reloaded.notes.first?.contents)
let attachment = try #require(restored.attribute(.attachment, at: restored.length - 1, effectiveRange: nil) as? NSTextAttachment)
#expect(attachment.fileWrapper?.regularFileContents == png)
try reloaded.update(id: id, contents: NSAttributedString(string: "Changed"))
#expect(try Data(contentsOf: backup.appendingPathComponent("TXT.rtf")) == originalRTF)
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.appendingPathComponent("LegacyBackups").path) == [id.uuidString + ".rtfd"])
}
@Test @MainActor
func failedLegacyBackupBlocksMigrationAndPreservesMemoryAndOriginalSource() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let id = UUID()
let url = directory.appendingPathComponent(id.uuidString + ".rtfd")
let source = NSAttributedString(string: "## Original")
let wrapper = try source.fileWrapper(from: NSRange(location: 0, length: source.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd])
try wrapper.write(to: url, options: .atomic, originalContentsURL: nil)
let original = try Data(contentsOf: url.appendingPathComponent("TXT.rtf"))
let store = try NotesStore(directory: directory)
let blocker = directory.appendingPathComponent("LegacyBackups")
try Data("not a directory".utf8).write(to: blocker)
#expect(throws: (any Error).self) { try store.update(id: id, contents: NSAttributedString(string: "Unsaved")) }
#expect(store.notes.first?.contents.string == "Original")
#expect(try Data(contentsOf: url.appendingPathComponent("TXT.rtf")) == original)
#expect(!FileManager.default.fileExists(atPath: url.appendingPathComponent("ConjureNote.json").path))
try FileManager.default.removeItem(at: blocker)
try store.update(id: id, contents: NSAttributedString(string: "Saved"))
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "Saved")
}
@Test @MainActor
func corruptStructuredMetadataIsNeverReinterpretedAsLegacyMarkdown() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let store = try NotesStore(directory: directory)
let id = try store.create()
try store.update(id: id, contents: NSAttributedString(string: "# literal"))
let package = directory.appendingPathComponent(id.uuidString + ".rtfd")
let metadata = package.appendingPathComponent("ConjureNote.json")
let corrupt = Data("invalid json".utf8)
try corrupt.write(to: metadata)
let originalRTF = try Data(contentsOf: package.appendingPathComponent("TXT.rtf"))
#expect(throws: NoteDocument.DocumentError.self) { try NotesStore(directory: directory) }
#expect(try Data(contentsOf: metadata) == corrupt)
#expect(try Data(contentsOf: package.appendingPathComponent("TXT.rtf")) == originalRTF)
#expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent("LegacyBackups").path))
}
+155 -35
View File
@@ -16,6 +16,12 @@ private func vimKeys(_ keys: String, editor: NotesTextView) throws {
for character in keys { try vimKey(String(character), editor: editor) }
}
@MainActor
private func vimEventTurn() {
// Let AppKit close its ordinary event-scoped undo groups between key events.
CFRunLoopRunInMode(.defaultMode, 0.001, false)
}
@Test(arguments: [
("one two three", "dw", "two three"),
("one,two three", "dw", ",two three"),
@@ -52,11 +58,11 @@ private func vimKeys(_ keys: String, editor: NotesTextView) throws {
@MainActor func vimOperatorsRespectVisibleTextAndBoundaries(_ scenario: (String, String, String)) throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
editor.setMarkdownContents(NSAttributedString(string: scenario.0))
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: scenario.0)))
editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys(scenario.1, editor: editor)
#expect(editor.markdownString == scenario.2)
#expect(editor.documentContents.string == scenario.2)
#expect(editor.vimMode == (scenario.1.contains("c") ? .insert : .normal))
}
@@ -64,25 +70,29 @@ private func vimKeys(_ keys: String, editor: NotesTextView) throws {
func vimMotionsCountCharactersWordsAndLogicalLines() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 90, height: 400))
editor.setMarkdownContents(NSAttributedString(string: "## 🧑🏽‍💻é abc,def\nx\n long final line"))
let contents = NSMutableAttributedString(string: "🧑🏽‍💻é abc,def\nx\n long final line")
contents.addAttributes([NoteAttributes.block: "heading", NoteAttributes.headingLevel: 2],
range: (contents.string as NSString).paragraphRange(for: NSRange(location: 0, length: 0)))
editor.setDocumentContents(contents)
editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 3, length: 0))
editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys("l", editor: editor)
#expect(editor.selectedRange().location == 10) // One composed emoji, not seven UTF-16 units.
#expect(editor.selectedRange().location == 7) // One composed emoji, not seven UTF-16 units.
try vimKeys("l", editor: editor)
#expect(editor.selectedRange().location == 12) // Combining accent remains a single character.
#expect(editor.selectedRange().location == 9) // Combining accent remains a single character.
try vimKeys("w", editor: editor)
#expect(editor.selectedRange().location == 13)
#expect(editor.selectedRange().location == 10)
try vimKeys("e", editor: editor)
#expect(editor.selectedRange().location == 15)
#expect(editor.selectedRange().location == 12)
try vimKeys("w", editor: editor)
#expect(editor.selectedRange().location == 16)
try vimKeys("b", editor: editor)
#expect(editor.selectedRange().location == 13)
try vimKeys("b", editor: editor)
#expect(editor.selectedRange().location == 10)
try vimKeys("0", editor: editor)
#expect(editor.selectedRange().location == 3)
#expect(editor.selectedRange().location == 0)
try vimKeys("4ljj", editor: editor)
let final = (editor.string as NSString).range(of: " long final line").location
let finalRange = try #require(editor.string.range(of: " long final line"))
let final = NSRange(finalRange, in: editor.string).location
#expect(editor.selectedRange().location == final + 4) // Column survives a shorter line.
try vimKeys("^", editor: editor)
#expect(editor.selectedRange().location == final + 2)
@@ -91,13 +101,13 @@ func vimMotionsCountCharactersWordsAndLogicalLines() throws {
try vimKeys("G$", editor: editor)
#expect(editor.selectedRange().location == editor.string.utf16.count - 1)
try vimKeys("gg0", editor: editor)
#expect(editor.selectedRange().location == 3)
editor.setMarkdownContents(NSAttributedString(string: "one two"))
#expect(editor.selectedRange().location == 0)
editor.setDocumentContents(NSAttributedString(string: "one two"))
editor.setSelectedRange(NSRange(location: 0, length: 3))
try vimKeys("d", editor: editor)
#expect(editor.markdownString == " two") // Native mouse selections also accept Visual operators.
#expect(editor.documentContents.string == " two") // Native mouse selections also accept Visual operators.
try vimKey("ø", editor: editor, flags: .option)
#expect(editor.markdownString == " two") // Modified text cannot leak into Normal mode.
#expect(editor.documentContents.string == " two") // Modified text cannot leak into Normal mode.
}
@Test @MainActor
@@ -111,32 +121,37 @@ func vimYankPasteAndUndoPreserveRichAttachments() throws {
editor.isRichText = true
editor.allowsUndo = true
let attachment = NSTextAttachment()
let source = NSMutableAttributedString(string: "## Image ")
let source = NSMutableAttributedString(string: "Image ")
source.append(NSAttributedString(attachment: attachment))
source.append(NSAttributedString(string: "\nTail"))
editor.setMarkdownContents(source)
source.addAttributes([NoteAttributes.block: "heading", NoteAttributes.headingLevel: 2], range: NSRange(location: 0, length: 8))
source.addAttributes([NoteAttributes.bold: true, .underlineStyle: NSUnderlineStyle.single.rawValue], range: NSRange(location: 0, length: 5))
editor.setDocumentContents(source)
editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 3, length: 0))
editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys("yyp", editor: editor)
#expect(editor.markdownString == "## Image \u{fffc}\n## Image \u{fffc}\nTail")
let pasted = editor.markdownContents
#expect(pasted.attribute(.attachment, at: 20, effectiveRange: nil) as? NSTextAttachment === attachment)
#expect(editor.documentContents.string == "Image \u{fffc}\nImage \u{fffc}\nTail")
let pasted = editor.documentContents
#expect(pasted.attribute(.attachment, at: 14, effectiveRange: nil) as? NSTextAttachment === attachment)
#expect(pasted.attribute(NoteAttributes.headingLevel, at: 8, effectiveRange: nil) as? Int == 2)
#expect(pasted.attribute(NoteAttributes.bold, at: 8, effectiveRange: nil) as? Bool == true)
#expect(pasted.attribute(.underlineStyle, at: 8, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
try vimKeys("u", editor: editor)
#expect(editor.markdownString == source.string)
#expect(editor.documentContents.string == source.string)
try vimKey("r", editor: editor, flags: .control)
#expect(editor.markdownString == pasted.string)
editor.setMarkdownContents(NSAttributedString(string: "one two"))
#expect(editor.documentContents.string == pasted.string)
editor.setDocumentContents(NSAttributedString(string: "one two"))
editor.undoManager?.removeAllActions()
editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys("ywP", editor: editor)
#expect(editor.markdownString == "one one two")
#expect(editor.documentContents.string == "one one two")
try vimKeys("u", editor: editor)
#expect(editor.markdownString == "one two")
#expect(editor.documentContents.string == "one two")
editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys("cw", editor: editor)
try vimKeys("new", editor: editor)
try vimKey("\u{1b}", editor: editor, code: 53)
#expect(editor.markdownString == "new two")
#expect(editor.documentContents.string == "new two")
#expect(editor.vimMode == .normal)
#expect(editor.selectedRange().location == 2)
}
@@ -154,15 +169,120 @@ func vimInsertEntryAndLinewisePasteKeepParagraphsSeparate() throws {
("one", "o", "one\n"),
("one", "O", "\none")
] {
editor.setMarkdownContents(NSAttributedString(string: before))
editor.setDocumentContents(NSAttributedString(string: before))
editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys(keys, editor: editor)
#expect(editor.markdownString == after)
#expect(editor.documentContents.string == after)
if keys == "O" { #expect(editor.selectedRange().location == 0) }
}
}
@Test @MainActor
func vimDeletingFinalBoldCharacterOnlyDeletesThatCharacter() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
let contents = NSMutableAttributedString(string: "ab tail")
contents.addAttribute(NoteAttributes.bold, value: true, range: NSRange(location: 0, length: 2))
editor.setDocumentContents(contents)
editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 1, length: 0))
try vimKeys("x", editor: editor)
#expect(editor.documentContents.string == "a tail")
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
#expect(editor.vimState.register?.string == "b")
#expect(editor.vimState.register?.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
try vimKeys("P", editor: editor)
#expect(editor.documentContents.string == "ab tail")
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 1, effectiveRange: nil) as? Bool == true)
}
@Test @MainActor
func vimCharacterRegisterPreservesNativeImageAttributes() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
let attachment = NSTextAttachment()
let imageBytes = Data([0, 1, 2, 3])
attachment.fileWrapper = FileWrapper(regularFileWithContents: imageBytes)
let contents = NSMutableAttributedString(attributedString: NSAttributedString(attachment: attachment))
contents.append(NSAttributedString(string: " tail"))
editor.setDocumentContents(contents)
editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys("ylp", editor: editor)
#expect(editor.documentContents.string == "\u{fffc}\u{fffc} tail")
let pasted = try #require(editor.documentContents.attribute(.attachment, at: 1, effectiveRange: nil) as? NSTextAttachment)
#expect(pasted.fileWrapper?.regularFileContents == imageBytes)
}
@Test(arguments: ["escape", "focus", "toggle", "reset"])
@MainActor func vimChangeAndTypingUndoTogetherAcrossEvents(_ ending: String) throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
window.contentView = editor
window.makeFirstResponder(editor)
defer { window.orderOut(nil) }
editor.allowsUndo = true
let original = NSMutableAttributedString(string: "one two")
original.addAttribute(NoteAttributes.bold, value: true, range: NSRange(location: 0, length: 3))
editor.setDocumentContents(original)
editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 0, length: 0))
let undo = try #require(editor.undoManager)
undo.removeAllActions()
try vimKeys("cw", editor: editor)
vimEventTurn()
for character in "new" {
try vimKey(String(character), editor: editor)
vimEventTurn()
}
#expect(editor.documentContents.string == "new two")
switch ending {
case "escape": try vimKey("\u{1b}", editor: editor, code: 53)
case "focus": #expect(window.makeFirstResponder(nil))
case "toggle": editor.vimEnabled = false
default: editor.resetVim()
}
vimEventTurn()
#expect(undo.canUndo)
undo.undo()
#expect(editor.documentContents.string == original.string)
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
#expect(!undo.canUndo)
undo.redo()
#expect(editor.documentContents.string == "new two")
}
@Test @MainActor
func vimSeparateInsertionSessionsHaveSeparateUndoOperations() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
window.contentView = editor
window.makeFirstResponder(editor)
defer { window.orderOut(nil) }
editor.allowsUndo = true
editor.setDocumentContents(NSAttributedString(string: "tail"))
editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 0, length: 0))
let undo = try #require(editor.undoManager)
undo.removeAllActions()
try vimKeys("ione ", editor: editor)
vimEventTurn()
#expect(editor.documentContents.string == "one tail")
#expect(editor.vimMode == .insert)
try vimKey("\u{1b}", editor: editor, code: 53)
try vimKeys("0itwo ", editor: editor)
vimEventTurn()
try vimKey("\u{1b}", editor: editor, code: 53)
#expect(editor.documentContents.string == "two one tail")
try vimKeys("u", editor: editor)
#expect(editor.documentContents.string == "one tail")
try vimKeys("u", editor: editor)
#expect(editor.documentContents.string == "tail")
}
@Test @MainActor
func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
_ = NSApplication.shared
@@ -185,7 +305,7 @@ func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
let toggle = try #require(descendants(content).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Toggle Vim editing" })
#expect(!editor.vimEnabled)
try vimKeys("hello", editor: editor)
#expect(editor.markdownString == "hello")
#expect(editor.documentContents.string == "hello")
toggle.performClick(nil)
#expect(defaults.bool(forKey: "notesVimEnabled"))
#expect(editor.vimMode == .normal)
@@ -204,9 +324,9 @@ func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
window.sendEvent(escape)
#expect(window.firstResponder === editor)
#expect(window.isVisible)
let beforeMotion = editor.markdownString
let beforeMotion = editor.documentContents.string
try vimKeys("w", editor: editor)
#expect(editor.markdownString == beforeMotion)
#expect(editor.documentContents.string == beforeMotion)
try vimKeys("d", editor: editor)
window.sendEvent(escape)
#expect(!window.isVisible)
@@ -215,9 +335,9 @@ func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
let reopened = try #require(descendants(reopenedContent).compactMap { $0 as? NotesTextView }.first)
#expect(reopened.vimEnabled)
#expect(reopened.vimMode == .normal)
let beforeReopenedMotion = reopened.markdownString
let beforeReopenedMotion = reopened.documentContents.string
try vimKeys("w", editor: reopened)
#expect(reopened.markdownString == beforeReopenedMotion)
#expect(reopened.documentContents.string == beforeReopenedMotion)
let newToggle = try #require(descendants(reopenedContent).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Toggle Vim editing" })
newToggle.performClick(nil)
#expect(!defaults.bool(forKey: "notesVimEnabled"))
+30 -27
View File
@@ -1,41 +1,44 @@
import AppKit
import ConjureKit
import Testing
@testable import Conjure
@Test @MainActor func numberedMarkersShareTheBodyTextBaseline() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200))
for source in ["1. 1", "123) 123", " 12. 12"] {
editor.setMarkdownContents(NSAttributedString(string: source))
MarkdownHighlighting.apply(to: editor)
editor.refreshTasks()
editor.drawsBackground = false
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
window.contentView = editor
defer { window.orderOut(nil) }
for source in ["1. 1", "123. 123", " 12. 12"] {
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
let layout = try #require(editor.layoutManager)
let container = try #require(editor.textContainer)
layout.ensureLayout(for: container)
let text = editor.string as NSString
let marker = text.range(of: "\u{fffc}")
let body = NSRange(location: NSMaxRange(marker) + 1, length: text.length - NSMaxRange(marker) - 1)
var bottoms: [Int] = []
for range in [marker, body] {
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 400, pixelsHigh: 80,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
let bitmapContext = try #require(NSGraphicsContext(bitmapImageRep: bitmap))
bitmapContext.cgContext.translateBy(x: 0, y: 80)
bitmapContext.cgContext.scaleBy(x: 1, y: -1)
let context = NSGraphicsContext(cgContext: bitmapContext.cgContext, flipped: true)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = context
context.cgContext.clear(CGRect(x: 0, y: 0, width: 400, height: 80))
layout.drawGlyphs(forGlyphRange: layout.glyphRange(forCharacterRange: range, actualCharacterRange: nil), at: .zero)
NSGraphicsContext.restoreGraphicsState()
var bottom = -1
for y in 0..<80 {
for x in 0..<400 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.2 { bottom = y }
let glyph = layout.glyphIndexForCharacter(at: 0)
let fragment = layout.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
let bodyX = editor.textContainerOrigin.x + fragment.minX + layout.location(forGlyphAt: glyph).x
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 400, pixelsHigh: 200,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
let bitmapContext = try #require(NSGraphicsContext(bitmapImageRep: bitmap))
bitmapContext.cgContext.translateBy(x: 0, y: 200)
bitmapContext.cgContext.scaleBy(x: 1, y: -1)
let context = NSGraphicsContext(cgContext: bitmapContext.cgContext, flipped: true)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = context
context.cgContext.clear(CGRect(x: 0, y: 0, width: 400, height: 200))
editor.draw(editor.bounds)
NSGraphicsContext.restoreGraphicsState()
var markerBottom = -1, bodyBottom = -1
for y in 0..<200 {
for x in 0..<400 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.2 {
if CGFloat(x) < bodyX - 1 { markerBottom = y }
else { bodyBottom = y }
}
try #require(bottom >= 0)
bottoms.append(bottom)
}
#expect(abs(bottoms[0] - bottoms[1]) <= 1, "Marker/body ink baseline for \(source)")
try #require(markerBottom >= 0 && bodyBottom >= 0)
#expect(abs(markerBottom - bodyBottom) <= 1, "Marker/body ink baseline for \(source)")
#expect(!editor.string.contains("\u{fffc}"))
}
}