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" : [ "pins" : [
{ {
"identity" : "highlighterswift", "identity" : "highlighterswift",
@@ -9,6 +9,24 @@
"revision" : "fe7aae9c9b31d3b296fd3d2dd575e1a207bb29e0", "revision" : "fe7aae9c9b31d3b296fd3d2dd575e1a207bb29e0",
"version" : "3.1.0" "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 "version" : 3
+7 -2
View File
@@ -5,9 +5,14 @@ let package = Package(
name: "Conjure", name: "Conjure",
platforms: [.macOS(.v14)], platforms: [.macOS(.v14)],
products: [.executable(name: "Conjure", targets: ["Conjure"])], 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: [ targets: [
.target(name: "ConjureKit", resources: [.process("Resources")]), .target(name: "ConjureKit", dependencies: [
.product(name: "Markdown", package: "swift-markdown"),
], resources: [.process("Resources")]),
.executableTarget(name: "Conjure", dependencies: [ .executableTarget(name: "Conjure", dependencies: [
"ConjureKit", .product(name: "Highlighter", package: "HighlighterSwift"), "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. 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. 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. 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. 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 | | Bullet list | Command-Shift-L |
| Checklist | Command-Shift-T | | 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. 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.
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.
Embedded images display with 8-point rounded corners. Rounding only clips their rendering in Notes; saving and copying retain the original image data. 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 ### 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 | | Native Undo / Redo | `u` / Control-R; Command-Z / Command-Shift-Z remain available |
| Return to Normal (from Insert/Visual) | Escape or Control-[ | | 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. 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 AppKit
import ConjureKit import ConjureKit
import Highlighter
import UniformTypeIdentifiers import UniformTypeIdentifiers
@MainActor @MainActor
@@ -236,66 +235,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
@objc private func formatNote(_ sender: NSButton) { @objc private func formatNote(_ sender: NSButton) {
guard editor.isEditable else { return } editor.format(sender.tag)
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))
}
focusEditor() focusEditor()
} }
@@ -306,203 +246,12 @@ final class NotesController: NSViewController, NSTextViewDelegate {
_ = save() _ = 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 @discardableResult
func save() -> Bool { func save() -> Bool {
guard isDirty, let store, let selectedID else { return true } guard isDirty, let store, let selectedID else { return true }
do { do {
try store.update(id: selectedID, contents: editor.markdownContents) try store.update(id: selectedID, contents: editor.documentContents)
isDirty = false isDirty = false
noteTitle.stringValue = store.notes.first { $0.id == selectedID }?.title ?? "Untitled Note" noteTitle.stringValue = store.notes.first { $0.id == selectedID }?.title ?? "Untitled Note"
status.isHidden = true status.isHidden = true
@@ -530,7 +279,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
func handleCommandKey(_ event: NSEvent) -> Bool { func handleCommandKey(_ event: NSEvent) -> Bool {
guard store != nil, view.window?.attachedSheet == nil else { return false } guard store != nil, view.window?.attachedSheet == nil else { return false }
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift]) 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() ?? "" let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
if let index = Self.formattingKeys.firstIndex(where: { $0.key == key && $0.modifiers == modifiers }) { if let index = Self.formattingKeys.firstIndex(where: { $0.key == key && $0.modifiers == modifiers }) {
guard editor.isEditable, !actions.isShown, view.window?.firstResponder === editor, guard editor.isEditable, !actions.isShown, view.window?.firstResponder === editor,
@@ -571,7 +320,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
private func copyAsMarkdown() { private func copyAsMarkdown() {
actions.close(animated: false) actions.close(animated: false)
do { do {
let export = try NoteMarkdownExport(contents: editor.markdownContents) let export = try NoteMarkdownExport(contents: editor.documentContents)
let clipboard = NSPasteboard.general let clipboard = NSPasteboard.general
clipboard.clearContents() clipboard.clearContents()
guard clipboard.setString(export.clipboardText, forType: .string) else { 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 } guard let window = view.window, window.attachedSheet == nil else { return }
actions.close(animated: false) actions.close(animated: false)
do { do {
let export = try NoteMarkdownExport(contents: editor.markdownContents) let export = try NoteMarkdownExport(contents: editor.documentContents)
let panel = NSSavePanel() let panel = NSSavePanel()
panel.title = "Export as Markdown" panel.title = "Export as Markdown"
panel.prompt = "Export" panel.prompt = "Export"
@@ -635,7 +384,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
try store.delete(id: id) try store.delete(id: id)
self.selectedID = nil self.selectedID = nil
self.defaults.removeObject(forKey: "selectedNote") self.defaults.removeObject(forKey: "selectedNote")
self.editor.setMarkdownContents(NSAttributedString(string: "")) self.editor.setDocumentContents(NSAttributedString(string: ""))
self.editor.undoManager?.removeAllActions() self.editor.undoManager?.removeAllActions()
self.editor.isEditable = false self.editor.isEditable = false
self.noteTitle.stringValue = "Untitled Note" 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 } guard let note = store?.notes.first(where: { $0.id == id }) else { return }
selectedID = id selectedID = id
defaults.set(id.uuidString, forKey: "selectedNote") defaults.set(id.uuidString, forKey: "selectedNote")
editor.setMarkdownContents(note.contents) editor.setDocumentContents(note.contents)
editor.undoManager?.removeAllActions() editor.undoManager?.removeAllActions()
editor.isEditable = true editor.isEditable = true
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) 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
+39 -40
View File
@@ -17,7 +17,7 @@ struct NotesVimState {
var linewiseRegister = false 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 @MainActor
private struct VimBuffer { private struct VimBuffer {
var characters: [Character] = [] var characters: [Character] = []
@@ -29,12 +29,8 @@ private struct VimBuffer {
var offset = 0 var offset = 0
for character in contents.string { for character in contents.string {
let size = String(character).utf16.count 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) characters.append(character)
ranges.append(NSRange(location: offset, length: size)) ranges.append(NSRange(location: offset, length: size))
}
offset += size offset += size
} }
} }
@@ -98,10 +94,12 @@ extension NotesTextView {
var vimMode: NotesVimMode { vimState.mode } var vimMode: NotesVimMode { vimState.mode }
func resetVim() { func resetVim() {
endInsertionSession()
let register = vimState.register, linewise = vimState.linewiseRegister let register = vimState.register, linewise = vimState.linewiseRegister
vimState = NotesVimState() vimState = NotesVimState()
vimState.register = register vimState.register = register
vimState.linewiseRegister = linewise vimState.linewiseRegister = linewise
needsDisplay = true
vimModeChanged?() vimModeChanged?()
} }
@@ -113,19 +111,22 @@ extension NotesTextView {
} }
private func setVimMode(_ mode: NotesVimMode) { private func setVimMode(_ mode: NotesVimMode) {
if vimState.mode == .insert { endInsertionSession() }
breakUndoCoalescing() breakUndoCoalescing()
clearVimCommand() clearVimCommand()
vimState.mode = mode vimState.mode = mode
vimState.anchor = nil vimState.anchor = nil
vimState.head = nil vimState.head = nil
vimState.column = nil vimState.column = nil
if mode == .insert { beginInsertionSession() }
needsDisplay = true
vimModeChanged?() vimModeChanged?()
} }
@discardableResult @discardableResult
func handleVimEscape() -> Bool { func handleVimEscape() -> Bool {
guard vimEnabled, vimState.mode != .normal, !hasMarkedText() else { return false } 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) var head = vimState.head ?? buffer.index(selectedRange().location)
if vimState.mode == .insert && head > buffer.lineStart(head) { head -= 1 } if vimState.mode == .insert && head > buffer.lineStart(head) { head -= 1 }
setVimMode(.normal) setVimMode(.normal)
@@ -192,11 +193,12 @@ extension NotesTextView {
vimState.count = min(9999, vimState.count * 10 + digit) vimState.count = min(9999, vimState.count * 10 + digit)
return true return true
} }
let buffer = VimBuffer(attributedString()) let buffer = VimBuffer(documentContents)
if vimState.mode == .normal, selectedRange().length > 0 { if vimState.mode == .normal, selectedRange().length > 0 {
vimState.mode = .visual vimState.mode = .visual
vimState.anchor = buffer.index(selectedRange().location) vimState.anchor = buffer.index(selectedRange().location)
vimState.head = max(vimState.anchor!, buffer.index(NSMaxRange(selectedRange())) - 1) vimState.head = max(vimState.anchor!, buffer.index(NSMaxRange(selectedRange())) - 1)
needsDisplay = true
vimModeChanged?() vimModeChanged?()
} }
let origin = buffer.normal(vimState.head ?? buffer.index(selectedRange().location)) 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 key == "g" { vimState.awaitingG = true; return true }
if "dcy".contains(key) { if "dcy".contains(key) {
if visual { if visual {
let range = vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine) let range = selectedRange()
applyVimOperator(key, range: range, linewise: vimState.mode == .visualLine) applyVimOperator(key, range: range, linewise: vimState.mode == .visualLine)
} else if vimState.pending == key { } else if vimState.pending == key {
var end = buffer.lineEnd(origin) var end = buffer.lineEnd(origin)
@@ -305,22 +307,22 @@ extension NotesTextView {
setVimCaret(target, buffer: buffer) setVimCaret(target, buffer: buffer)
insertNewline(nil) insertNewline(nil)
if key == "O" { if key == "O" {
let updated = VimBuffer(attributedString()) let updated = VimBuffer(documentContents)
let caret = updated.index(selectedRange().location) let caret = updated.index(selectedRange().location)
setVimCaret(updated.lineStart(max(0, caret - 1)), buffer: updated) setVimCaret(updated.lineStart(max(0, caret - 1)), buffer: updated)
} }
case "x": 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 { else {
let end = min(buffer.lineEnd(origin), origin + count) let end = min(buffer.lineEnd(origin), origin + count)
let startOffset = buffer.offset(origin), endOffset = buffer.offset(end) 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": case "D", "C":
var end = buffer.lineEnd(origin) var end = buffer.lineEnd(origin)
for _ in 1..<count where buffer.newline(end) { end = buffer.lineEnd(end + 1) } for _ in 1..<count where buffer.newline(end) { end = buffer.lineEnd(end + 1) }
let startOffset = buffer.offset(origin), endOffset = buffer.offset(end) 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 "p", "P": pasteVimRegister(before: key == "P", count: count, origin: origin, buffer: buffer)
case "u": case "u":
setVimMode(.normal) setVimMode(.normal)
@@ -348,7 +350,7 @@ extension NotesTextView {
let high = min(buffer.count, max(origin, target) + (inclusive ? 1 : 0)) let high = min(buffer.count, max(origin, target) + (inclusive ? 1 : 0))
let start = buffer.offset(low), end = buffer.offset(high) let start = buffer.offset(low), end = buffer.offset(high)
// A motion against a boundary is a no-op, not a character deletion. // 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 { clearVimCommand() }
} }
} else { } 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) { private func applyVimLineOperator(_ operation: Character, start: Int, end: Int, buffer: VimBuffer) {
let source = markdownString as NSString let first = buffer.offset(buffer.lineStart(start))
let startOffset = markdownRange(for: NSRange(location: buffer.offset(buffer.lineStart(start)), length: 0)).location let lastLineEnd = buffer.lineEnd(end)
let endOffset = markdownRange(for: NSRange(location: buffer.offset(buffer.lineEnd(end)), length: 0)).location let last = buffer.offset(lastLineEnd + (buffer.newline(lastLineEnd) ? 1 : 0))
let first = source.lineRange(for: NSRange(location: startOffset, length: 0)).location
let last = NSMaxRange(source.lineRange(for: NSRange(location: endOffset, length: 0)))
applyVimOperator(operation, range: NSRange(location: first, length: last - first), linewise: true) applyVimOperator(operation, range: NSRange(location: first, length: last - first), linewise: true)
} }
private func applyVimOperator(_ operation: Character, range: NSRange, linewise: Bool) { private func applyVimOperator(_ operation: Character, range: NSRange, linewise: Bool) {
let source = markdownContents let source = documentContents
let register = NSMutableAttributedString(attributedString: source.attributedSubstring(from: range)) 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.register = register
vimState.linewiseRegister = linewise vimState.linewiseRegister = linewise
let originalCaret = selectedRange().location let originalCaret = selectedRange().location
@@ -386,12 +385,14 @@ extension NotesTextView {
return return
} }
var deletion = range 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 { if linewise {
let text = source.string as NSString let text = source.string as NSString
if operation == "c" { if operation == "c" {
// cc keeps the following line separate while opening a blank line for insertion. // 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 { } else if NSMaxRange(range) == text.length, range.location > 0 {
let previous = text.rangeOfComposedCharacterSequence(at: range.location - 1) let previous = text.rangeOfComposedCharacterSequence(at: range.location - 1)
if text.substring(with: previous).allSatisfy(\.isNewline) { if text.substring(with: previous).allSatisfy(\.isNewline) {
@@ -400,10 +401,9 @@ extension NotesTextView {
} }
} }
} }
replaceMarkdown(replacement, range: deletion) replaceDocument(replacement, range: deletion, selection: NSRange(location: deletion.location, length: 0))
if operation == "c" { if operation == "c" { typingAttributes = attributes }
setSelectedRange(renderedRange(for: NSRange(location: deletion.location, length: 0))) else { normalizeVimCaret() }
} else { normalizeVimCaret() }
} }
private func pasteVimRegister(before: Bool, count: Int, origin: Int, buffer: VimBuffer) { private func pasteVimRegister(before: Bool, count: Int, origin: Int, buffer: VimBuffer) {
@@ -412,11 +412,10 @@ extension NotesTextView {
for _ in 0..<count { insertion.append(register) } for _ in 0..<count { insertion.append(register) }
var range: NSRange var range: NSRange
if vimState.mode == .visual || vimState.mode == .visualLine { if vimState.mode == .visual || vimState.mode == .visualLine {
range = vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine) range = selectedRange()
} else if vimState.linewiseRegister { } else if vimState.linewiseRegister {
let source = markdownString as NSString let source = documentContents.string as NSString
let position = markdownRange(for: NSRange(location: buffer.offset(origin), length: 0)).location let line = source.lineRange(for: NSRange(location: buffer.offset(origin), length: 0))
let line = source.lineRange(for: NSRange(location: position, length: 0))
let offset = before ? line.location : NSMaxRange(line) let offset = before ? line.location : NSMaxRange(line)
range = NSRange(location: offset, length: 0) range = NSRange(location: offset, length: 0)
if offset == source.length, offset == 0 || !source.substring(from: offset - 1).allSatisfy(\.isNewline) { if offset == source.length, offset == 0 || !source.substring(from: offset - 1).allSatisfy(\.isNewline) {
@@ -427,20 +426,20 @@ extension NotesTextView {
} }
} else { } else {
let index = before ? origin : min(buffer.lineEnd(origin), origin + 1) 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) 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 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 { if !vimState.linewiseRegister {
let updated = VimBuffer(attributedString()) let updated = VimBuffer(documentContents)
setVimCaret(updated.normal(max(0, updated.index(selectedRange().location) - 1)), buffer: updated) setVimCaret(updated.normal(max(0, updated.index(selectedRange().location) - 1)), buffer: updated)
} else { normalizeVimCaret() } } else { normalizeVimCaret() }
} }
private func normalizeVimCaret() { private func normalizeVimCaret() {
let buffer = VimBuffer(attributedString()) let buffer = VimBuffer(documentContents)
setVimCaret(buffer.normal(buffer.index(selectedRange().location)), buffer: buffer) setVimCaret(buffer.normal(buffer.index(selectedRange().location)), buffer: buffer)
vimState.anchor = nil vimState.anchor = nil
vimState.head = 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)] private let images: [(name: String, data: Data)]
public init(contents: NSAttributedString) throws { public init(contents: NSAttributedString) throws {
let contents = NoteDocument.markdown(contents)
let source = contents.string as NSString let source = contents.string as NSString
var markdown = "" var markdown = ""
var clipboard = "" var clipboard = ""
+36 -10
View File
@@ -24,6 +24,7 @@ public struct Note: Identifiable {
public final class NotesStore { public final class NotesStore {
public private(set) var notes: [Note] = [] public private(set) var notes: [Note] = []
private let directory: URL private let directory: URL
private var legacyIDs: Set<UUID> = []
public init(directory: URL = FileManager.default.urls( public init(directory: URL = FileManager.default.urls(
for: .applicationSupportDirectory, in: .userDomainMask for: .applicationSupportDirectory, in: .userDomainMask
@@ -34,16 +35,26 @@ public final class NotesStore {
at: directory, includingPropertiesForKeys: nil at: directory, includingPropertiesForKeys: nil
).filter { $0.pathExtension.lowercased() == "rtfd" } ).filter { $0.pathExtension.lowercased() == "rtfd" }
.sorted { $0.lastPathComponent < $1.lastPathComponent } .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 { guard let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent) else {
throw CocoaError(.fileReadCorruptFile) throw CocoaError(.fileReadCorruptFile)
} }
let contents = try NSAttributedString( let wrapper = try FileWrapper(url: url, options: .immediate)
url: url, options: [.documentType: NSAttributedString.DocumentType.rtfd], guard wrapper.isDirectory else { throw CocoaError(.fileReadCorruptFile) }
documentAttributes: nil let contents: NSAttributedString
) if wrapper.fileWrappers?[NoteDocument.metadataFilename] != nil {
return Note(id: id, contents: contents) 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 { public func create() throws -> UUID {
@@ -69,14 +80,29 @@ public final class NotesStore {
} }
try FileManager.default.removeItem(at: directory.appendingPathComponent(id.uuidString + ".rtfd")) try FileManager.default.removeItem(at: directory.appendingPathComponent(id.uuidString + ".rtfd"))
notes.remove(at: index) notes.remove(at: index)
legacyIDs.remove(id)
} }
private func save(_ note: Note) throws { private func save(_ note: Note) throws {
let wrapper = try note.contents.fileWrapper( let wrapper = try NoteDocument.fileWrapper(note.contents)
from: NSRange(location: 0, length: note.contents.length),
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
)
let url = directory.appendingPathComponent(note.id.uuidString + ".rtfd", isDirectory: true) 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) 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. - 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. - 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. - 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: - [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>`. - 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. - 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] keep the caret on the heading's visible line after Backspace and Return, not on the preceding line.
- [x] improve: - [x] improve:
- add cmd+shift+s for strikethrough and cmd+shift+u for underline - 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 - 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 - [x] feature: vim motions
- opt-in Normal/Insert/Visual editing, counted motions, delete/change/yank, rich-text paste, and native Undo/Redo. - 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 - [ ] feature: configurable color scheme
- general design should stay consistent - 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) - 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 AppKit
import ConjureKit
import Testing import Testing
@testable import Conjure @testable import Conjure
@Test @MainActor func fencedCodeHighlightsTokensWithoutChangingSourceOrApplyingMarkdown() throws { @Test @MainActor
func codeBlocksHighlightTokensWithoutInterpretingTheirLiteralMarkdown() throws {
_ = NSApplication.shared _ = NSApplication.shared
for fence in ["```", "~~~~"] { 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 source = "# Heading 🦊\n\(fence)py\ndef main():\n # comment\n return \"**not bold**\"\n\(fence)\nAfter\n```unknown-language\n# literal\n```"
let editor = NSTextView() let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 500))
editor.string = text editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
MarkdownHighlighting.apply(to: editor)
let storage = try #require(editor.textStorage) let storage = try #require(editor.textStorage)
func attributes(_ token: String) -> [NSAttributedString.Key: Any] { 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 keyword = try #require(attributes("def")[.foregroundColor] as? NSColor)
let name = try #require(attributes("main")[.foregroundColor] as? NSColor) let name = try #require(attributes("main")[.foregroundColor] as? NSColor)
@@ -19,20 +20,23 @@ import Testing
#expect(keyword != name) #expect(keyword != name)
#expect(comment != keyword) #expect(comment != keyword)
#expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true) #expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true)
#expect(storage.string == text) #expect(attributes("**not bold**")[NoteAttributes.bold] as? Bool != true)
editor.string = "```python\ndef main():\n pass" #expect(attributes("# literal")[NoteAttributes.headingLevel] == nil)
MarkdownHighlighting.apply(to: editor) #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) 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 _ = NSApplication.shared
let editor = NSTextView() let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 600))
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" 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.string = text editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
MarkdownHighlighting.apply(to: editor)
let storage = try #require(editor.textStorage) let storage = try #require(editor.textStorage)
func font(_ token: String) throws -> NSFont { func font(_ token: String) throws -> NSFont {
try #require(storage.attribute(.font, at: (editor.string as NSString).range(of: token).location, 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 } 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) } for (larger, smaller) in zip(sizes, sizes.dropFirst()) { #expect(larger > smaller) }
#expect(try font("Six").pointSize >= font("Body").pointSize) #expect(try font("Six").pointSize >= font("Body").pointSize)
#expect(try font("**Bold**").pointSize == font("Two").pointSize) #expect(try font("Bold").pointSize == font("Two").pointSize)
#expect(try font("*italic*").pointSize == font("Two").pointSize) #expect(try font("italic").pointSize == font("Two").pointSize)
#expect(try font("Literal").pointSize == font("Body").pointSize) #expect(try font("Literal").pointSize == font("Body").pointSize)
#expect(try font("Not a heading").pointSize == font("Body").pointSize) #expect(try font("Not a heading").pointSize == font("Body").pointSize)
#expect(try font("Three").isFixedPitch) #expect(try font("Three").isFixedPitch)
#expect(editor.string == text) let three = (editor.string as NSString).range(of: "Three")
storage.replaceCharacters(in: (editor.string as NSString).range(of: "### Three"), with: "Three") editor.setSelectedRange(three)
MarkdownHighlighting.apply(to: editor) for _ in 0..<4 { editor.format(0) }
#expect(editor.string.contains("Three"))
#expect(try font("Three").pointSize == font("Body").pointSize) #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) let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) } defer { try? FileManager.default.removeItem(at: directory) }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) 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) let source = NSMutableAttributedString(string: text)
source.addAttribute(.attachment, value: NSTextAttachment(), range: NSRange(location: 0, length: 1)) source.addAttribute(.attachment, value: NSTextAttachment(), range: NSRange(location: 0, length: 1))
let export = try NoteMarkdownExport(contents: source) let export = try NoteMarkdownExport(contents: source)
@@ -32,13 +32,13 @@ func markdownExportWritesOriginalImageBytesAndEscapedLinksWithoutChangingSource(
wrapper.preferredFilename = name wrapper.preferredFilename = name
let originalFilename = wrapper.filename let originalFilename = wrapper.filename
let attachment = NSTextAttachment(fileWrapper: wrapper) 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(attachment: attachment))
source.append(NSAttributedString(string: "\n- [ ] todo \n")) source.append(NoteDocument.importMarkdown(NSAttributedString(string: "\n- [ ] todo \n")))
let before = NSAttributedString(attributedString: source) let before = NSAttributedString(attributedString: source)
let export = try NoteMarkdownExport(contents: source) let export = try NoteMarkdownExport(contents: source)
#expect(export.hasImages) #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(source.isEqual(to: before))
#expect(wrapper.preferredFilename == name) #expect(wrapper.preferredFilename == name)
#expect(wrapper.filename == originalFilename) #expect(wrapper.filename == originalFilename)
@@ -46,7 +46,7 @@ func markdownExportWritesOriginalImageBytesAndEscapedLinksWithoutChangingSource(
source.mutableString.setString("Changed") source.mutableString.setString("Changed")
wrapper.preferredFilename = "changed.png" wrapper.preferredFilename = "changed.png"
try export.write(to: directory) 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 String(contentsOf: directory.appendingPathComponent("note.md"), encoding: .utf8) == markdown)
#expect(try Data(contentsOf: directory.appendingPathComponent(name)) == png) #expect(try Data(contentsOf: directory.appendingPathComponent(name)) == png)
#expect(Set(try FileManager.default.contentsOfDirectory(atPath: directory.path)) == ["note.md", name]) #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)) let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 50))
editor.font = NotesAppearance.font editor.font = NotesAppearance.font
editor.drawsBackground = false editor.drawsBackground = false
editor.setMarkdownContents(NSAttributedString(string: "text")) editor.setDocumentContents(NSAttributedString(string: "text"))
func paintedWidth() throws -> Int { func paintedWidth() throws -> Int {
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 100, pixelsHigh: 50, let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 100, pixelsHigh: 50,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, 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 @testable import Conjure
@Test @MainActor @Test @MainActor
func headingsFollowVisibleParagraphEditingAndRoundTrip() throws { func headingShortcutsConsumeSyntaxAndPersistAllSixLevels() throws {
_ = NSApplication.shared _ = NSApplication.shared
let suite = "NotesHeadings.\(UUID().uuidString)" let suite = "NotesHeadings.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite)) let defaults = try #require(UserDefaults(suiteName: suite))
@@ -21,90 +21,60 @@ func headingsFollowVisibleParagraphEditingAndRoundTrip() throws {
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first) let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
window.makeFirstResponder(editor) 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 { for level in 1...6 {
reset("### Title") editor.setDocumentContents(NSAttributedString(string: "Title"))
editor.undoManager?.beginUndoGrouping() editor.setSelectedRange(NSRange(location: 0, length: 0))
editor.beginInsertionSession()
for character in String(repeating: "#", count: level) + " " { for character in String(repeating: "#", count: level) + " " {
editor.insertText(String(character), replacementRange: editor.selectedRange()) editor.insertText(String(character), replacementRange: editor.selectedRange())
} }
editor.undoManager?.endUndoGrouping() editor.endInsertionSession()
let expected = String(repeating: "#", count: level) + " Title" #expect(editor.string == "Title")
#expect(editor.markdownString == expected) #expect(editor.selectedRange() == NSRange(location: 0, length: 0))
let title = (editor.string as NSString).range(of: "Title") #expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "heading")
#expect(editor.selectedRange().location == title.location) #expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level)
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.undoManager?.undo() 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() editor.undoManager?.redo()
#expect(editor.markdownString == expected) #expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level)
#expect(controller.save()) #expect(controller.save())
let reloaded = try #require(NotesStore(directory: directory).notes.first) 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 @Test @MainActor
@@ -116,7 +86,8 @@ func headingCommandsKeepCaretOnTheVisibleBodyLine() throws {
window.makeFirstResponder(editor) window.makeFirstResponder(editor)
defer { window.orderOut(nil) } defer { window.orderOut(nil) }
for source in ["\n# Title", "Text\n\n## Title", "Text\n\n\n###### Title"] { 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)) editor.setSelectedRange(NSRange(location: (editor.string as NSString).range(of: "Title").location, length: 0))
for command in [#selector(NSResponder.deleteBackward(_:)), #selector(NSResponder.insertNewline(_:))] { for command in [#selector(NSResponder.deleteBackward(_:)), #selector(NSResponder.insertNewline(_:))] {
editor.doCommand(by: command) editor.doCommand(by: command)
@@ -128,6 +99,6 @@ func headingCommandsKeepCaretOnTheVisibleBodyLine() throws {
#expect(caretRect.origin == titleRect.origin) #expect(caretRect.origin == titleRect.origin)
#expect(caretRect.height == titleRect.height) #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 @testable import Conjure
@Test @MainActor @Test @MainActor
func noteFormattingHidesSyntaxButPreservesMarkdownAndLiteralCode() throws { func importedFormattingBecomesVisibleTextAndNativeSemanticMarks() throws {
_ = NSApplication.shared _ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 800)) 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*" 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.setMarkdownContents(NSAttributedString(string: source)) editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
MarkdownHighlighting.apply(to: editor) #expect(editor.string == "Heading\nSubheading\nitalic bold emphasis strong gone underlined *literal*\nQuote\nLabel\n# Code *literal*")
editor.refreshTasks() func attribute(_ key: NSAttributedString.Key, in token: String) -> Any? {
let layout = try #require(editor.layoutManager) editor.documentContents.attribute(key, at: (editor.string as NSString).range(of: token).location, effectiveRange: nil)
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))
} }
} #expect(attribute(NoteAttributes.headingLevel, in: "Heading") as? Int == 1)
return result #expect(attribute(NoteAttributes.headingLevel, in: "Subheading") as? Int == 2)
} for token in ["italic", "emphasis"] { #expect(attribute(NoteAttributes.italic, in: token) as? Bool == true) }
#expect(visibleText() == "Heading\nSubheading\nitalic bold emphasis strong gone underlined *literal*\nQuote\nLabel\n```text\n# Code *literal*\n```\nUnmatched * and \\*escaped*") for token in ["bold", "strong"] { #expect(attribute(NoteAttributes.bold, in: token) as? Bool == true) }
#expect(editor.markdownString == source) #expect(attribute(.strikethroughStyle, in: "gone") as? Int == NSUnderlineStyle.single.rawValue)
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count)) #expect(attribute(.underlineStyle, in: "underlined") as? Int == NSUnderlineStyle.single.rawValue)
let pasteboard = NSPasteboard.withUniqueName() #expect(attribute(NoteAttributes.inlineCode, in: "*literal*") as? Bool == true)
defer { pasteboard.releaseGlobally() } #expect(attribute(NoteAttributes.block, in: "Quote") as? String == "quote")
#expect(editor.writeSelection(to: pasteboard, types: editor.writablePasteboardTypes)) #expect(String(describing: try #require(attribute(.link, in: "Label"))) == "https://example.com")
#expect(pasteboard.string(forType: .string) == source) #expect(attribute(NoteAttributes.block, in: "# Code") as? String == "code")
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) #expect(attribute(NoteAttributes.bold, in: "# Code") as? Bool != true)
defer { try? FileManager.default.removeItem(at: directory) } #expect(attribute(NoteAttributes.headingLevel, in: "# Code") == nil)
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)
} }
private final class NotesLayoutProbe: NSLayoutManager { @Test @MainActor
var observeFormatting: ((NSTextStorage) -> Void)? func completedInlineShortcutsConsumeDelimitersAndKeepUndoableNativeMarks() throws {
_ = NSApplication.shared
override func processEditing(for textStorage: NSTextStorage, edited editMask: NSTextStorageEditActions, let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
range newCharRange: NSRange, changeInLength delta: Int, editor.allowsUndo = true
invalidatedRange invalidatedCharRange: NSRange) { let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
observeFormatting?(textStorage) window.contentView = editor
super.processEditing(for: textStorage, edited: editMask, range: newCharRange, window.makeFirstResponder(editor)
changeInLength: delta, invalidatedRange: invalidatedCharRange) 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 @Test @MainActor
func typingDoesNotExposeUnformattedExistingTextToLayout() throws { func richCopyPasteKeepsNativeMarksUnicodeAndImageBytesWithoutMarkdownParsing() throws {
_ = NSApplication.shared _ = NSApplication.shared
let suite = "NotesFlashing.\(UUID().uuidString)" let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1,
let defaults = try #require(UserDefaults(suiteName: suite)) bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite) colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
defer { bitmap.setColor(.red, atX: 0, y: 0)
defaults.removePersistentDomain(forName: suite) let png = try #require(bitmap.representation(using: .png, properties: [:]))
try? FileManager.default.removeItem(at: directory) let file = FileWrapper(regularFileWithContents: png)
} file.preferredFilename = "pixel.png"
let store = try NotesStore(directory: directory) let contents = NSMutableAttributedString(attributedString:
let id = try store.create() NoteDocument.importMarkdown(NSAttributedString(string: "- [x] **🦊 café** ")))
try store.update(id: id, contents: NSAttributedString(string: "# Heading\n- [ ] Task\nPlain")) contents.append(NSAttributedString(attachment: NSTextAttachment(fileWrapper: file)))
defaults.set(id.uuidString, forKey: "selectedNote") let source = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200))
let controller = NotesController(defaults: defaults, directory: directory) source.setDocumentContents(contents)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 570, height: 404), source.setSelectedRange(NSRange(location: 0, length: source.string.utf16.count))
styleMask: .borderless, backing: .buffered, defer: false) let board = NSPasteboard.withUniqueName()
window.contentViewController = controller defer { board.releaseGlobally() }
defer { _ = controller.close(); window.orderOut(nil) } #expect(source.writeSelection(to: board, types: source.writablePasteboardTypes))
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } #expect(board.string(forType: .string) == source.string)
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first) let destination = NotesTextView(frame: source.frame)
let layout = NotesLayoutProbe() destination.allowsUndo = true
try #require(editor.textContainer).replaceLayoutManager(layout) #expect(destination.readSelection(from: board))
editor.refreshTasks() #expect(destination.string == source.string)
window.makeFirstResponder(editor) #expect(destination.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
layout.observeFormatting = { storage in #expect(destination.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "task")
let text = storage.string as NSString #expect(destination.documentContents.attribute(NoteAttributes.checked, at: 0, effectiveRange: nil) as? Bool == true)
let heading = text.range(of: "Heading") let image = try #require(destination.documentContents.attribute(.attachment,
let task = text.range(of: "Task") at: destination.string.utf16.count - 1, effectiveRange: nil) as? NSTextAttachment)
#expect((storage.attribute(.font, at: heading.location, effectiveRange: nil) as? NSFont)?.pointSize == 26) #expect(image.fileWrapper?.regularFileContents == png)
#expect((storage.attribute(.paragraphStyle, at: task.location, effectiveRange: nil) as? NSParagraphStyle)?.headIndent == 22) destination.undoManager?.undo()
#expect(storage.attribute(MarkdownHighlighting.hiddenMarkup, at: 0, effectiveRange: nil) as? Bool == true) #expect(destination.string.isEmpty)
} destination.undoManager?.redo()
defer { layout.observeFormatting = nil } #expect(destination.string == source.string)
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)
} }
@Test @MainActor @Test @MainActor
@@ -112,7 +118,7 @@ func noteImagesRenderRoundedWithoutChangingSavedImageData() throws {
#expect(editor.readSelection(from: pasteboard)) #expect(editor.readSelection(from: pasteboard))
func checkRoundedDrawing() throws { func checkRoundedDrawing() throws {
MarkdownHighlighting.apply(to: editor) editor.applyDocumentStyle()
let layout = try #require(editor.layoutManager) let layout = try #require(editor.layoutManager)
layout.ensureLayout(for: try #require(editor.textContainer)) layout.ensureLayout(for: try #require(editor.textContainer))
let output = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 200, pixelsHigh: 120, 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) } defer { try? FileManager.default.removeItem(at: directory) }
let store = try NotesStore(directory: directory) let store = try NotesStore(directory: directory)
let id = try store.create() 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 reloaded = try #require(NotesStore(directory: directory).notes.first)
let attachment = try #require(reloaded.contents.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment) let attachment = try #require(reloaded.contents.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment)
#expect(attachment.fileWrapper?.regularFileContents == png) #expect(attachment.fileWrapper?.regularFileContents == png)
editor.setMarkdownContents(reloaded.contents) editor.setDocumentContents(reloaded.contents)
try checkRoundedDrawing() try checkRoundedDrawing()
} }
@Test @MainActor @Test @MainActor
func hiddenNoteMarkupKeepsTypingDeletionFormattingAndUndoAtVisibleBoundaries() throws { func nativeFormattingSurvivesVisibleBoundaryEditingAndUndo() throws {
_ = NSApplication.shared _ = NSApplication.shared
let suite = "NotesMarkup.\(UUID().uuidString)" let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
let defaults = try #require(UserDefaults(suiteName: suite)) editor.allowsUndo = true
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite) let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
defer { window.contentView = editor
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)
window.makeFirstResponder(editor) window.makeFirstResponder(editor)
editor.insertText("# Title\n*word*", replacementRange: NSRange(location: 0, length: 0)) defer { window.orderOut(nil) }
editor.setSelectedRange(NSRange(location: 0, length: 0)) editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "# Title\n*word*")))
editor.insertText("A", replacementRange: editor.selectedRange()) editor.insertText("A", replacementRange: NSRange(location: 0, length: 0))
#expect(editor.markdownString == "# ATitle\n*word*") #expect(editor.string == "ATitle\nword")
editor.breakUndoCoalescing() #expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == 1)
editor.undoManager?.removeAllActions()
editor.undoManager?.beginUndoGrouping()
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
editor.undoManager?.removeAllActions()
editor.deleteBackward(nil) editor.deleteBackward(nil)
editor.undoManager?.endUndoGrouping() #expect(editor.string == "ATitle\nwor")
#expect(editor.markdownString == "# ATitle\n*wor*") #expect(editor.documentContents.attribute(NoteAttributes.italic, at: "ATitle\n".utf16.count, effectiveRange: nil) as? Bool == true)
editor.undoManager?.undo() editor.undoManager?.undo()
#expect(editor.markdownString == "# ATitle\n*word*") #expect(editor.string == "ATitle\nword")
let word = (editor.string as NSString).range(of: "word") 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) editor.setSelectedRange(word)
italic.performClick(nil) editor.format(2)
#expect(editor.markdownString == "# ATitle\nword") #expect(editor.string == "ATitle\nword")
#expect(controller.save()) #expect(editor.selectedRange() == word)
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "# ATitle\nword") #expect(editor.documentContents.attribute(NoteAttributes.italic, at: word.location, effectiveRange: nil) as? Bool != true)
} }
@Test @MainActor @Test @MainActor
@@ -234,62 +220,51 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
} }
editor.insertText("fox 🦊", replacementRange: editor.selectedRange()) editor.insertText("fox 🦊", replacementRange: editor.selectedRange())
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count)) editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
let selection = editor.selectedRange()
#expect(try shortcut("b")) #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(try shortcut("i"))
#expect(editor.markdownString == "***fox 🦊***") #expect(editor.documentContents.attribute(NoteAttributes.italic, at: 0, effectiveRange: nil) as? Bool == true)
let font = try #require(editor.textStorage?.attribute(.font, at: editor.selectedRange().location, let font = try #require(editor.textStorage?.attribute(.font, at: 0, effectiveRange: nil) as? NSFont)
effectiveRange: nil) as? NSFont)
let traits = NSFontManager.shared.traits(of: font) let traits = NSFontManager.shared.traits(of: font)
#expect(traits.contains(.boldFontMask) && traits.contains(.italicFontMask)) #expect(traits.contains(.boldFontMask) && traits.contains(.italicFontMask))
#expect(try shortcut("i")) #expect(try shortcut("i"))
#expect(editor.markdownString == "**fox 🦊**")
#expect(try shortcut("b")) #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(try shortcut("s", shift: true))
#expect(editor.markdownString == "~~fox 🦊~~") #expect(editor.documentContents.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
#expect(editor.textStorage?.attribute(.strikethroughStyle, at: editor.selectedRange().location,
effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
#expect(try shortcut("s", shift: true)) #expect(try shortcut("s", shift: true))
#expect(editor.markdownString == "fox 🦊") #expect(editor.documentContents.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) == nil)
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions() editor.undoManager?.removeAllActions()
editor.undoManager?.beginUndoGrouping()
#expect(try shortcut("u", shift: true)) #expect(try shortcut("u", shift: true))
editor.undoManager?.endUndoGrouping() #expect(editor.documentContents.attribute(.underlineStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
#expect(editor.markdownString == "<u>fox 🦊</u>") #expect(editor.string == "fox 🦊")
#expect(editor.textStorage?.attribute(.underlineStyle, at: editor.selectedRange().location, #expect(editor.selectedRange() == selection)
effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
#expect(controller.save()) #expect(controller.save())
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "<u>fox 🦊</u>") let saved = try #require(NotesStore(directory: directory).notes.first)
#expect(try NoteMarkdownExport(contents: editor.markdownContents).clipboardText == "<u>fox 🦊</u>") #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() editor.undoManager?.undo()
#expect(editor.markdownString == "fox 🦊") #expect(editor.documentContents.attribute(.underlineStyle, at: 0, effectiveRange: nil) == nil)
#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(try shortcut("l", shift: true)) #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(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(!(try shortcut("b", option: true)))
#expect(editor.markdownString == "fox 🦊") #expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true)
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions() editor.undoManager?.removeAllActions()
editor.undoManager?.beginUndoGrouping()
#expect(try shortcut("e")) #expect(try shortcut("e"))
editor.undoManager?.endUndoGrouping() #expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: 0, effectiveRange: nil) as? Bool == true)
#expect(editor.markdownString == "`fox 🦊`")
editor.undoManager?.undo() editor.undoManager?.undo()
#expect(editor.markdownString == "fox 🦊") #expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: 0, effectiveRange: nil) as? Bool != true)
#expect(try shortcut("k")) #expect(try shortcut("k"))
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == "Search notes actions" }) let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == "Search notes actions" })
#expect(window.firstResponder !== editor) #expect(window.firstResponder !== editor)
_ = try shortcut("b") _ = 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(search.stringValue.isEmpty)
#expect(controller.handle(#selector(NSResponder.cancelOperation(_:)))) #expect(controller.handle(#selector(NSResponder.cancelOperation(_:))))
#expect(controller.save()) #expect(controller.save())
@@ -297,30 +272,28 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
} }
@Test @MainActor @Test @MainActor
func inlineCodeContainerStaysWithinCodeAndDisappearsWhenDelimitersAreRemoved() throws { func inlineCodeContainerIsLimitedToItsMarkAndDisappearsWhenToggledOff() throws {
_ = NSApplication.shared _ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 300)) 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```" 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 storage = try #require(editor.textStorage)
let layout = try #require(editor.layoutManager) let body = (editor.string as NSString).range(of: "some *literal* code 🦊")
layout.ensureLayout(for: try #require(editor.textContainer))
let body = (source as NSString).range(of: "some *literal* code 🦊")
for index in body.location..<NSMaxRange(body) { for index in body.location..<NSMaxRange(body) {
#expect(storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.accent) #expect(storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.accent)
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.selectionFill) #expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.selectionFill)
} }
for token in ["Before", "after", "not inline"] { for token in ["Before", "after", "not inline"] {
let index = (source as NSString).range(of: token).location let index = (editor.string as NSString).range(of: token).location
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) == nil) #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(storage.attribute(.underlineStyle, at: literalUnderline.location, effectiveRange: nil) == nil)
#expect(editor.markdownString == source) let before = editor.string
#expect(editor.markdownContents.attribute(.backgroundColor, at: body.location, effectiveRange: nil) == nil) editor.setSelectedRange(body)
editor.replaceMarkdown("some *literal* code 🦊", editor.format(4)
range: NSRange(location: body.location - 1, length: body.length + 2)) #expect(editor.string == before)
let plain = (editor.string as NSString).range(of: "some") #expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: body.location, effectiveRange: nil) as? Bool != true)
#expect(storage.attribute(.backgroundColor, at: plain.location, effectiveRange: nil) == nil) #expect(storage.attribute(.backgroundColor, at: body.location, effectiveRange: nil) == nil)
#expect(storage.attribute(.foregroundColor, at: plain.location, effectiveRange: nil) as? NSColor == SunsetPalette.text) #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) let imageFile = FileWrapper(regularFileWithContents: png)
imageFile.preferredFilename = "pixel.png" imageFile.preferredFilename = "pixel.png"
let attachment = NSTextAttachment(fileWrapper: imageFile) 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)) first.append(NSAttributedString(attachment: attachment))
let expectedFirstText = first.string let expectedFirstText = first.string
try store.update(id: firstID, contents: first) try store.update(id: firstID, contents: first)
first.mutableString.setString("Mutated caller buffer") first.mutableString.setString("Mutated caller buffer")
#expect(store.notes.first { $0.id == firstID }?.contents.string == expectedFirstText) #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: "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) #expect(store.notes.first { $0.id == firstID }?.contents.string == expectedFirstText)
let reloaded = try NotesStore(directory: directory) let reloaded = try NotesStore(directory: directory)
#expect(Set(reloaded.notes.map(\.id)) == Set([firstID, secondID])) #expect(Set(reloaded.notes.map(\.id)) == Set([firstID, secondID]))
let restoredFirst = try #require(reloaded.notes.first { $0.id == firstID }) let restoredFirst = try #require(reloaded.notes.first { $0.id == firstID })
#expect(restoredFirst.contents.string == expectedFirstText) #expect(restoredFirst.contents.string == expectedFirstText)
#expect(restoredFirst.title == "# 日本語 🦊") #expect(restoredFirst.title == "日本語 🦊")
let restoredAttachment = try #require(restoredFirst.contents.attribute( let restoredAttachment = try #require(restoredFirst.contents.attribute(
.attachment, at: restoredFirst.contents.length - 1, effectiveRange: nil .attachment, at: restoredFirst.contents.length - 1, effectiveRange: nil
) as? NSTextAttachment) ) as? NSTextAttachment)
let restoredPNG = try #require(restoredAttachment.fileWrapper?.regularFileContents) let restoredPNG = try #require(restoredAttachment.fileWrapper?.regularFileContents)
#expect(restoredPNG == png) #expect(restoredPNG == png)
#expect(NSImage(data: restoredPNG) != nil) #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 @Test @MainActor
@@ -131,3 +131,85 @@ func deletingNotesPersistsWithoutChangingOtherNotesAndPreservesMemoryOnFailure()
#expect(store.notes.isEmpty) #expect(store.notes.isEmpty)
#expect(try NotesStore(directory: directory).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) } 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: [ @Test(arguments: [
("one two three", "dw", "two three"), ("one two three", "dw", "two three"),
("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 { @MainActor func vimOperatorsRespectVisibleTextAndBoundaries(_ scenario: (String, String, String)) throws {
_ = NSApplication.shared _ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) 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.vimEnabled = true
editor.setSelectedRange(NSRange(location: 0, length: 0)) editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys(scenario.1, editor: editor) 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)) #expect(editor.vimMode == (scenario.1.contains("c") ? .insert : .normal))
} }
@@ -64,25 +70,29 @@ private func vimKeys(_ keys: String, editor: NotesTextView) throws {
func vimMotionsCountCharactersWordsAndLogicalLines() throws { func vimMotionsCountCharactersWordsAndLogicalLines() throws {
_ = NSApplication.shared _ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 90, height: 400)) 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.vimEnabled = true
editor.setSelectedRange(NSRange(location: 3, length: 0)) editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys("l", editor: editor) 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) 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) try vimKeys("w", editor: editor)
#expect(editor.selectedRange().location == 13) #expect(editor.selectedRange().location == 10)
try vimKeys("e", editor: editor) try vimKeys("e", editor: editor)
#expect(editor.selectedRange().location == 15) #expect(editor.selectedRange().location == 12)
try vimKeys("w", editor: editor) try vimKeys("w", editor: editor)
#expect(editor.selectedRange().location == 16)
try vimKeys("b", editor: editor)
#expect(editor.selectedRange().location == 13) #expect(editor.selectedRange().location == 13)
try vimKeys("b", editor: editor)
#expect(editor.selectedRange().location == 10)
try vimKeys("0", editor: editor) try vimKeys("0", editor: editor)
#expect(editor.selectedRange().location == 3) #expect(editor.selectedRange().location == 0)
try vimKeys("4ljj", editor: editor) 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. #expect(editor.selectedRange().location == final + 4) // Column survives a shorter line.
try vimKeys("^", editor: editor) try vimKeys("^", editor: editor)
#expect(editor.selectedRange().location == final + 2) #expect(editor.selectedRange().location == final + 2)
@@ -91,13 +101,13 @@ func vimMotionsCountCharactersWordsAndLogicalLines() throws {
try vimKeys("G$", editor: editor) try vimKeys("G$", editor: editor)
#expect(editor.selectedRange().location == editor.string.utf16.count - 1) #expect(editor.selectedRange().location == editor.string.utf16.count - 1)
try vimKeys("gg0", editor: editor) try vimKeys("gg0", editor: editor)
#expect(editor.selectedRange().location == 3) #expect(editor.selectedRange().location == 0)
editor.setMarkdownContents(NSAttributedString(string: "one two")) editor.setDocumentContents(NSAttributedString(string: "one two"))
editor.setSelectedRange(NSRange(location: 0, length: 3)) editor.setSelectedRange(NSRange(location: 0, length: 3))
try vimKeys("d", editor: editor) 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) 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 @Test @MainActor
@@ -111,32 +121,37 @@ func vimYankPasteAndUndoPreserveRichAttachments() throws {
editor.isRichText = true editor.isRichText = true
editor.allowsUndo = true editor.allowsUndo = true
let attachment = NSTextAttachment() let attachment = NSTextAttachment()
let source = NSMutableAttributedString(string: "## Image ") let source = NSMutableAttributedString(string: "Image ")
source.append(NSAttributedString(attachment: attachment)) source.append(NSAttributedString(attachment: attachment))
source.append(NSAttributedString(string: "\nTail")) 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.vimEnabled = true
editor.setSelectedRange(NSRange(location: 3, length: 0)) editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys("yyp", editor: editor) try vimKeys("yyp", editor: editor)
#expect(editor.markdownString == "## Image \u{fffc}\n## Image \u{fffc}\nTail") #expect(editor.documentContents.string == "Image \u{fffc}\nImage \u{fffc}\nTail")
let pasted = editor.markdownContents let pasted = editor.documentContents
#expect(pasted.attribute(.attachment, at: 20, effectiveRange: nil) as? NSTextAttachment === attachment) #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) try vimKeys("u", editor: editor)
#expect(editor.markdownString == source.string) #expect(editor.documentContents.string == source.string)
try vimKey("r", editor: editor, flags: .control) try vimKey("r", editor: editor, flags: .control)
#expect(editor.markdownString == pasted.string) #expect(editor.documentContents.string == pasted.string)
editor.setMarkdownContents(NSAttributedString(string: "one two")) editor.setDocumentContents(NSAttributedString(string: "one two"))
editor.undoManager?.removeAllActions() editor.undoManager?.removeAllActions()
editor.setSelectedRange(NSRange(location: 0, length: 0)) editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys("ywP", editor: editor) try vimKeys("ywP", editor: editor)
#expect(editor.markdownString == "one one two") #expect(editor.documentContents.string == "one one two")
try vimKeys("u", editor: editor) try vimKeys("u", editor: editor)
#expect(editor.markdownString == "one two") #expect(editor.documentContents.string == "one two")
editor.setSelectedRange(NSRange(location: 0, length: 0)) editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys("cw", editor: editor) try vimKeys("cw", editor: editor)
try vimKeys("new", editor: editor) try vimKeys("new", editor: editor)
try vimKey("\u{1b}", editor: editor, code: 53) 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.vimMode == .normal)
#expect(editor.selectedRange().location == 2) #expect(editor.selectedRange().location == 2)
} }
@@ -154,15 +169,120 @@ func vimInsertEntryAndLinewisePasteKeepParagraphsSeparate() throws {
("one", "o", "one\n"), ("one", "o", "one\n"),
("one", "O", "\none") ("one", "O", "\none")
] { ] {
editor.setMarkdownContents(NSAttributedString(string: before)) editor.setDocumentContents(NSAttributedString(string: before))
editor.vimEnabled = true editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 0, length: 0)) editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys(keys, editor: editor) try vimKeys(keys, editor: editor)
#expect(editor.markdownString == after) #expect(editor.documentContents.string == after)
if keys == "O" { #expect(editor.selectedRange().location == 0) } 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 @Test @MainActor
func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws { func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
_ = NSApplication.shared _ = 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" }) let toggle = try #require(descendants(content).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Toggle Vim editing" })
#expect(!editor.vimEnabled) #expect(!editor.vimEnabled)
try vimKeys("hello", editor: editor) try vimKeys("hello", editor: editor)
#expect(editor.markdownString == "hello") #expect(editor.documentContents.string == "hello")
toggle.performClick(nil) toggle.performClick(nil)
#expect(defaults.bool(forKey: "notesVimEnabled")) #expect(defaults.bool(forKey: "notesVimEnabled"))
#expect(editor.vimMode == .normal) #expect(editor.vimMode == .normal)
@@ -204,9 +324,9 @@ func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
window.sendEvent(escape) window.sendEvent(escape)
#expect(window.firstResponder === editor) #expect(window.firstResponder === editor)
#expect(window.isVisible) #expect(window.isVisible)
let beforeMotion = editor.markdownString let beforeMotion = editor.documentContents.string
try vimKeys("w", editor: editor) try vimKeys("w", editor: editor)
#expect(editor.markdownString == beforeMotion) #expect(editor.documentContents.string == beforeMotion)
try vimKeys("d", editor: editor) try vimKeys("d", editor: editor)
window.sendEvent(escape) window.sendEvent(escape)
#expect(!window.isVisible) #expect(!window.isVisible)
@@ -215,9 +335,9 @@ func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
let reopened = try #require(descendants(reopenedContent).compactMap { $0 as? NotesTextView }.first) let reopened = try #require(descendants(reopenedContent).compactMap { $0 as? NotesTextView }.first)
#expect(reopened.vimEnabled) #expect(reopened.vimEnabled)
#expect(reopened.vimMode == .normal) #expect(reopened.vimMode == .normal)
let beforeReopenedMotion = reopened.markdownString let beforeReopenedMotion = reopened.documentContents.string
try vimKeys("w", editor: reopened) 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" }) let newToggle = try #require(descendants(reopenedContent).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Toggle Vim editing" })
newToggle.performClick(nil) newToggle.performClick(nil)
#expect(!defaults.bool(forKey: "notesVimEnabled")) #expect(!defaults.bool(forKey: "notesVimEnabled"))
+22 -19
View File
@@ -1,41 +1,44 @@
import AppKit import AppKit
import ConjureKit
import Testing import Testing
@testable import Conjure @testable import Conjure
@Test @MainActor func numberedMarkersShareTheBodyTextBaseline() throws { @Test @MainActor func numberedMarkersShareTheBodyTextBaseline() throws {
_ = NSApplication.shared _ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200))
for source in ["1. 1", "123) 123", " 12. 12"] { editor.drawsBackground = false
editor.setMarkdownContents(NSAttributedString(string: source)) let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
MarkdownHighlighting.apply(to: editor) window.contentView = editor
editor.refreshTasks() 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 layout = try #require(editor.layoutManager)
let container = try #require(editor.textContainer) let container = try #require(editor.textContainer)
layout.ensureLayout(for: container) layout.ensureLayout(for: container)
let text = editor.string as NSString let glyph = layout.glyphIndexForCharacter(at: 0)
let marker = text.range(of: "\u{fffc}") let fragment = layout.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
let body = NSRange(location: NSMaxRange(marker) + 1, length: text.length - NSMaxRange(marker) - 1) let bodyX = editor.textContainerOrigin.x + fragment.minX + layout.location(forGlyphAt: glyph).x
var bottoms: [Int] = [] let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 400, pixelsHigh: 200,
for range in [marker, body] {
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 400, pixelsHigh: 80,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
let bitmapContext = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) let bitmapContext = try #require(NSGraphicsContext(bitmapImageRep: bitmap))
bitmapContext.cgContext.translateBy(x: 0, y: 80) bitmapContext.cgContext.translateBy(x: 0, y: 200)
bitmapContext.cgContext.scaleBy(x: 1, y: -1) bitmapContext.cgContext.scaleBy(x: 1, y: -1)
let context = NSGraphicsContext(cgContext: bitmapContext.cgContext, flipped: true) let context = NSGraphicsContext(cgContext: bitmapContext.cgContext, flipped: true)
NSGraphicsContext.saveGraphicsState() NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = context NSGraphicsContext.current = context
context.cgContext.clear(CGRect(x: 0, y: 0, width: 400, height: 80)) context.cgContext.clear(CGRect(x: 0, y: 0, width: 400, height: 200))
layout.drawGlyphs(forGlyphRange: layout.glyphRange(forCharacterRange: range, actualCharacterRange: nil), at: .zero) editor.draw(editor.bounds)
NSGraphicsContext.restoreGraphicsState() NSGraphicsContext.restoreGraphicsState()
var bottom = -1 var markerBottom = -1, bodyBottom = -1
for y in 0..<80 { for y in 0..<200 {
for x in 0..<400 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.2 { bottom = y } 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}"))
} }
} }