diff --git a/Package.resolved b/Package.resolved index a687faf..d9159cb 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fc870f54e8ae0c2fe51ba1e87667691472a20f609075b305c4117df7d7db9336", + "originHash" : "ca20de906df51b7d817aeab261f0e858854d41b957c0169d04e413adab8a05bf", "pins" : [ { "identity" : "highlighterswift", @@ -9,6 +9,24 @@ "revision" : "fe7aae9c9b31d3b296fd3d2dd575e1a207bb29e0", "version" : "3.1.0" } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark.git", + "state" : { + "revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a", + "version" : "0.9.0" + } + }, + { + "identity" : "swift-markdown", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-markdown", + "state" : { + "revision" : "ea79e83c8744d2b50b0dc2d5bbd1e857e1253bf9", + "version" : "0.6.0" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 071e362..1b13d17 100644 --- a/Package.swift +++ b/Package.swift @@ -5,9 +5,14 @@ let package = Package( name: "Conjure", platforms: [.macOS(.v14)], products: [.executable(name: "Conjure", targets: ["Conjure"])], - dependencies: [.package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.1.0")], + dependencies: [ + .package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.1.0"), + .package(url: "https://github.com/swiftlang/swift-markdown", exact: "0.6.0"), + ], targets: [ - .target(name: "ConjureKit", resources: [.process("Resources")]), + .target(name: "ConjureKit", dependencies: [ + .product(name: "Markdown", package: "swift-markdown"), + ], resources: [.process("Resources")]), .executableTarget(name: "Conjure", dependencies: [ "ConjureKit", .product(name: "Highlighter", package: "HighlighterSwift"), ]), diff --git a/README.md b/README.md index 66b37b1..bc7de52 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Commands explicitly declare `Application.Command.presentsView`. The shared dispa Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it a global shortcut in Settings. Notes starts with a compact 570 × 404 layout inspired by Raycast Notes, with rounded corners and a centered muted note title, while using Conjure’s shared warm gradient background, cream text, and amber list markers and caret. Drag a window edge or corner to resize it; Notes remembers its size across reopening and app restarts, independently of other launcher views. The minimum size is 570 × 260, and restored dimensions fit the current screen’s visible area. The title bar offers Close; the bottom-right action pill offers **Browse Notes** and **Actions**, with **New Note** in Actions and on Command-N. Browse Notes (Command-P) opens Conjure’s styled, scrollable searchable picker upwards from the footer, shared with translation language selection—not a native menu. Fuzzy-search note titles, then select with Up/Down, Control-J/K, and Return or by clicking a note. A checkmark marks the current note, which is selected and scrolled into view on opening. Reopening Notes restores the last selection. Each note’s first nonempty line becomes its title; duplicate titles remain independently selectable. -Notes uses a standard macOS window with system-inherited appearance and only the native Close button visible; minimize and zoom are hidden. The editor uses 15-point monospaced body text, including bold, italic, and code, while retaining Conjure’s warm palette. Heading sizes decrease from H1 through H6 (26, 23, 21, 19, 17, and 15 points); inline emphasis retains the heading’s size, and fenced code is not treated as headings. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless with their existing dark appearance. +Notes uses a standard macOS window with system-inherited appearance and only the native Close button visible; minimize and zoom are hidden. The editor uses 15-point monospaced body text, including bold, italic, and code, while retaining Conjure’s warm palette. Heading sizes decrease from H1 through H6 (26, 23, 21, 19, 17, and 15 points); inline emphasis retains the heading’s size, and code-block contents remain literal. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless with their existing dark appearance. Actions (Command-K) opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P), **New Note** (Command-N), and **Delete Note**. Delete Note asks for confirmation before permanently deleting the currently selected note and its embedded images. Delete is focused by default: Return confirms and Escape cancels; deletion cannot be undone. After deletion, Notes selects a remaining note or creates a fresh blank note if none remain. Save or deletion failures preserve the current note and editor buffer. Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its title-bar controls are covered. Escape or clicking outside closes an open popup and returns focus to the editor without leaving Notes; another Escape dismisses the launcher. Control-J/K navigates only while a popup is open, preserving normal editor commands when it is closed. @@ -149,9 +149,9 @@ Notes actions are ordered Browse Notes, New Note, Copy as Markdown, Export as Ma Notes Actions also includes **Copy as Markdown** and **Export as Markdown…**. Both export the whole note, regardless of the selection, without modifying the editor or RTFD autosaves. Clipboard exports replace images with inline-code filename placeholders such as ``. 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 `…` 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 `…`. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type returns the paragraphs to body text. Successful autosaves stay unobtrusive; only save, load, deletion, or export errors appear below the editor. -Markdown highlighting and list alignment update in one formatting transaction per edit, so typing does not expose an intermediate unformatted layout. +Typing, toolbar actions, and Vim share one attributed-document edit and Undo path. Completed shortcuts publish their final formatting and list layout before autosaving; edits do not serialize and reconstruct the document as Markdown. Note text fades over 16 points at the editor’s scroll edges rather than clipping sharply above the formatting strip. Matching 16-point scroll insets keep the first and last lines clear of the fade when scrolled to either end. The 36-point title header and footer meet the editor without extra vertical gaps. @@ -172,27 +172,23 @@ Formatting shortcuts use the same actions as the bottom strip. They apply to the | Bullet list | Command-Shift-L | | Checklist | Command-Shift-T | -Edit Markdown directly: headings, bold, italic, underline, strikethrough, links, quotes, and inline code render without their recognized formatting delimiters. Heading `#` prefixes, emphasis markers, underline tags, link wrappers and destinations, and quote prefixes are hidden rather than deleted. Code-block fences and their language labels stay visible, with syntax highlighting inside the block. Inline code uses Conjure’s orange accent text on a lightly tinted inline container; this background is display-only and stays out of saved contents. Unmatched or escaped punctuation and literal code remain visible. Copying a selection containing hidden syntax and saving notes preserve the original Markdown. Arrow navigation and typing skip hidden syntax, while the formatting strip still toggles it. Bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and native checkbox markers share a marker column, so item text aligns at the same nesting depth even with long numbers; wrapped lines align with the item text, not the marker. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues lists at the same indentation, incrementing numbers and resetting task checkboxes. On an empty item, Return exits one nesting level; repeat to leave the list entirely. Nesting has no fixed depth limit. Prose and fenced code retain ordinary newlines. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note. +Notes uses Notion-style Markdown input shortcuts: completing `**bold**`, `*italic*`, `~~strike~~`, inline backticks, `underline`, or `[label](url)` consumes the syntax and creates formatting on the visible text. At paragraph start, `# ` through `###### ` set the absolute heading level; `> `, `- `, `1. `, and `- [ ] ` create quotes and lists. There are no hidden delimiter positions for the caret to enter or accidentally delete. Unmatched syntax stays literal. Plain-text paste stays literal rather than being parsed as Markdown; rich-text copy/paste between Notes editors preserves semantic formatting and image bytes. Whole-note Markdown export generates normalized syntax rather than preserving the original delimiter spelling. -Typing `# ` through `###### ` at the visible start of an existing heading replaces its level rather than adding to it. Backspace at a heading's visible start removes the preceding newline, leaving a heading at the start of the document unchanged. Removing an empty line above preserves the heading; joining onto nonempty text inherits the preceding paragraph's type (`Text\n## Heading` becomes `TextHeading`). Return at a heading's visible start inserts an empty line above it, keeping its hidden prefix with the heading text, including after Backspace. Both commands keep the caret on the heading's visible line rather than the preceding line. These edits support Undo and retain Markdown serialization inside the existing RTFD autosaves. +Backspace at a heading's visible start removes the preceding newline, leaving a heading at the document start unchanged. Removing an empty line above preserves its heading type; joining onto nonempty prose inherits the preceding paragraph's type. Return at a heading's start inserts an empty line above it; Return within or at the end of a heading creates a body paragraph. These operations use the same visible text positions as native selection and support Undo. -Tab anywhere on a bullet, numbered, or task item indents that line by two spaces, preserving the caret or selection within the line. Shift-Tab removes one leading tab or up to two spaces; at the root it does nothing and never removes the item. These commands change only the current line’s indentation, not nested children. Moving an item into or out of a numbered list updates the affected sibling sequences: a new nested sequence starts at 1, an existing sequence continues from its preceding sibling, and later siblings are renumbered in the saved and copied Markdown too. Existing standalone starting numbers and each marker’s delimiter are preserved. Indentation and numbering changes undo together. Return on a whitespace-only item outdents to its actual parent, or removes one indent unit when there is no parent; only Return on an empty root item removes the marker. Repeated Return therefore exits even a standalone nested list one level at a time. +Tab on a bullet, numbered, or task item increases its nesting depth; Shift-Tab decreases it, stopping at the root. Carets and Unicode selections remain at the same text offsets. A new nested numbered sequence starts at 1 and following peers are renumbered. Return continues a nonempty list item, resets a new task to unchecked, and outdents an empty item one level before returning a root-level empty item to body text. -Checkbox conversion waits for a space or tab after the closing bracket: type `- [ ] `, `- [] `, or `- [x] `. A closing bracket alone remains literal, so typing directly after it does not briefly create and then remove a checkbox. Both `[]` and `[ ]` mean unchecked; toggling a compact `[]` task on produces `[x]`, and toggling it off produces `[ ]`. +Type a space after `[ ]`, `[]`, or `[x]` at a bullet's start to make a task. Checklist buttons toggle semantic checked state without changing text or moving the caret. Bullets and numbers are drawn in the paragraph gutter, not stored as text or synthetic attachments. Arrow keys, selections, typing, and Vim operate on the actual item body. Plain copying copies visible text; **Copy as Markdown** includes the list syntax. -When typing a list marker before existing whitespace on an otherwise blank line, the marker stays literal until you explicitly type Space or Tab immediately after it. Existing whitespace does not complete the marker for you. Tab completes a still-literal marker; on an already rendered list item it indents the line. Loading or pasting valid list Markdown still renders it directly. +Bullets and checkboxes are centered in their shared gutter; numbered markers are right-aligned on the body text baseline. Wrapped lines share the same text column, including nested items and gutters widened for longer numbers. -List markers are atomic inline tokens, including nested bullets and numbers; task markers such as `- [ ]` and `- [x]` use native checkboxes. Left/Right crosses a marker and its single required separator (a space or tab) in one visible step, between the marker's start and the item body. Only that separator is hidden: additional spaces and tabs are visible body text with normal cursor stops. Shift-Left/Right uses the same boundaries and preserves the selection anchor when extending or reversing direction. Visible indentation and body whitespace retain normal character stops. Selecting only a token copies only its original marker; a keyboard selection across the marker also includes its required separator. Click a checkbox to toggle completion without moving the selection. Backspace at the start of the body expands the original marker and deletes its final character, preserving the separator; forward Delete immediately before the token expands the marker and deletes its first character. Each edit is one Undo step. Removing an item's preceding newline after prose makes its marker literal inline text; joining onto an empty or indentation-only line keeps a valid list token. Saved notes and copied text retain the original Markdown spelling, numbers, case, and spacing, alongside embedded images; alignment formatting is display-only. Thematic breaks and list syntax inside fenced code or ordinary prose stay literal. - -Unordered markers display as `•`; bullets and checkboxes are centered in the shared marker column. Checkboxes align vertically with the text’s cap-height center, using its baseline rather than the attachment frame. Numbered markers display their original number and delimiter, right-aligned before the item text and resting on the same text baseline. Item text and wrapped lines retain their shared alignment, including nested lists and columns widened by longer numbers. The stored and copied Markdown retains the original bullet spelling. - -Typing, pasting, and Return at the visible start of an item use the position after its preserved Markdown separator. Continuing to type after a marker converts therefore keeps the item rendered, rather than inserting characters into the marker syntax. - -Fenced code uses [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) (Highlight.js) for language-aware token colors. Add a language after the opening backticks or tildes, for example `py`, `python`, `swift`, `js`, or `json`. Unlabeled fences use automatic detection; unknown languages remain plain monospace. Highlighting runs entirely offline. +Type three backticks, optionally followed by a language such as `swift` or `python`, then Return to enter a code block. The input fence is consumed; code content stays literal and uses [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) for offline token colors. Return on an empty code paragraph returns to body text; the Code block button also toggles the paragraph type. Unspecified languages use automatic detection, and unknown languages remain plain monospace. Markdown export regenerates fences and language labels. Embedded images display with 8-point rounded corners. Rounding only clips their rendering in Notes; saving and copying retain the original image data. -Notes autosave locally as RTFD packages, including embedded images, under `~/Library/Application Support/Conjure/Notes`. No account, network access, or cloud sync is involved. Save failures appear below the editor and prevent switching, closing, or quitting until saving succeeds, preserving the unsaved buffer. The Close button and Command-W dismiss Conjure, including any open popup; leaving Notes or switching destinations removes its popup completely and restores the launcher’s original dimensions. +Paste images or drag image files into the editor to embed them locally. Command-Z undoes edits; Command-Shift-Z redoes them. Switching notes clears Undo history so edits cannot spill into another note. + +Notes autosave locally as RTFD packages, including embedded images, under `~/Library/Application Support/Conjure/Notes`. Each package also contains versioned `ConjureNote.json` metadata for paragraph types and inline marks that RTFD does not preserve by itself. Metadata is validated against the stored text; corrupt or unsupported metadata reports an error instead of being interpreted as legacy Markdown. Older Markdown-backed RTFD notes are converted in memory on load. Their first save preserves the original package in `LegacyBackups/.rtfd` before atomically writing the structured package; backup failures prevent migration. No account, network access, or cloud sync is involved. Save failures preserve the unsaved buffer and prevent switching, closing, or quitting until saving succeeds. The Close button and Command-W dismiss Conjure, including open popups; leaving Notes removes its popup and restores the launcher’s original dimensions. ### Vim editing @@ -216,7 +212,7 @@ Normal mode uses a translucent, one-character-wide block cursor. Insert mode and | Native Undo / Redo | `u` / Control-R; Command-Z / Command-Shift-Z remain available | | Return to Normal (from Insert/Visual) | Escape or Control-[ | -Counts work before motions and operators, including `3j`, `2dd`, and `2d3w`. Vertical movement uses logical lines rather than wrapped display rows and retains the desired column across shorter lines. Character motions and selections respect composed Unicode characters and skip hidden Markdown syntax. Whole-token operators carry recognized inline formatting delimiters with their visible text; linewise operations include heading and list prefixes. Yank/delete/change use a local unnamed register, preserving attributed text and embedded images for `p`/`P` without replacing the system clipboard. Editing and Undo still use the native text view and RTFD autosaves. +Counts work before motions and operators, including `3j`, `2dd`, and `2d3w`. Vertical movement uses logical lines rather than wrapped display rows and retains the desired column across shorter lines. Character motions respect composed Unicode characters and use the actual attributed text offsets. Yank/delete/change use a local unnamed register that preserves formatting and embedded images for `p`/`P` without replacing the system clipboard. A change command and its replacement typing undo together across real key events; focus changes, mode changes, and Command shortcuts finish the insertion group. Vim and ordinary editing use the same document and Undo manager. Escape closes an open picker first. With Vim enabled, Escape returns Insert or Visual mode to Normal; in Normal mode it dismisses Notes, just as when Vim is disabled. Command-W still dismisses the window. Command shortcuts, formatting controls, and native Insert-mode input remain available. Pending commands cancel on focus changes and Command shortcuts. This is a supported Vim subset, not an embedded Vim: search, text objects, named registers, macros, and dot-repeat are not implemented. diff --git a/Sources/Conjure/NotesController.swift b/Sources/Conjure/NotesController.swift index 703b2f1..c1911ea 100644 --- a/Sources/Conjure/NotesController.swift +++ b/Sources/Conjure/NotesController.swift @@ -1,6 +1,5 @@ import AppKit import ConjureKit -import Highlighter import UniformTypeIdentifiers @MainActor @@ -236,66 +235,7 @@ final class NotesController: NSViewController, NSTextViewDelegate { @objc private func formatNote(_ sender: NSButton) { - guard editor.isEditable else { return } - let contents = editor.markdownContents - let source = contents.string as NSString - var selection = editor.markdownInsertionRange(for: editor.selectedRange()) - if sender.tag == 6 { - selection = source.lineRange(for: selection) - if selection.length > 0, source.character(at: NSMaxRange(selection) - 1) == 10 { selection.length -= 1 } - } - let wrappers = [1: ("**", "**"), 2: ("*", "*"), 3: ("~~", "~~"), 4: ("`", "`"), - 5: ("[", "](url)"), 6: ("```\n", "\n```"), 11: ("", "")] - if let (open, close) = wrappers[sender.tag] { - let before = selection.location - open.utf16.count - let after = NSMaxRange(selection) - var stars = 0 - if open == "*" { - while selection.location > stars, after + stars < source.length, - source.character(at: selection.location - stars - 1) == 42, - source.character(at: after + stars) == 42 { stars += 1 } - } - if before >= 0, after + close.utf16.count <= source.length, - source.substring(with: NSRange(location: before, length: open.utf16.count)) == open, - source.substring(with: NSRange(location: after, length: close.utf16.count)) == close, - open != "*" || stars % 2 == 1 { - let body = contents.attributedSubstring(from: selection) - selection = NSRange(location: before, length: selection.length + open.utf16.count + close.utf16.count) - replaceMarkdown(body, range: selection, selection: NSRange(location: before, length: body.length)) - } else { - let body = NSMutableAttributedString(string: open, attributes: editor.typingAttributes) - body.append(contents.attributedSubstring(from: selection)) - body.append(NSAttributedString(string: close, attributes: editor.typingAttributes)) - replaceMarkdown(body, range: selection, - selection: NSRange(location: selection.location + open.utf16.count, length: selection.length)) - } - } else { - let prefixes = [0: "# ", 7: "> ", 8: "1. ", 9: "- ", 10: "- [ ] "] - guard let prefix = prefixes[sender.tag] else { return } - let length = selection.length > 0 && source.character(at: NSMaxRange(selection) - 1) == 10 - ? selection.length - 1 : selection.length - let lines = source.lineRange(for: NSRange(location: selection.location, length: length)) - let body = NSMutableAttributedString(attributedString: contents.attributedSubstring(from: lines)) - let rows = body.string.components(separatedBy: "\n") - var offset = body.length, firstDelta = 0 - for (index, line) in rows.enumerated().reversed() { - offset -= line.utf16.count + (index < rows.count - 1 ? 1 : 0) - if index == rows.count - 1, index > 0, line.isEmpty { continue } - let indent = String(line.prefix { $0 == " " || $0 == "\t" }) - let content = String(line.dropFirst(indent.count)) - let item = Self.listMatch(in: content as NSString) - let kind = item.map { $0.range(at: 6).location != NSNotFound ? 10 : $0.range(at: 3).location != NSNotFound ? 8 : 9 } - let removing = kind == sender.tag || (item == nil && content.hasPrefix(prefix)) - let oldPrefix = item?.range.length ?? (removing ? prefix.utf16.count : 0) - let newPrefix = removing ? "" : sender.tag == 8 ? "\(index + 1). " : prefix - body.replaceCharacters(in: NSRange(location: offset + indent.utf16.count, length: oldPrefix), - with: NSAttributedString(string: newPrefix, attributes: editor.typingAttributes)) - if index == 0 { firstDelta = newPrefix.utf16.count - oldPrefix } - } - let start = max(lines.location, selection.location + firstDelta) - let end = max(start, NSMaxRange(selection) + body.length - lines.length) - replaceMarkdown(body, range: lines, selection: NSRange(location: start, length: end - start)) - } + editor.format(sender.tag) focusEditor() } @@ -306,203 +246,12 @@ final class NotesController: NSViewController, NSTextViewDelegate { _ = save() } - private static let listItem = try! NSRegularExpression( - pattern: "^([ \\t]*)([-+*]|([0-9]+)([.)]))([ \\t]+|$)(?:\\[([ xX]?)\\]([ \\t]+|$))?" - ) - - private static func listMatch(in line: NSString) -> NSTextCheckingResult? { - guard let item = listItem.firstMatch(in: line as String, range: NSRange(location: 0, length: line.length)) else { return nil } - let marker = line.substring(with: item.range(at: 2)) - // Spaced thematic breaks are not list items or list ancestors. - if marker == "-" || marker == "*" { - let characters = (line as String).lazy.filter { !$0.isWhitespace } - let bullet = marker.first! - if characters.count >= 3, characters.allSatisfy({ $0 == bullet }) { return nil } - } - return item - } - - private static func indentationWidth(_ indentation: String) -> Int { - indentation.reduce(0) { $1 == "\t" ? $0 + 4 - $0 % 4 : $0 + 1 } - } - - private static func outdentLength(_ indentation: String) -> Int { - indentation.hasPrefix("\t") ? 1 : indentation.prefix(2).prefix { $0 == " " }.count - } - - private static func continuedPrefix(_ item: NSTextCheckingResult, in line: NSString) -> String? { - var marker = line.substring(with: item.range(at: 2)) - if item.range(at: 3).location != NSNotFound { - guard let number = UInt64(line.substring(with: item.range(at: 3))), number < UInt64.max else { return nil } - marker = "\(number + 1)" + line.substring(with: item.range(at: 4)) - } - var prefix = line.substring(with: item.range(at: 1)) + marker + line.substring(with: item.range(at: 5)) - if item.range(at: 6).location != NSNotFound { - prefix += "[ ]" + line.substring(with: item.range(at: 7)) - } - return prefix - } - - private func setSelectionWithUndo(_ selection: NSRange) { - let previous = editor.selectedRange() - editor.undoManager?.registerUndo(withTarget: self) { $0.setSelectionWithUndo(previous) } - editor.setSelectedRange(selection) - } - - private func replaceListMarkdown(_ replacement: String, range: NSRange, selection: NSRange) { - replaceMarkdown(NSAttributedString(string: replacement, attributes: editor.typingAttributes), range: range, selection: selection) - } - - private func replaceMarkdown(_ replacement: NSAttributedString, range: NSRange, selection: NSRange) { - editor.undoManager?.beginUndoGrouping() - // Native text undo restores its diff boundary, not the user's selection. - setSelectionWithUndo(editor.selectedRange()) - editor.replaceMarkdown(replacement, range: range) - setSelectionWithUndo(editor.renderedRange(for: selection)) - editor.undoManager?.endUndoGrouping() - } - - func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - let newline = commandSelector == #selector(NSResponder.insertNewline(_:)) - let indenting = commandSelector == #selector(NSResponder.insertTab(_:)) - let outdenting = commandSelector == #selector(NSResponder.insertBacktab(_:)) - guard textView === editor, editor.isEditable, newline || indenting || outdenting else { return false } - let source = editor.markdownString as NSString - let selection = editor.markdownInsertionRange(for: editor.selectedRange()) - var start = 0, end = 0 - source.getLineStart(&start, end: nil, contentsEnd: &end, - for: NSRange(location: selection.location, length: 0)) - let line = source.substring(with: NSRange(location: start, length: end - start)) as NSString - guard let item = Self.listMatch(in: line), NSMaxRange(selection) <= end else { return false } - let codeBlocks = MarkdownHighlighting.codeBlocks(in: source as String) - guard !codeBlocks.contains(where: { - NSIntersectionRange($0.range, NSRange(location: start, length: end - start)).length > 0 - }) else { return false } - let indent = line.substring(with: item.range(at: 1)) - if indenting { - let marker = editor.renderedRange(for: - NSRange(location: start + item.range(at: 2).location, length: 0)).location - // A literal marker still needs native Tab to complete its separator. - guard editor.textStorage?.attribute(.attachment, at: marker, effectiveRange: nil) is NSTextAttachment else { return false } - } - if indenting || outdenting { - let removed = outdenting ? Self.outdentLength(indent) : 0 - guard indenting || removed > 0 else { return true } - let replacement = indenting ? " " : "" - let delta = replacement.utf16.count - removed - var selectedStart = max(start, selection.location + delta) - var selectedEnd = max(start, NSMaxRange(selection) + delta) - func isListLine(_ range: NSRange) -> Bool { - Self.listMatch(in: source.substring(with: range) as NSString) != nil - && !codeBlocks.contains { NSIntersectionRange($0.range, range).length > 0 } - } - var block = source.lineRange(for: NSRange(location: start, length: 0)) - while block.location > 0 { - let previous = source.lineRange(for: NSRange(location: block.location - 1, length: 0)) - guard isListLine(previous) else { break } - block = NSUnionRange(previous, block) - } - while NSMaxRange(block) < source.length { - let next = source.lineRange(for: NSRange(location: NSMaxRange(block), length: 0)) - guard isListLine(next) else { break } - block = NSUnionRange(block, next) - } - let body = NSMutableAttributedString(attributedString: editor.markdownContents.attributedSubstring(from: block)) - body.replaceCharacters(in: NSRange(location: start - block.location, length: removed), with: replacement) - let changed = body.string as NSString - let oldDepth = Self.indentationWidth(indent) - let newDepth = Self.indentationWidth(replacement + (indent as NSString).substring(from: removed)) - var ancestors: [(depth: Int, number: UInt64?, delimiter: String)] = [] - var edits: [(range: NSRange, number: String)] = [] - var oldLevel = true, newLevel = true - var offset = 0 - while offset < changed.length { - let row = changed.lineRange(for: NSRange(location: offset, length: 0)) - let text = changed.substring(with: row) as NSString - guard let match = Self.listMatch(in: text) else { break } - let depth = Self.indentationWidth(text.substring(with: match.range(at: 1))) - if offset + block.location > start { - if depth < oldDepth { oldLevel = false } - if depth < newDepth { newLevel = false } - } - while let last = ancestors.last, last.depth > depth { ancestors.removeLast() } - let peer = ancestors.last?.depth == depth ? ancestors.removeLast() : nil - var number: UInt64? - var delimiter = "" - if match.range(at: 3).location != NSNotFound { - number = UInt64(text.substring(with: match.range(at: 3))) - delimiter = text.substring(with: match.range(at: 4)) - if offset + block.location >= start, - (oldLevel && depth == oldDepth) || (newLevel && depth == newDepth) { - if let peer, peer.delimiter == delimiter, let previous = peer.number, previous < UInt64.max { - number = previous + 1 - } else if !ancestors.isEmpty || offset + block.location > start { - number = 1 - } - if let number, UInt64(text.substring(with: match.range(at: 3))) != number { - let digits = match.range(at: 3) - edits.append((NSRange(location: offset + digits.location, length: digits.length), String(number))) - } - } - } - ancestors.append((depth, number, delimiter)) - offset = NSMaxRange(row) - } - for edit in edits.reversed() { - body.replaceCharacters(in: edit.range, with: edit.number) - let location = block.location + edit.range.location - let difference = edit.number.utf16.count - edit.range.length - func adjusted(_ position: Int) -> Int { - if position <= location { return position } - if position < location + edit.range.length { return location + min(position - location, edit.number.utf16.count) } - return position + difference - } - selectedStart = adjusted(selectedStart) - selectedEnd = adjusted(selectedEnd) - } - replaceMarkdown(body, range: block, - selection: NSRange(location: selectedStart, length: selectedEnd - selectedStart)) - return true - } - let markerEnd = item.range(at: 6).location == NSNotFound - ? NSMaxRange(item.range(at: 2)) : NSMaxRange(item.range(at: 6)) + 1 - guard selection.location >= start + markerEnd else { return false } - let body = line.substring(from: item.range.length) - if body.trimmingCharacters(in: .whitespaces).isEmpty { - var replacement = indent.isEmpty ? "" : line.substring(from: Self.outdentLength(indent)) - var previous = start - let depth = Self.indentationWidth(indent) - while depth > 0, previous > 0 { - let range = source.lineRange(for: NSRange(location: previous - 1, length: 0)) - previous = range.location - let ancestor = source.substring(with: range) as NSString - if let parent = Self.listMatch(in: ancestor), - !codeBlocks.contains(where: { NSIntersectionRange($0.range, range).length > 0 }) { - let parentIndent = ancestor.substring(with: parent.range(at: 1)) - if Self.indentationWidth(parentIndent) < depth { - replacement = Self.continuedPrefix(parent, in: ancestor) ?? "" - break - } - } else if !(ancestor as String).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - let leading = (ancestor as String).prefix { $0 == " " || $0 == "\t" } - if Self.indentationWidth(String(leading)) < depth { break } - } - } - replaceListMarkdown(replacement, range: NSRange(location: start, length: end - start), - selection: NSRange(location: start + replacement.utf16.count, length: 0)) - } else { - guard let prefix = Self.continuedPrefix(item, in: line) else { return false } - replaceListMarkdown("\n" + prefix, range: selection, - selection: NSRange(location: selection.location + 1 + prefix.utf16.count, length: 0)) - } - return true - } @discardableResult func save() -> Bool { guard isDirty, let store, let selectedID else { return true } do { - try store.update(id: selectedID, contents: editor.markdownContents) + try store.update(id: selectedID, contents: editor.documentContents) isDirty = false noteTitle.stringValue = store.notes.first { $0.id == selectedID }?.title ?? "Untitled Note" status.isHidden = true @@ -530,7 +279,7 @@ final class NotesController: NSViewController, NSTextViewDelegate { func handleCommandKey(_ event: NSEvent) -> Bool { guard store != nil, view.window?.attachedSheet == nil else { return false } let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift]) - if modifiers.contains(.command) { editor.clearVimCommand() } + if modifiers.contains(.command) { editor.endInsertionSession(); editor.clearVimCommand() } let key = event.charactersIgnoringModifiers?.lowercased() ?? "" if let index = Self.formattingKeys.firstIndex(where: { $0.key == key && $0.modifiers == modifiers }) { guard editor.isEditable, !actions.isShown, view.window?.firstResponder === editor, @@ -571,7 +320,7 @@ final class NotesController: NSViewController, NSTextViewDelegate { private func copyAsMarkdown() { actions.close(animated: false) do { - let export = try NoteMarkdownExport(contents: editor.markdownContents) + let export = try NoteMarkdownExport(contents: editor.documentContents) let clipboard = NSPasteboard.general clipboard.clearContents() guard clipboard.setString(export.clipboardText, forType: .string) else { @@ -584,7 +333,7 @@ final class NotesController: NSViewController, NSTextViewDelegate { guard let window = view.window, window.attachedSheet == nil else { return } actions.close(animated: false) do { - let export = try NoteMarkdownExport(contents: editor.markdownContents) + let export = try NoteMarkdownExport(contents: editor.documentContents) let panel = NSSavePanel() panel.title = "Export as Markdown" panel.prompt = "Export" @@ -635,7 +384,7 @@ final class NotesController: NSViewController, NSTextViewDelegate { try store.delete(id: id) self.selectedID = nil self.defaults.removeObject(forKey: "selectedNote") - self.editor.setMarkdownContents(NSAttributedString(string: "")) + self.editor.setDocumentContents(NSAttributedString(string: "")) self.editor.undoManager?.removeAllActions() self.editor.isEditable = false self.noteTitle.stringValue = "Untitled Note" @@ -654,7 +403,7 @@ final class NotesController: NSViewController, NSTextViewDelegate { guard let note = store?.notes.first(where: { $0.id == id }) else { return } selectedID = id defaults.set(id.uuidString, forKey: "selectedNote") - editor.setMarkdownContents(note.contents) + editor.setDocumentContents(note.contents) editor.undoManager?.removeAllActions() editor.isEditable = true editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) @@ -694,174 +443,3 @@ private final class NotesView: NSView { } } -private final class RoundedImageAttachmentCell: NSTextAttachmentCell { - override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) { - NSGraphicsContext.saveGraphicsState() - defer { NSGraphicsContext.restoreGraphicsState() } - NSBezierPath(roundedRect: cellFrame, xRadius: 8, yRadius: 8).addClip() - super.draw(withFrame: cellFrame, in: controlView) - } -} - - -@MainActor -enum MarkdownHighlighting { - // ponytail: whole-note highlighting; incremental parsing if large notes make typing slow. - static let hiddenMarkup = NSAttributedString.Key("Conjure.hiddenMarkup") - static let codeBackground = NSAttributedString.Key("Conjure.codeBackground") - private static let inlineCode = try! NSRegularExpression(pattern: "(?`+)(?[^`\\n]+)\\k(?!`)") - private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [ - (try! NSRegularExpression(pattern: "(?\\*{2,3}|_{2,3})(?\\S(?:[^\\n]*?\\S)?)\\k(?![*_])"), [.font: NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize, weight: .bold)]), - (try! NSRegularExpression(pattern: "(?[*_])(?[^\\s*_](?:[^*_\\n]*?[^\\s*_])?)\\k(?![*_])"), [.font: NSFontManager.shared.convert(NotesAppearance.font, toHaveTrait: .italicFontMask)]), - (try! NSRegularExpression(pattern: "(?[^\\]\\n]+)\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]), - (try! NSRegularExpression(pattern: "(?\\S(?:[^\\n]*?\\S)?)~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]), - (try! NSRegularExpression(pattern: "(?(?[^\\n]+?)"), [.underlineStyle: NSUnderlineStyle.single.rawValue]), - (try! NSRegularExpression(pattern: "(?m)^(?:>[ \\t]*)+(?[^\\n]+)"), [:]), - ] - private static let heading = try! NSRegularExpression(pattern: "(?m)^(#{1,6}) (?.+)$") - 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] - } -} diff --git a/Sources/Conjure/NotesTextView.swift b/Sources/Conjure/NotesTextView.swift index 304ecbc..9b504bb 100644 --- a/Sources/Conjure/NotesTextView.swift +++ b/Sources/Conjure/NotesTextView.swift @@ -1,743 +1,630 @@ import AppKit - -private let literalTaskMarker = NSAttributedString.Key("Conjure.literalTaskMarker") -private let sourceTaskAttachment = NSAttributedString.Key("Conjure.sourceTaskAttachment") -private let sourceListParagraph = NSAttributedString.Key("Conjure.sourceListParagraph") -let listWhitespace = NSAttributedString.Key("Conjure.listWhitespace") - -private final class ListAttachment: NSTextAttachment { - let source: NSAttributedString - let isTask: Bool - var marker: String { source.string } - var checked: Bool { marker.hasSuffix("[x]") || marker.hasSuffix("[X]") } - - @MainActor - init(source: NSAttributedString, isTask: Bool) { - self.source = source - self.isTask = isTask - super.init(data: nil, ofType: nil) - let cell = ListAttachmentCell(textCell: isTask ? "" : (source.length == 1 ? "•" : source.string)) - cell.attributedStringValue = NSAttributedString(string: cell.stringValue, - attributes: [.font: NotesAppearance.font, .foregroundColor: SunsetPalette.accent]) - cell.alignment = source.length == 1 ? .center : .right - attachmentCell = cell - bounds = NSRect(x: 0, y: -4, width: 22, height: 21) - } - - required init?(coder: NSCoder) { fatalError("List attachments are serialized as Markdown") } -} - -private final class ListAttachmentCell: NSTextAttachmentCell { - nonisolated(unsafe) var columnWidth: CGFloat = 22 - nonisolated override func cellSize() -> NSSize { NSSize(width: columnWidth, height: 21) } - nonisolated override func cellBaselineOffset() -> NSPoint { NSPoint(x: 0, y: -4) } - override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) { - guard !stringValue.isEmpty else { return } - let label = attributedStringValue - let size = label.size() - let x = alignment == .center ? cellFrame.midX - size.width / 2 : cellFrame.maxX - 4 - size.width - let y = alignment == .center ? cellFrame.midY - size.height / 2 - : cellFrame.maxY + cellBaselineOffset().y - ceil(NotesAppearance.font.ascender) - label.draw(at: NSPoint(x: x, y: y)) - } -} +import ConjureKit +import Highlighter @MainActor -final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate { - private static let listPattern = try! NSRegularExpression( - pattern: "(?m)^([ \\t]*)([-+*]|[0-9]+[.)])([ \\t]+)(?:(\\[([ xX]?)\\])(?=[ \\t]))?" - ) - private static let headingPrefix = try! NSRegularExpression(pattern: "^#{1,6} ") - - private static func heading(in source: NSString, at position: Int) -> NSRange? { - let line = source.lineRange(for: NSRange(location: position, length: 0)) - guard let match = headingPrefix.firstMatch(in: source.substring(with: line), - range: NSRange(location: 0, length: line.length)), - !MarkdownHighlighting.codeBlocks(in: source as String).contains(where: { - NSIntersectionRange($0.range, line).length > 0 - }) else { return nil } - return NSRange(location: line.location, length: match.range.length) - } - private var tasks: [(range: NSRange, attachment: ListAttachment)] = [] +final class NotesTextView: NSTextView { + private let edits: UndoManager = { + let manager = UndoManager() + manager.groupsByEvent = false + return manager + }() + private var insertionSession = false + private var styling = false + private var emptyTypingAttributes: [NSAttributedString.Key: Any]? + private var operationDepth = 0 + private var documentChanged = false + private var markerColumn: CGFloat = 22 private var checkboxes: [NSButton] = [] - private var horizontalSelection: (anchor: Int, head: Int)? + private var listParagraphs: [(range: NSRange, kind: String, depth: Int, number: Int, checked: Bool)] = [] var vimState = NotesVimState() { - didSet { - if oldValue.mode != vimState.mode { needsDisplay = true } - } + didSet { if oldValue.mode != vimState.mode { needsDisplay = true } } } var vimModeChanged: (() -> Void)? var vimEnabled = false { - didSet { resetVim(); needsDisplay = true } + didSet { endInsertionSession(); resetVim(); needsDisplay = true } } + override var undoManager: UndoManager? { edits } + var documentContents: NSAttributedString { attributedString() } - var markdownContents: NSAttributedString { Self.serialize(attributedString()) } - var markdownString: String { markdownContents.string } - - func setMarkdownContents(_ contents: NSAttributedString) { - horizontalSelection = nil + func setDocumentContents(_ contents: NSAttributedString) { + endInsertionSession() resetVim() - textStorage?.setAttributedString(Self.render(contents)) - refreshTasks() + textStorage?.setAttributedString(contents) + applyDocumentStyle() + setSelectedRange(NSRange(location: 0, length: 0)) + setTypingAttributes(attributes(at: 0)) } - private var normalCursorWidth: CGFloat { - let position = selectedRange().location - let cursorFont = position < (textStorage?.length ?? 0) - ? textStorage?.attribute(.font, at: position, effectiveRange: nil) as? NSFont : font - return max(2, ceil((cursorFont ?? NotesAppearance.font).maximumAdvancement.width)) + func beginInsertionSession() { + guard !insertionSession else { return } + breakUndoCoalescing() + edits.beginUndoGrouping() + insertionSession = true } - override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) { - guard vimEnabled, vimMode == .normal else { - super.drawInsertionPoint(in: rect, color: color, turnedOn: flag) - return + func endInsertionSession() { + guard insertionSession else { return } + breakUndoCoalescing() + insertionSession = false + edits.endUndoGrouping() + } + + private func grouped(_ action: () -> Void) { + let ownsGroup = edits.groupingLevel == 0 + if ownsGroup { edits.beginUndoGrouping() } + operationDepth += 1 + action() + operationDepth -= 1 + if operationDepth == 0, documentChanged { + documentChanged = false + let typing = currentTypingAttributes + applyDocumentStyle() + super.didChangeText() + setTypingAttributes(typing) } - var block = rect - block.size.width = normalCursorWidth - super.drawInsertionPoint(in: block, color: color.withAlphaComponent(0.45), turnedOn: flag) + if ownsGroup { edits.endUndoGrouping() } } - override func setNeedsDisplay(_ invalidRect: NSRect) { - var rect = invalidRect - if vimEnabled, vimMode == .normal { rect.size.width += normalCursorWidth } - super.setNeedsDisplay(rect) - } - private static func serialize(_ contents: NSAttributedString, keepingMetadata: Bool = false) -> NSAttributedString { - let result = NSMutableAttributedString(attributedString: contents) - restoreListLayout(in: result) - MarkdownHighlighting.removeCodeBackground(from: result) - result.removeAttribute(MarkdownHighlighting.hiddenMarkup, range: NSRange(location: 0, length: result.length)) - var replacements: [(NSRange, ListAttachment)] = [] - contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: contents.length)) { value, range, _ in - if let task = value as? ListAttachment { replacements.append((range, task)) } - } - for (range, task) in replacements.reversed() { - let marker = NSMutableAttributedString(attributedString: task.source) - let markerRange = NSRange(location: 0, length: marker.length) - marker.removeAttribute(literalTaskMarker, range: markerRange) - if keepingMetadata { marker.addAttribute(sourceTaskAttachment, value: task, range: markerRange) } - result.replaceCharacters(in: range, with: marker) - } - if !keepingMetadata { - let range = NSRange(location: 0, length: result.length) - result.removeAttribute(literalTaskMarker, range: range) - result.removeAttribute(sourceTaskAttachment, range: range) - } - return result - } - - private static func restoreListLayout(in contents: NSMutableAttributedString) { - let range = NSRange(location: 0, length: contents.length) - contents.enumerateAttribute(sourceListParagraph, in: range) { value, range, _ in - guard let value else { return } - if let style = value as? NSParagraphStyle { - contents.addAttribute(.paragraphStyle, value: style, range: range) - } else { - contents.removeAttribute(.paragraphStyle, range: range) + func replaceDocument(_ replacement: NSAttributedString, range: NSRange, selection: NSRange? = nil) { + guard isEditable, let storage = textStorage, range.location >= 0, range.location <= storage.length, + range.length >= 0, range.length <= storage.length - range.location else { return } + grouped { + // Preflight normally captures AppKit undo; this transaction owns the attributed inverse. + edits.disableUndoRegistration() + let allowed = shouldChangeText(in: range, replacementString: replacement.string) + edits.enableUndoRegistration() + guard allowed else { return } + let previous = storage.attributedSubstring(from: range) + let previousSelection = selectedRange() + let previousTyping = currentTypingAttributes + edits.registerUndo(withTarget: self) { editor in + editor.replaceDocument(previous, + range: NSRange(location: range.location, length: replacement.length), selection: previousSelection) } + storage.replaceCharacters(in: range, with: replacement) + setSelectedRange(selection ?? NSRange(location: range.location + replacement.length, length: 0)) + let typing = replacement.length > 0 ? replacement.attributes(at: replacement.length - 1, effectiveRange: nil) + : paragraph(at: selectedRange().location).length == 0 ? previousTyping : attributes(at: selectedRange().location) + setTypingAttributes(typing) + didChangeText() } - contents.removeAttribute(sourceListParagraph, range: range) - contents.removeAttribute(listWhitespace, range: range) - } - - // ponytail: parse the whole note on edits; incremental parsing if large lists make typing slow. - private static func render(_ contents: NSAttributedString) -> NSAttributedString { - let source = contents.string as NSString - let range = NSRange(location: 0, length: source.length) - let code = MarkdownHighlighting.codeBlocks(in: contents.string) - let result = NSMutableAttributedString(attributedString: contents) - result.removeAttribute(sourceTaskAttachment, range: range) - // Explicit values stop AppKit inheriting display-only attributes from replaced list text. - result.addAttribute(listWhitespace, value: false, range: range) - contents.enumerateAttribute(.paragraphStyle, in: range) { style, run, _ in - result.addAttribute(sourceListParagraph, value: style ?? NSNull(), range: run) - } - result.addAttribute(literalTaskMarker, value: false, range: range) - contents.enumerateAttribute(literalTaskMarker, in: range) { value, range, _ in - if value as? Bool == true { result.addAttribute(literalTaskMarker, value: true, range: range) } - } - for match in listPattern.matches(in: contents.string, range: range).reversed() { - let isTask = match.range(at: 4).location != NSNotFound - var markerRange = match.range(at: 2) - if isTask { markerRange.length = NSMaxRange(match.range(at: 4)) - markerRange.location } - let line = source.lineRange(for: match.range) - if !isTask { - let body = source.substring(with: NSRange(location: NSMaxRange(match.range), length: NSMaxRange(line) - NSMaxRange(match.range))) - .trimmingCharacters(in: .newlines) - // Leave a task's bracket prefix editable until its closing bracket has a separator. - if ["[", "[ ", "[x", "[X"].contains(body) - || ["[]", "[ ]", "[x]", "[X]"].contains(where: { body.hasPrefix($0) }) { continue } - let marker = source.substring(with: markerRange) - if marker == "-" || marker == "*" { - let characters = source.substring(with: line).filter { !$0.isWhitespace } - if characters.count >= 3, characters.allSatisfy({ String($0) == marker }) { continue } - } - } - guard !code.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue } - var literal = false - contents.enumerateAttribute(literalTaskMarker, in: markerRange) { value, _, stop in - if value as? Bool == true { literal = true; stop.pointee = true } - } - guard !literal else { continue } - let marker = source.substring(with: markerRange) - let existing = contents.attribute(sourceTaskAttachment, at: markerRange.location, effectiveRange: nil) as? ListAttachment - let attachment: ListAttachment - if let existing, existing.marker == marker { - attachment = existing - } else { - let markerSource = NSMutableAttributedString(attributedString: contents.attributedSubstring(from: markerRange)) - markerSource.removeAttribute(sourceTaskAttachment, range: NSRange(location: 0, length: markerSource.length)) - attachment = ListAttachment(source: markerSource, isTask: isTask) - } - let token = NSMutableAttributedString(attachment: attachment) - var attributes = contents.attributes(at: markerRange.location, effectiveRange: nil) - attributes.removeValue(forKey: .attachment) - attributes.removeValue(forKey: sourceTaskAttachment) - attributes[literalTaskMarker] = false - attributes[listWhitespace] = false - attributes[sourceListParagraph] = attributes[.paragraphStyle] ?? NSNull() - token.addAttributes(attributes, range: NSRange(location: 0, length: 1)) - result.replaceCharacters(in: markerRange, with: token) - } - return result - } - - func markdownRange(for rendered: NSRange) -> NSRange { - func offset(_ position: Int) -> Int { - var result = min(position, string.utf16.count) - for task in tasks where NSMaxRange(task.range) <= position { - result += task.attachment.marker.utf16.count - 1 - } - return result - } - let start = offset(rendered.location) - return NSRange(location: start, length: offset(NSMaxRange(rendered)) - start) - } - - func renderedRange(for markdown: NSRange) -> NSRange { - func offset(_ position: Int, end: Bool) -> Int { - var expansion = 0 - for task in tasks { - let start = task.range.location + expansion - let length = task.attachment.marker.utf16.count - if position < start { break } - if position < start + length { - return task.range.location + ((position > start && end) ? 1 : 0) - } - expansion += length - 1 - } - return min(max(0, position - expansion), string.utf16.count) - } - let start = offset(markdown.location, end: markdown.length == 0) - return NSRange(location: start, length: offset(NSMaxRange(markdown), end: true) - start) - } - - private var textTypingAttributes: [NSAttributedString.Key: Any] { - var attributes = typingAttributes - attributes.removeValue(forKey: .attachment) - attributes.removeValue(forKey: literalTaskMarker) - attributes.removeValue(forKey: sourceTaskAttachment) - if let original = attributes.removeValue(forKey: sourceListParagraph) { - attributes[.paragraphStyle] = original as? NSParagraphStyle - } - attributes.removeValue(forKey: listWhitespace) - attributes.removeValue(forKey: MarkdownHighlighting.hiddenMarkup) - if attributes.removeValue(forKey: MarkdownHighlighting.codeBackground) as? Bool == true { - attributes.removeValue(forKey: .backgroundColor) - } - return attributes - } - - func replaceMarkdown(_ text: String, range: NSRange) { - replaceSource(NSAttributedString(string: text, attributes: textTypingAttributes), range: range, semantic: true) - } - - func replaceMarkdown(_ contents: NSAttributedString, range: NSRange) { - replaceSource(contents, range: range, semantic: true) - } - - func markdownInsertionRange(for rendered: NSRange) -> NSRange { - guard rendered.length == 0 else { return markdownRange(for: rendered) } - let body = hiddenBoundary(from: rendered.location, forward: true) - return markdownRange(for: NSRange(location: body, length: 0)) } override func keyDown(with event: NSEvent) { if handleVimKey(event) { return } - if event.keyCode == 48, - event.modifierFlags.intersection([.command, .control, .option, .shift]) == .shift, - delegate?.textView?(self, doCommandBy: #selector(NSResponder.insertBacktab(_:))) == true { - return - } - super.keyDown(with: event) + if vimEnabled, vimMode == .insert, !event.modifierFlags.contains(.command) { beginInsertionSession() } + grouped { super.keyDown(with: event) } } override func resignFirstResponder() -> Bool { let resigned = super.resignFirstResponder() - if resigned { clearVimCommand() } + if resigned { endInsertionSession(); clearVimCommand() } return resigned } override func mouseDown(with event: NSEvent) { + endInsertionSession() if vimEnabled && vimMode != .insert { resetVim() } else { clearVimCommand() } super.mouseDown(with: event) } override func insertText(_ insertString: Any, replacementRange: NSRange) { - let replacement: NSAttributedString - if let attributed = insertString as? NSAttributedString { - replacement = Self.serialize(attributed, keepingMetadata: true) - } else if let text = insertString as? String { - replacement = NSAttributedString(string: text, attributes: textTypingAttributes) - } else { return } + if vimEnabled, vimMode == .insert { beginInsertionSession() } let range = replacementRange.location == NSNotFound ? selectedRange() : replacementRange - replaceSource(replacement, range: markdownInsertionRange(for: range), semantic: false, - typedInput: insertString is String) + let replacement: NSAttributedString + if let value = insertString as? NSAttributedString { replacement = value } + else if let value = insertString as? String { + var typing = currentTypingAttributes + if range.location < string.utf16.count { + let context = attributes(at: range.location) + for key in Self.blockKeys { + typing.removeValue(forKey: key) + if let value = context[key] { typing[key] = value } + } + } + typing.removeValue(forKey: .attachment) + replacement = NSAttributedString(string: value, attributes: typing) + } else { return } + grouped { + replaceDocument(replacement, range: range) + if insertString is String, !hasMarkedText(), replacement.length > 0 { consumeShortcut() } + } } - private func replaceSource(_ replacement: NSAttributedString, range originalRange: NSRange, semantic: Bool, typedInput: Bool = false) { + private func attributes(at position: Int) -> [NSAttributedString.Key: Any] { + guard let storage = textStorage, storage.length > 0 else { + return [.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text, .paragraphStyle: NotesAppearance.paragraph] + } + var result = storage.attributes(at: min(max(0, position), storage.length - 1), effectiveRange: nil) + result.removeValue(forKey: .attachment) + return result + } + + private var currentTypingAttributes: [NSAttributedString.Key: Any] { + paragraph(at: selectedRange().location).length == 0 ? emptyTypingAttributes ?? typingAttributes : typingAttributes + } + + private func setTypingAttributes(_ original: [NSAttributedString.Key: Any]) { + var attrs = original + attrs.removeValue(forKey: .attachment) + let level = min(6, max(1, attrs[NoteAttributes.headingLevel] as? Int ?? 1)) + let heading = attrs[NoteAttributes.block] as? String == "heading" + let size: CGFloat = heading ? [26, 23, 21, 19, 17, 15][level - 1] : 15 + let font = NSFont.monospacedSystemFont(ofSize: size, + weight: attrs[NoteAttributes.bold] as? Bool == true ? .bold : heading ? .semibold : .regular) + attrs[.font] = attrs[NoteAttributes.italic] as? Bool == true + ? NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) : font + attrs[.foregroundColor] = attrs[NoteAttributes.inlineCode] as? Bool == true ? SunsetPalette.accent : SunsetPalette.text + typingAttributes = attrs + emptyTypingAttributes = paragraph(at: selectedRange().location).length == 0 ? attrs : nil + } + + private static let blockKeys = [NoteAttributes.block, NoteAttributes.headingLevel, NoteAttributes.depth, + NoteAttributes.number, NoteAttributes.checked, NoteAttributes.language] + private static let inlineKeys = [NoteAttributes.bold, NoteAttributes.italic, NoteAttributes.inlineCode, + NSAttributedString.Key.underlineStyle, .strikethroughStyle, .link] + private func paragraph(at position: Int) -> NSRange { + (string as NSString).paragraphRange(for: NSRange(location: min(position, string.utf16.count), length: 0)) + } + private func blockAttributes(_ kind: String?, from original: [NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any] { + var result = original + for key in Self.blockKeys { result.removeValue(forKey: key) } + if let kind { result[NoteAttributes.block] = kind } + return result + } + + func format(_ tool: Int) { guard isEditable else { return } - let source = NSMutableAttributedString(attributedString: Self.serialize(attributedString(), keepingMetadata: true)) - var range = originalRange - guard range.location <= source.length, NSMaxRange(range) <= source.length else { return } - let oldHeading = Self.heading(in: source.string as NSString, at: range.location) - // Return at the visible paragraph start keeps the heading prefix with its body. - if replacement.string == "\n", range.length == 0, let heading = oldHeading, - range.location == NSMaxRange(heading) { - range.location = heading.location - } - var caretPosition = range.location + replacement.length - var existingWhitespace = false - if typedInput, range.length == 0, replacement.length > 0 { - var end = 0 - (source.string as NSString).getLineStart(nil, end: nil, contentsEnd: &end, - for: NSRange(location: range.location, length: 0)) - let tail = (source.string as NSString).substring(with: NSRange(location: range.location, length: max(0, end - range.location))) - existingWhitespace = !tail.isEmpty && tail.allSatisfy { $0 == " " || $0 == "\t" } - } - source.replaceCharacters(in: range, with: replacement) - // A newly typed prefix at the visible start replaces the paragraph's existing level. - if typedInput, range.length == 0, let heading = oldHeading, - range.location >= NSMaxRange(heading) { - let bodyStart = NSMaxRange(heading) - let commandLength = caretPosition - bodyStart - if commandLength > 0, - let command = Self.headingPrefix.firstMatch(in: (source.string as NSString).substring( - with: NSRange(location: bodyStart, length: commandLength)), - range: NSRange(location: 0, length: commandLength)), - command.range.length == commandLength { - source.deleteCharacters(in: heading) - caretPosition -= heading.length - } - } - if typedInput, range.length == 0, replacement.length > 0, - caretPosition == range.location + replacement.length { - let line = (source.string as NSString).lineRange(for: NSRange(location: range.location, length: 0)) - if let match = Self.listPattern.firstMatch(in: source.string, range: line) { - var marker = match.range(at: 2) - if match.range(at: 4).location != NSNotFound { - marker.length = NSMaxRange(match.range(at: 4)) - marker.location + endInsertionSession() + let keys: [Int: NSAttributedString.Key] = [1: NoteAttributes.bold, 2: NoteAttributes.italic, + 3: .strikethroughStyle, 4: NoteAttributes.inlineCode, 5: .link, 11: .underlineStyle] + if let key = keys[tool] { + let selection = selectedRange() + let existing = selection.length > 0 ? attributes(at: selection.location) : typingAttributes + let removing = (existing[key] as? Bool == true) || (existing[key] as? Int ?? 0) != 0 || (key == .link && existing[key] != nil) + let value: Any = key == .link ? "url" : (key == .underlineStyle || key == .strikethroughStyle ? 1 : true) + let booleanMark = [NoteAttributes.bold, NoteAttributes.italic, NoteAttributes.inlineCode].contains(key) + if selection.length == 0 { + if removing { + if booleanMark { typingAttributes[key] = false } + else { typingAttributes.removeValue(forKey: key) } } - if range.location == NSMaxRange(marker), - replacement.string.allSatisfy({ $0 == " " || $0 == "\t" }) { - source.removeAttribute(literalTaskMarker, range: marker) - } else if existingWhitespace, range.location >= marker.location, - range.location + replacement.length == NSMaxRange(marker) { - source.addAttribute(literalTaskMarker, value: true, range: marker) + else { typingAttributes[key] = value } + return + } + let replacement = NSMutableAttributedString(attributedString: documentContents.attributedSubstring(from: selection)) + let full = NSRange(location: 0, length: replacement.length) + if removing { + if booleanMark { replacement.addAttribute(key, value: false, range: full) } + else { replacement.removeAttribute(key, range: full) } + } + else { replacement.addAttribute(key, value: value, range: full) } + replaceDocument(replacement, range: selection, selection: selection) + } else { + let kinds = [0: "heading", 6: "code", 7: "quote", 8: "ordered", 9: "bullet", 10: "task"] + guard let kind = kinds[tool] else { return } + modifyParagraphs { attrs in + let old = attrs[NoteAttributes.block] as? String + if kind == "heading" { + let level = attrs[NoteAttributes.headingLevel] as? Int ?? 0 + var changed = self.blockAttributes(level == 6 ? nil : kind, from: attrs) + if level < 6 { changed[NoteAttributes.headingLevel] = level + 1 } + return changed } + var changed = self.blockAttributes(old == kind ? nil : kind, from: attrs) + if old != kind { + if ["bullet", "ordered", "task"].contains(kind) { changed[NoteAttributes.depth] = 0 } + if kind == "ordered" { changed[NoteAttributes.number] = 1 } + if kind == "task" { changed[NoteAttributes.checked] = false } + } + return changed } } - let rendered = Self.render(source) - let before = attributedString() - let old = before.string as NSString, new = rendered.string as NSString - func equal(_ left: Int, _ right: Int) -> Bool { - guard old.character(at: left) == new.character(at: right) else { return false } - let lhs = before.attribute(.attachment, at: left, effectiveRange: nil) as? NSTextAttachment - let rhs = rendered.attribute(.attachment, at: right, effectiveRange: nil) as? NSTextAttachment - if let lhs = lhs as? ListAttachment, let rhs = rhs as? ListAttachment { - return lhs.marker == rhs.marker + } + + private func modifyParagraphs(_ transform: ([NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any]) { + let selection = selectedRange() + if string.isEmpty || selection.location == string.utf16.count && string.hasSuffix("\n") && selection.length == 0 { + setTypingAttributes(transform(currentTypingAttributes)) + return + } + let length = selection.length > 0 ? selection.length - 1 : 0 + let range = (string as NSString).paragraphRange(for: NSRange(location: selection.location, length: length)) + let replacement = NSMutableAttributedString(attributedString: documentContents.attributedSubstring(from: range)) + let source = replacement.string as NSString + var index = 0 + while index < source.length { + let row = source.paragraphRange(for: NSRange(location: index, length: 0)) + let attrs = transform(replacement.attributes(at: index, effectiveRange: nil)) + for key in Self.blockKeys { + replacement.removeAttribute(key, range: row) + if let value = attrs[key] { replacement.addAttribute(key, value: value, range: row) } } - guard lhs === rhs else { return false } - return (before.attribute(literalTaskMarker, at: left, effectiveRange: nil) as? Bool == true) - == (rendered.attribute(literalTaskMarker, at: right, effectiveRange: nil) as? Bool == true) + index = NSMaxRange(row) } - var prefix = 0 - while prefix < min(old.length, new.length), equal(prefix, prefix) { prefix += 1 } - if prefix < old.length { prefix = min(prefix, old.rangeOfComposedCharacterSequence(at: prefix).location) } - if prefix < new.length { prefix = min(prefix, new.rangeOfComposedCharacterSequence(at: prefix).location) } - var suffix = 0 - while suffix < min(old.length, new.length) - prefix, - equal(old.length - suffix - 1, new.length - suffix - 1) { suffix += 1 } - for text in [old, new] where suffix > 0 && suffix < text.length { - let boundary = text.length - suffix - let character = text.rangeOfComposedCharacterSequence(at: boundary) - if character.location < boundary { suffix = min(suffix, text.length - NSMaxRange(character)) } + replaceDocument(replacement, range: range, selection: selection) + setTypingAttributes(attributes(at: selection.location)) + } + + override func insertTab(_ sender: Any?) { + if ["bullet", "ordered", "task"].contains(typingBlock) { + modifyParagraphs { attrs in + var attrs = attrs + attrs[NoteAttributes.depth] = (attrs[NoteAttributes.depth] as? Int ?? 0) + 1 + if attrs[NoteAttributes.block] as? String == "ordered" { attrs[NoteAttributes.number] = 1 } + return attrs + } + } else { grouped { super.insertTab(sender) } } + } + override func insertBacktab(_ sender: Any?) { + guard ["bullet", "ordered", "task"].contains(typingBlock) else { return } + modifyParagraphs { attrs in + var attrs = attrs + attrs[NoteAttributes.depth] = max(0, (attrs[NoteAttributes.depth] as? Int ?? 0) - 1) + return attrs } - let removed = NSRange(location: prefix, length: old.length - prefix - suffix) - let inserted = NSRange(location: prefix, length: new.length - prefix - suffix) - let structural = semantic || caretPosition != originalRange.location + replacement.length - if removed.length > 0 || inserted.length > 0 { - if structural { breakUndoCoalescing() } - super.insertText(rendered.attributedSubstring(from: inserted), replacementRange: removed) - if structural { breakUndoCoalescing() } + } + private var typingBlock: String { + let attrs = selectedRange().location < string.utf16.count ? attributes(at: selectedRange().location) : currentTypingAttributes + return attrs[NoteAttributes.block] as? String ?? "" + } + + override func insertNewline(_ sender: Any?) { + guard isEditable else { return } + grouped { + let selection = selectedRange() + let row = paragraph(at: selection.location) + let prefix = (string as NSString).substring(with: NSRange(location: row.location, length: selection.location - row.location)) + if prefix.hasPrefix("```"), prefix.dropFirst(3).allSatisfy({ $0.isLetter || $0.isNumber || "_+.-".contains($0) }) { + var attrs = blockAttributes(typingBlock == "code" ? nil : "code", from: attributes(at: row.location)) + if prefix.count > 3 { attrs[NoteAttributes.language] = String(prefix.dropFirst(3)) } + replaceDocument(NSAttributedString(string: "", attributes: attrs), range: NSRange(location: row.location, length: prefix.utf16.count)) + setTypingAttributes(attrs) + return + } + let kind = typingBlock + let empty = (string as NSString).substring(with: row).trimmingCharacters(in: .newlines).isEmpty + if empty, ["bullet", "ordered", "task", "quote", "code"].contains(kind) { + let depth = currentTypingAttributes[NoteAttributes.depth] as? Int ?? 0 + modifyParagraphs { attrs in + if depth > 0 { + var attrs = attrs; attrs[NoteAttributes.depth] = depth - 1; return attrs + } + return self.blockAttributes(nil, from: attrs) + } + return + } + var next = attributes(at: selection.location) + if selection.location == string.utf16.count { next = currentTypingAttributes } + if kind == "heading" { next = blockAttributes(nil, from: next) } + if kind == "task" { next[NoteAttributes.checked] = false } + if kind == "ordered" { next[NoteAttributes.number] = (next[NoteAttributes.number] as? Int ?? 1) + 1 } + for key in Self.inlineKeys { next.removeValue(forKey: key) } + if NSMaxRange(selection) <= NSMaxRange(row), !(kind == "heading" && selection.location == row.location) { + let tail = NSRange(location: NSMaxRange(selection), length: NSMaxRange(row) - NSMaxRange(selection)) + let replacement = NSMutableAttributedString(string: "\n", attributes: next) + replacement.append(documentContents.attributedSubstring(from: tail)) + let full = NSRange(location: 0, length: replacement.length) + for key in Self.blockKeys { + replacement.removeAttribute(key, range: full) + if let value = next[key] { replacement.addAttribute(key, value: value, range: full) } + } + replaceDocument(replacement, range: NSRange(location: selection.location, length: NSMaxRange(row) - selection.location), + selection: NSRange(location: selection.location + 1, length: 0)) + } else { + replaceDocument(NSAttributedString(string: "\n", attributes: next), range: selection) + } + setTypingAttributes(next) } - let caret = renderedRange(for: NSRange(location: caretPosition, length: 0)) - // Upstream affinity places the caret above a paragraph's hidden leading glyphs. - setSelectedRange(NSRange(location: hiddenBoundary(from: caret.location, forward: true), length: 0), - affinity: .downstream, stillSelecting: false) } override func deleteBackward(_ sender: Any?) { let selection = selectedRange() - if selection.length == 0 { - let source = markdownString as NSString - let position = markdownInsertionRange(for: selection).location - if let heading = Self.heading(in: source, at: position), position == NSMaxRange(heading) { - guard heading.location > 0 else { return } - let previous = source.lineRange(for: NSRange(location: heading.location - 1, length: 0)) - let empty = source.substring(with: previous).trimmingCharacters(in: .newlines).isEmpty - replaceMarkdown("", range: NSRange(location: heading.location - 1, - length: 1 + (empty ? 0 : heading.length))) - return - } - } - let boundary = hiddenBoundary(from: selection.location, forward: false) - if selection.length == 0, let task = tasks.first(where: { NSMaxRange($0.range) == boundary }) { - expand(task, backwards: true) - } else if selection.length > 0 { - replaceMarkdown("", range: markdownRange(for: selection)) + if selection.length > 0 { + replaceDocument(NSAttributedString(string: ""), range: selection) } else if selection.location > 0 { let source = string as NSString - let atLineStart = boundary == 0 || source.character(at: boundary - 1) == 10 - let range = boundary < selection.location && atLineStart - ? NSRange(location: boundary, length: selection.location - boundary) - : source.rangeOfComposedCharacterSequence(at: max(0, boundary - 1)) - replaceMarkdown("", range: markdownRange(for: range)) + let removed = source.rangeOfComposedCharacterSequence(at: selection.location - 1) + if source.substring(with: removed).allSatisfy(\.isNewline) { + let current = paragraph(at: selection.location) + let previous = paragraph(at: removed.location) + let previousEmpty = source.substring(with: previous).trimmingCharacters(in: .newlines).isEmpty + let attrs = attributes(at: previousEmpty ? selection.location : previous.location) + let joined = NSUnionRange(previous, current) + let replacement = NSMutableAttributedString(attributedString: documentContents.attributedSubstring(from: joined)) + replacement.deleteCharacters(in: NSRange(location: removed.location - joined.location, length: removed.length)) + let all = NSRange(location: 0, length: replacement.length) + for key in Self.blockKeys { + replacement.removeAttribute(key, range: all) + if let value = attrs[key] { replacement.addAttribute(key, value: value, range: all) } + } + replaceDocument(replacement, range: joined, selection: NSRange(location: removed.location, length: 0)) + setTypingAttributes(attrs) + } else { + replaceDocument(NSAttributedString(string: ""), range: removed) + } } } - override func deleteForward(_ sender: Any?) { let selection = selectedRange() - let boundary = hiddenBoundary(from: selection.location, forward: true) - if selection.length == 0, let task = tasks.first(where: { $0.range.location == selection.location }) { - expand(task, backwards: false) - } else if selection.length > 0 { - replaceMarkdown("", range: markdownRange(for: selection)) - } else if boundary < string.utf16.count { - let range = (string as NSString).rangeOfComposedCharacterSequence(at: boundary) - replaceMarkdown("", range: markdownRange(for: range)) + if selection.length > 0 { replaceDocument(NSAttributedString(string: ""), range: selection) } + else if selection.location < string.utf16.count { + let range = (string as NSString).rangeOfComposedCharacterSequence(at: selection.location) + replaceDocument(NSAttributedString(string: ""), range: range) } } - - private func expand(_ task: (range: NSRange, attachment: ListAttachment), backwards: Bool) { - let marker = task.attachment.source - let remaining = NSRange(location: backwards ? 0 : 1, length: marker.length - 1) - let text = NSMutableAttributedString(attributedString: marker.attributedSubstring(from: remaining)) - text.addAttribute(literalTaskMarker, value: true, range: NSRange(location: 0, length: text.length)) - let range = markdownRange(for: task.range) - replaceSource(text, range: range, semantic: true) - if !backwards { setSelectedRange(NSRange(location: task.range.location, length: 0)) } - } - - // Skip display-only syntax without changing the source offsets used by copy and save. - private func hiddenBoundary(from position: Int, forward: Bool) -> Int { - guard let storage = textStorage else { return position } - let index = forward ? position : position - 1 - guard index >= 0, index < storage.length else { return position } - func boundary(for key: NSAttributedString.Key) -> Int? { - var range = NSRange() - guard storage.attribute(key, at: index, effectiveRange: &range) as? Bool == true else { return nil } - return forward ? NSMaxRange(range) : range.location - } - return boundary(for: listWhitespace) ?? boundary(for: MarkdownHighlighting.hiddenMarkup) ?? position - } - - private func visualListBoundary(from position: Int, forward: Bool) -> Int? { - let boundary = hiddenBoundary(from: position, forward: forward) - guard let task = tasks.first(where: { - forward ? $0.range.location == boundary : NSMaxRange($0.range) == boundary - }) else { return nil } - return forward ? hiddenBoundary(from: NSMaxRange(task.range), forward: true) : task.range.location - } - - override func setSelectedRange(_ charRange: NSRange) { - horizontalSelection = nil - super.setSelectedRange(charRange) - } - - override func setSelectedRange(_ charRange: NSRange, affinity: NSSelectionAffinity, stillSelecting: Bool) { - horizontalSelection = nil - super.setSelectedRange(charRange, affinity: affinity, stillSelecting: stillSelecting) - } - - override func setSelectedRanges(_ ranges: [NSValue], affinity: NSSelectionAffinity, stillSelecting: Bool) { - horizontalSelection = nil - super.setSelectedRanges(ranges, affinity: affinity, stillSelecting: stillSelecting) - } - - private func moveNatively(forward: Bool, selecting: Bool, sender: Any?) { - if selecting { - if forward { super.moveRightAndModifySelection(sender) } - else { super.moveLeftAndModifySelection(sender) } - } else { - if forward { super.moveRight(sender) } - else { super.moveLeft(sender) } - } - } - - private func moveHorizontally(forward: Bool, selecting: Bool, sender: Any?) { - let selection = selectedRange() - let tracked = selecting ? horizontalSelection : nil - if !selecting { horizontalSelection = nil } - let anchor: Int - let head: Int - var nativeHead: Int? - if let tracked { - anchor = tracked.anchor - head = tracked.head - } else if selecting, selection.length > 0 { - // Let AppKit identify the active end of a mouse or otherwise native selection. - moveNatively(forward: forward, selecting: true, sender: sender) - let moved = selectedRange() - guard moved != selection else { return } - anchor = moved.location == selection.location ? selection.location : NSMaxRange(selection) - head = anchor == selection.location ? NSMaxRange(selection) : selection.location - nativeHead = moved.location == anchor ? NSMaxRange(moved) : moved.location - } else { - anchor = selection.location - head = selection.location - } - - if (selecting || selection.length == 0), - let target = visualListBoundary(from: head, forward: forward) { - if selecting { setHorizontalSelection(anchor: anchor, head: target) } - else { setSelectedRange(NSRange(location: target, length: 0)) } - return - } - - // Native movement supplies composed-character, bidi, and line-boundary behavior. - // Starting inside a null run already means "after the marker", so advance into the body. - let origin = hiddenBoundary(from: head, forward: true) - if let nativeHead, origin == head { - let target = hiddenBoundary(from: nativeHead, forward: true) - if target != nativeHead { setHorizontalSelection(anchor: anchor, head: target) } - return - } - if tracked != nil || nativeHead != nil || (selection.length == 0 && origin != head) { - setSelectedRange(NSRange(location: origin, length: 0)) - } - moveNatively(forward: forward, selecting: selecting, sender: sender) - let moved = selectedRange() - let newHead = selecting && moved.location == origin ? NSMaxRange(moved) : moved.location - let target = hiddenBoundary(from: newHead, forward: true) - if selecting { - if tracked != nil || nativeHead != nil || target != newHead || origin != head { - setHorizontalSelection(anchor: anchor, head: target) - } - } else if target != newHead { setSelectedRange(NSRange(location: target, length: 0)) } - } - - private func setHorizontalSelection(anchor: Int, head: Int) { - setSelectedRange(NSRange(location: min(anchor, head), length: abs(head - anchor)), - affinity: selectionAffinity, stillSelecting: false) - horizontalSelection = (anchor, head) - } - - override func moveRight(_ sender: Any?) { - moveHorizontally(forward: true, selecting: false, sender: sender) - } - - override func moveLeft(_ sender: Any?) { - moveHorizontally(forward: false, selecting: false, sender: sender) - } - - override func moveRightAndModifySelection(_ sender: Any?) { - moveHorizontally(forward: true, selecting: true, sender: sender) - } - - override func moveLeftAndModifySelection(_ sender: Any?) { - moveHorizontally(forward: false, selecting: true, sender: sender) - } - - private func markdownSelectionWriter() -> NSTextView { - let writer = NSTextView() - writer.isRichText = isRichText - writer.importsGraphics = importsGraphics - writer.textStorage?.setAttributedString(Self.serialize(attributedString().attributedSubstring(from: selectedRange()))) - writer.setSelectedRange(NSRange(location: 0, length: writer.string.utf16.count)) - return writer - } - - override func writeSelection(to pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool { - markdownSelectionWriter().writeSelection(to: pboard, type: type) - } - - override func writeSelection(to pboard: NSPasteboard, types: [NSPasteboard.PasteboardType]) -> Bool { - markdownSelectionWriter().writeSelection(to: pboard, types: types) - } - - override func readSelection(from pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool { - guard isEditable else { return false } - // Let AppKit decode its supported rich text, file and image formats without touching this undo stack. - let reader = NSTextView() - reader.isRichText = isRichText - reader.importsGraphics = importsGraphics - guard reader.readSelection(from: pboard, type: type) else { return false } - replaceSource(Self.serialize(reader.attributedString(), keepingMetadata: true), - range: markdownInsertionRange(for: selectedRange()), semantic: true) - return true - } - override func cut(_ sender: Any?) { guard isEditable, selectedRange().length > 0 else { return } copy(sender) - replaceMarkdown("", range: markdownRange(for: selectedRange())) + replaceDocument(NSAttributedString(string: ""), range: selectedRange()) + } + + private static let richPasteboard = NSPasteboard.PasteboardType("dev.conjure.notes.document") + override var writablePasteboardTypes: [NSPasteboard.PasteboardType] { [Self.richPasteboard] + super.writablePasteboardTypes } + override var readablePasteboardTypes: [NSPasteboard.PasteboardType] { [Self.richPasteboard] + super.readablePasteboardTypes } + override func writeSelection(to pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool { + guard type == Self.richPasteboard else { return super.writeSelection(to: pboard, type: type) } + do { + let contents = documentContents.attributedSubstring(from: selectedRange()) + guard let data = try NoteDocument.fileWrapper(contents).serializedRepresentation else { return false } + return pboard.setData(data, forType: type) + } catch { return false } + } + override func readSelection(from pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool { + guard isEditable else { return false } + let contents: NSAttributedString + if type == Self.richPasteboard { + guard let data = pboard.data(forType: type), let wrapper = FileWrapper(serializedRepresentation: data), + let decoded = try? NoteDocument.contents(from: wrapper) else { return false } + contents = decoded + } else { + let reader = NSTextView() + reader.isRichText = true; reader.importsGraphics = true + guard reader.readSelection(from: pboard, type: type) else { return false } + let imported = NSMutableAttributedString(attributedString: reader.attributedString()) + imported.enumerateAttribute(.font, in: NSRange(location: 0, length: imported.length)) { value, range, _ in + guard let font = value as? NSFont else { return } + let traits = NSFontManager.shared.traits(of: font) + if traits.contains(.boldFontMask) { imported.addAttribute(NoteAttributes.bold, value: true, range: range) } + if traits.contains(.italicFontMask) { imported.addAttribute(NoteAttributes.italic, value: true, range: range) } + } + contents = imported + } + replaceDocument(contents, range: selectedRange()) + return true + } + + private static let inlineShortcuts = [ + "(?\\*{2,3}|_{2,3})(?\\S(?:[^\\n]*?\\S)?)\\k$", + "(?[*_])(?[^\\s*_](?:[^*_\\n]*?[^\\s*_])?)\\k$", + "(?\\S(?:[^\\n]*?\\S)?)~~$", + "(?`{1,2})(?[^`\\n]+)\\k$", + "(?(?[^\\n]+?)$", + "(?[^\\]\\n]+)\\]\\([^\\n)]+\\)$" + ].map { try! NSRegularExpression(pattern: $0) } + private func consumeShortcut() { + guard typingBlock != "code", selectedRange().length == 0 else { return } + let caret = selectedRange().location + guard caret > 0 else { return } + switch (string as NSString).character(at: caret - 1) { + case 32, 42, 95, 126, 96, 62, 41: break + default: return + } + let row = paragraph(at: caret) + let prefixRange = NSRange(location: row.location, length: caret - row.location) + let prefix = (string as NSString).substring(with: prefixRange) + var kind: String?, level = 0, number = 1 + if prefix.hasSuffix(" ") { + let command = String(prefix.dropLast()) + if (1...6).contains(command.count), command.allSatisfy({ $0 == "#" }) { kind = "heading"; level = command.count } + else if command == ">" { kind = "quote" } + else if ["-", "+", "*"].contains(command) { kind = "bullet" } + else if ["- [ ]", "- []", "- [x]", "- [X]"].contains(command) { kind = "task" } + else if ["[ ]", "[]", "[x]", "[X]"].contains(command), typingBlock == "bullet" { kind = "task" } + else if command.last == "." || command.last == ")", let value = Int(command.dropLast()), value > 0 { kind = "ordered"; number = value } + } + if let kind { + var attrs = blockAttributes(kind, from: attributes(at: row.location)) + if kind == "heading" { attrs[NoteAttributes.headingLevel] = level } + if ["bullet", "ordered", "task"].contains(kind) { attrs[NoteAttributes.depth] = 0 } + if kind == "ordered" { attrs[NoteAttributes.number] = number } + if kind == "task" { attrs[NoteAttributes.checked] = prefix.lowercased().contains("[x]") } + let replacement = NSMutableAttributedString(attributedString: documentContents.attributedSubstring(from: row)) + replacement.deleteCharacters(in: NSRange(location: 0, length: prefixRange.length)) + for key in Self.blockKeys { + replacement.removeAttribute(key, range: NSRange(location: 0, length: replacement.length)) + if let value = attrs[key] { replacement.addAttribute(key, value: value, range: NSRange(location: 0, length: replacement.length)) } + } + replaceDocument(replacement, range: row, selection: NSRange(location: row.location, length: 0)) + setTypingAttributes(attrs) + return + } + // Inline shortcuts parse only the just-completed token, never the editing buffer. + for expression in Self.inlineShortcuts { + guard let match = expression.matches(in: prefix, range: NSRange(location: 0, length: prefix.utf16.count)).last else { continue } + let range = NSRange(location: row.location + match.range.location, length: match.range.length) + let token = documentContents.attributedSubstring(from: range) + let converted = NoteDocument.importMarkdown(token) + guard converted.string != token.string else { continue } + let next = currentTypingAttributes + replaceDocument(converted, range: range) + setTypingAttributes(next) + return + } } override func didChangeText() { - horizontalSelection = nil - refreshTasks() + if operationDepth > 0 { documentChanged = true; return } + applyDocumentStyle() super.didChangeText() } - func refreshTasks() { - tasks = [] - let contents = attributedString() - contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: contents.length)) { value, range, _ in - if let attachment = value as? ListAttachment { tasks.append((range, attachment)) } - } - let taskIndices = tasks.indices.filter { tasks[$0].attachment.isTask } - while checkboxes.count > taskIndices.count { - let checkbox = checkboxes.removeLast() - if window?.firstResponder === checkbox { window?.makeFirstResponder(self) } - checkbox.removeFromSuperview() - } - while checkboxes.count < taskIndices.count { - let checkbox = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:))) - checkbox.controlSize = .small - checkbox.contentTintColor = SunsetPalette.accent - checkbox.sizeToFit() - checkboxes.append(checkbox) - addSubview(checkbox) - } - let source = string as NSString - for (index, taskIndex) in taskIndices.enumerated() { - let task = tasks[taskIndex] - let checkbox = checkboxes[index] - checkbox.tag = taskIndex - checkbox.state = task.attachment.checked ? .on : .off - checkbox.isEnabled = isEditable - let end = NSMaxRange(source.lineRange(for: task.range)) - let title = source.substring(with: NSRange(location: NSMaxRange(task.range), length: end - NSMaxRange(task.range))) - .trimmingCharacters(in: .whitespacesAndNewlines) - checkbox.setAccessibilityLabel(title.isEmpty ? "Task" : title) - checkbox.toolTip = title - } - applyFormatting() - positionCheckboxes() - } - - private func applyFormatting() { - guard let storage = textStorage, let layoutManager else { return } - let range = NSRange(location: 0, length: storage.length) - layoutManager.delegate = self - storage.beginEditing() - Self.restoreListLayout(in: storage) - // Publish Markdown and list styles together, never the unindented reset between them. - MarkdownHighlighting.apply(to: self) - let markerFont = NotesAppearance.font - let column = ceil(tasks.reduce(CGFloat(22)) { width, task in - guard !task.attachment.isTask else { return width } - return max(width, (task.attachment.marker as NSString).size(withAttributes: [.font: markerFont]).width + 4) - }) - let space = (" " as NSString).size(withAttributes: [.font: markerFont]).width + private static let highlighter: Highlighter = { + let value = Highlighter()! + value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: 15) + return value + }() + // ponytail: style all paragraphs; dirty-paragraph styling if large notes make editing slow. + func applyDocumentStyle() { + guard !styling, let storage = textStorage else { return } + styling = true + defer { styling = false } let source = storage.string as NSString - for task in tasks { - let paragraph = source.paragraphRange(for: task.range) - let indentation = source.substring(with: NSRange(location: paragraph.location, length: task.range.location - paragraph.location)) - let depth = indentation.reduce(0) { $1 == "\t" ? $0 + 4 - $0 % 4 : $0 + 1 } - let style = (storage.attribute(.paragraphStyle, at: task.range.location, effectiveRange: nil) as? NSParagraphStyle - ?? NSParagraphStyle.default).mutableCopy() as! NSMutableParagraphStyle - storage.enumerateAttribute(.paragraphStyle, in: paragraph) { original, run, _ in - storage.addAttribute(sourceListParagraph, value: original ?? NSNull(), range: run) + let full = NSRange(location: 0, length: storage.length) + storage.beginEditing() + storage.removeAttribute(.backgroundColor, range: full) + var index = 0 + listParagraphs = [] + var counters: [Int: Int] = [:] + while index < storage.length { + let row = source.paragraphRange(for: NSRange(location: index, length: 0)) + let attrs = storage.attributes(at: index, effectiveRange: nil) + let kind = attrs[NoteAttributes.block] as? String ?? "" + let depth = max(0, attrs[NoteAttributes.depth] as? Int ?? 0) + let level = min(6, max(1, attrs[NoteAttributes.headingLevel] as? Int ?? 1)) + // A joined paragraph inherits its first block's type; inline marks remain independent. + for key in Self.blockKeys { + storage.removeAttribute(key, range: row) + if let value = attrs[key] { storage.addAttribute(key, value: value, range: row) } } - // Keep indentation as native whitespace; null paragraph-leading glyphs lose first-line layout. - style.firstLineHeadIndent = 0 - style.lineSpacing = NotesAppearance.paragraph.lineSpacing - style.paragraphSpacing = NotesAppearance.paragraph.paragraphSpacing - style.headIndent = CGFloat(depth) * space + column - style.tabStops = [] - style.defaultTabInterval = 4 * space - storage.addAttribute(.paragraphStyle, value: style, range: paragraph) - let separator = NSMaxRange(task.range) - if separator < NSMaxRange(paragraph), source.character(at: separator) == 32 || source.character(at: separator) == 9 { - storage.addAttribute(listWhitespace, value: true, - range: NSRange(location: separator, length: 1)) + let size: CGFloat = kind == "heading" ? [26, 23, 21, 19, 17, 15][level - 1] : 15 + let paragraph = NotesAppearance.paragraph.mutableCopy() as! NSMutableParagraphStyle + if ["bullet", "ordered", "task"].contains(kind) { + let previous = counters[depth] + counters = counters.filter { $0.key <= depth } + let number = kind == "ordered" ? (previous.map { $0 + 1 } ?? (attrs[NoteAttributes.number] as? Int ?? 1)) : 1 + if kind == "ordered" { counters[depth] = number; storage.addAttribute(NoteAttributes.number, value: number, range: row) } + else { counters.removeValue(forKey: depth) } + listParagraphs.append((row, kind, depth, number, attrs[NoteAttributes.checked] as? Bool == true)) + paragraph.firstLineHeadIndent = CGFloat(depth) * 20 + 22 + paragraph.headIndent = paragraph.firstLineHeadIndent + } else { counters = [:] } + if kind == "quote" { paragraph.firstLineHeadIndent = 18; paragraph.headIndent = 18 } + storage.addAttribute(.paragraphStyle, value: paragraph, range: row) + storage.enumerateAttributes(in: row) { run, range, _ in + let nativeTraits = (run[.font] as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? [] + let bold = run[NoteAttributes.bold] as? Bool ?? (kind != "heading" && kind != "code" && nativeTraits.contains(.boldFontMask)) + let italic = run[NoteAttributes.italic] as? Bool ?? nativeTraits.contains(.italicFontMask) + storage.addAttribute(NoteAttributes.bold, value: bold, range: range) + storage.addAttribute(NoteAttributes.italic, value: italic, range: range) + let font = NSFont.monospacedSystemFont(ofSize: size, weight: bold ? .bold : kind == "heading" ? .semibold : .regular) + storage.addAttribute(.font, value: italic ? NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) : font, range: range) + storage.addAttribute(.foregroundColor, value: run[NoteAttributes.inlineCode] as? Bool == true || run[.link] != nil ? SunsetPalette.accent : SunsetPalette.text, range: range) + if run[NoteAttributes.inlineCode] as? Bool == true || kind == "code" { storage.addAttribute(.backgroundColor, value: SunsetPalette.selectionFill, range: range) } } - (task.attachment.attachmentCell as? ListAttachmentCell)?.columnWidth = column - task.attachment.bounds.size.width = column + index = NSMaxRange(row) + } + storage.enumerateAttribute(NoteAttributes.block, in: full) { value, range, _ in + guard value as? String == "code" else { return } + let code = source.substring(with: range) + let language = storage.attribute(NoteAttributes.language, at: range.location, effectiveRange: nil) as? String + guard let highlighted = Self.highlighter.highlight(code, as: language), highlighted.string == code else { return } + highlighted.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: highlighted.length)) { value, run, _ in + if let value { storage.addAttribute(.foregroundColor, value: value, + range: NSRange(location: range.location + run.location, length: run.length)) } + } + } + storage.enumerateAttribute(.attachment, in: full) { value, _, _ in + guard let attachment = value as? NSTextAttachment, + let cell = attachment.attachmentCell as? NSTextAttachmentCell, let image = cell.image else { return } + let scale = min(1, 460 / max(image.size.width, 1), 240 / max(image.size.height, 1)) + if scale < 1 { image.size = NSSize(width: image.size.width * scale, height: image.size.height * scale) } + if !(cell is RoundedImageAttachmentCell) { attachment.attachmentCell = RoundedImageAttachmentCell(imageCell: image) } + } + markerColumn = ceil(listParagraphs.reduce(CGFloat(22)) { width, item in + guard item.kind == "ordered" else { return width } + return max(width, ("\(item.number)." as NSString).size(withAttributes: [.font: NotesAppearance.font]).width + 4) + }) + for item in listParagraphs { + let paragraph = NotesAppearance.paragraph.mutableCopy() as! NSMutableParagraphStyle + paragraph.firstLineHeadIndent = CGFloat(item.depth) * 20 + markerColumn + paragraph.headIndent = paragraph.firstLineHeadIndent + storage.addAttribute(.paragraphStyle, value: paragraph, range: item.range) } storage.endEditing() - // Only the required separator is hidden; additional whitespace is visible item-body text. - layoutManager.invalidateGlyphs(forCharacterRange: range, changeInLength: 0, actualCharacterRange: nil) + updateCheckboxes() + needsDisplay = true } - func layoutManager(_ layoutManager: NSLayoutManager, shouldGenerateGlyphs glyphs: UnsafePointer, - properties props: UnsafePointer, - characterIndexes charIndexes: UnsafePointer, font aFont: NSFont, - forGlyphRange glyphRange: NSRange) -> Int { - guard let storage = layoutManager.textStorage else { return 0 } - func hidden(_ index: Int) -> Bool { - storage.attribute(listWhitespace, at: index, effectiveRange: nil) as? Bool == true - || storage.attribute(MarkdownHighlighting.hiddenMarkup, at: index, effectiveRange: nil) as? Bool == true - } - guard (0.. tasks.count { checkboxes.removeLast().removeFromSuperview() } + while checkboxes.count < tasks.count { + let button = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:))) + button.controlSize = .small; button.contentTintColor = SunsetPalette.accent; button.sizeToFit() + checkboxes.append(button); addSubview(button) + } + for (button, task) in zip(checkboxes, tasks) { + button.tag = task.range.location; button.state = task.checked ? .on : .off; button.isEnabled = isEditable + let title = (string as NSString).substring(with: task.range).trimmingCharacters(in: .newlines) + button.setAccessibilityLabel(title.isEmpty ? "Task" : title) + } positionCheckboxes() } - private func positionCheckboxes() { - guard !checkboxes.isEmpty, let layoutManager, let textContainer, let textStorage else { return } + guard let layoutManager, let textContainer else { return } layoutManager.ensureLayout(for: textContainer) - for checkbox in checkboxes { - let task = tasks[checkbox.tag] - let glyphs = layoutManager.glyphRange(forCharacterRange: task.range, actualCharacterRange: nil) - let rect = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer) - .offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y) - let bodyGlyph = layoutManager.glyphIndexForCharacter(at: min(NSMaxRange(task.range) + 1, textStorage.length - 1)) - let fragment = layoutManager.lineFragmentRect(forGlyphAt: bodyGlyph, effectiveRange: nil) - let baseline = textContainerOrigin.y + fragment.minY + layoutManager.location(forGlyphAt: bodyGlyph).y - let centerY = baseline - NotesAppearance.font.capHeight / 2 - checkbox.setFrameOrigin(NSPoint(x: rect.midX - checkbox.frame.width / 2, - y: centerY - checkbox.frame.height / 2)) + for button in checkboxes where button.tag < string.utf16.count { + let glyph = layoutManager.glyphIndexForCharacter(at: button.tag) + let line = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil) + let baseline = line.minY + layoutManager.location(forGlyphAt: glyph).y + textContainerOrigin.y + let depth = attributes(at: button.tag)[NoteAttributes.depth] as? Int ?? 0 + button.setFrameOrigin(NSPoint(x: textContainerOrigin.x + CGFloat(depth) * 20 + (markerColumn - 4 - button.frame.width) / 2, + y: baseline - NotesAppearance.font.capHeight / 2 - button.frame.height / 2)) } } - + override func layout() { super.layout(); positionCheckboxes() } @objc private func toggleTask(_ sender: NSButton) { - guard isEditable, tasks.indices.contains(sender.tag), tasks[sender.tag].attachment.isTask else { return } - let task = tasks[sender.tag] - let selection = selectedRange() - let marker = task.attachment.marker as NSString - let range = markdownRange(for: task.range) - let opening = marker.range(of: "[", options: .backwards).location - replaceMarkdown(task.attachment.checked ? " " : "x", - range: NSRange(location: range.location + opening + 1, - length: marker.length - opening - 2)) - setSelectedRange(selection) + guard isEditable, sender.tag < string.utf16.count else { return } + let range = paragraph(at: sender.tag), selection = selectedRange() + let replacement = NSMutableAttributedString(attributedString: documentContents.attributedSubstring(from: range)) + replacement.addAttribute(NoteAttributes.checked, value: sender.state == .on, range: NSRange(location: 0, length: replacement.length)) + replaceDocument(replacement, range: range, selection: selection) + window?.makeFirstResponder(self) + } + + private var normalCursorWidth: CGFloat { + let font = attributes(at: selectedRange().location)[.font] as? NSFont ?? NotesAppearance.font + return max(2, ceil(font.maximumAdvancement.width)) + } + override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) { + guard vimEnabled, vimMode == .normal else { super.drawInsertionPoint(in: rect, color: color, turnedOn: flag); return } + var block = rect; block.size.width = normalCursorWidth + super.drawInsertionPoint(in: block, color: color.withAlphaComponent(0.45), turnedOn: flag) + } + override func setNeedsDisplay(_ invalidRect: NSRect) { + var rect = invalidRect + if vimEnabled, vimMode == .normal { rect.size.width += normalCursorWidth } + super.setNeedsDisplay(rect) + } +} + +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) } } diff --git a/Sources/Conjure/NotesVim.swift b/Sources/Conjure/NotesVim.swift index d251cde..22d2215 100644 --- a/Sources/Conjure/NotesVim.swift +++ b/Sources/Conjure/NotesVim.swift @@ -17,7 +17,7 @@ struct NotesVimState { var linewiseRegister = false } -// ponytail: project the whole note for each command; cache visible offsets if large notes make motions slow. +// ponytail: scan the whole note for each command; cache character offsets if large notes make motions slow. @MainActor private struct VimBuffer { var characters: [Character] = [] @@ -29,12 +29,8 @@ private struct VimBuffer { var offset = 0 for character in contents.string { let size = String(character).utf16.count - let hidden = contents.attribute(MarkdownHighlighting.hiddenMarkup, at: offset, effectiveRange: nil) as? Bool == true - || contents.attribute(listWhitespace, at: offset, effectiveRange: nil) as? Bool == true - if !hidden { - characters.append(character) - ranges.append(NSRange(location: offset, length: size)) - } + characters.append(character) + ranges.append(NSRange(location: offset, length: size)) offset += size } } @@ -98,10 +94,12 @@ extension NotesTextView { var vimMode: NotesVimMode { vimState.mode } func resetVim() { + endInsertionSession() let register = vimState.register, linewise = vimState.linewiseRegister vimState = NotesVimState() vimState.register = register vimState.linewiseRegister = linewise + needsDisplay = true vimModeChanged?() } @@ -113,19 +111,22 @@ extension NotesTextView { } private func setVimMode(_ mode: NotesVimMode) { + if vimState.mode == .insert { endInsertionSession() } breakUndoCoalescing() clearVimCommand() vimState.mode = mode vimState.anchor = nil vimState.head = nil vimState.column = nil + if mode == .insert { beginInsertionSession() } + needsDisplay = true vimModeChanged?() } @discardableResult func handleVimEscape() -> Bool { guard vimEnabled, vimState.mode != .normal, !hasMarkedText() else { return false } - let buffer = VimBuffer(attributedString()) + let buffer = VimBuffer(documentContents) var head = vimState.head ?? buffer.index(selectedRange().location) if vimState.mode == .insert && head > buffer.lineStart(head) { head -= 1 } setVimMode(.normal) @@ -192,11 +193,12 @@ extension NotesTextView { vimState.count = min(9999, vimState.count * 10 + digit) return true } - let buffer = VimBuffer(attributedString()) + let buffer = VimBuffer(documentContents) if vimState.mode == .normal, selectedRange().length > 0 { vimState.mode = .visual vimState.anchor = buffer.index(selectedRange().location) vimState.head = max(vimState.anchor!, buffer.index(NSMaxRange(selectedRange())) - 1) + needsDisplay = true vimModeChanged?() } let origin = buffer.normal(vimState.head ?? buffer.index(selectedRange().location)) @@ -213,7 +215,7 @@ extension NotesTextView { if key == "g" { vimState.awaitingG = true; return true } if "dcy".contains(key) { if visual { - let range = vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine) + let range = selectedRange() applyVimOperator(key, range: range, linewise: vimState.mode == .visualLine) } else if vimState.pending == key { var end = buffer.lineEnd(origin) @@ -305,22 +307,22 @@ extension NotesTextView { setVimCaret(target, buffer: buffer) insertNewline(nil) if key == "O" { - let updated = VimBuffer(attributedString()) + let updated = VimBuffer(documentContents) let caret = updated.index(selectedRange().location) setVimCaret(updated.lineStart(max(0, caret - 1)), buffer: updated) } case "x": - if visual { applyVimOperator("d", range: vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine), linewise: vimState.mode == .visualLine) } + if visual { applyVimOperator("d", range: selectedRange(), linewise: vimState.mode == .visualLine) } else { let end = min(buffer.lineEnd(origin), origin + count) let startOffset = buffer.offset(origin), endOffset = buffer.offset(end) - if end > origin { applyVimOperator("d", range: vimSourceRange(for: NSRange(location: startOffset, length: endOffset - startOffset)), linewise: false) } + if end > origin { applyVimOperator("d", range: NSRange(location: startOffset, length: endOffset - startOffset), linewise: false) } } case "D", "C": var end = buffer.lineEnd(origin) for _ in 1.. start { applyVimOperator(operation, range: vimSourceRange(for: NSRange(location: start, length: end - start)), linewise: false) } + if end > start { applyVimOperator(operation, range: NSRange(location: start, length: end - start), linewise: false) } else { clearVimCommand() } } } else { @@ -358,24 +360,21 @@ extension NotesTextView { } } - private func vimSourceRange(for rendered: NSRange, linewise: Bool = false) -> NSRange { - let range = markdownRange(for: MarkdownHighlighting.completeInlineRange(rendered, in: attributedString())) - return linewise ? (markdownString as NSString).lineRange(for: range) : range - } - private func applyVimLineOperator(_ operation: Character, start: Int, end: Int, buffer: VimBuffer) { - let source = markdownString as NSString - let startOffset = markdownRange(for: NSRange(location: buffer.offset(buffer.lineStart(start)), length: 0)).location - let endOffset = markdownRange(for: NSRange(location: buffer.offset(buffer.lineEnd(end)), length: 0)).location - let first = source.lineRange(for: NSRange(location: startOffset, length: 0)).location - let last = NSMaxRange(source.lineRange(for: NSRange(location: endOffset, length: 0))) + let first = buffer.offset(buffer.lineStart(start)) + let lastLineEnd = buffer.lineEnd(end) + let last = buffer.offset(lastLineEnd + (buffer.newline(lastLineEnd) ? 1 : 0)) applyVimOperator(operation, range: NSRange(location: first, length: last - first), linewise: true) } private func applyVimOperator(_ operation: Character, range: NSRange, linewise: Bool) { - let source = markdownContents + let source = documentContents let register = NSMutableAttributedString(attributedString: source.attributedSubstring(from: range)) - if linewise && !register.string.hasSuffix("\n") { register.append(NSAttributedString(string: "\n")) } + if linewise && !register.string.hasSuffix("\n") { + var attributes = register.length > 0 ? register.attributes(at: register.length - 1, effectiveRange: nil) : [:] + attributes[.attachment] = nil + register.append(NSAttributedString(string: "\n", attributes: attributes)) + } vimState.register = register vimState.linewiseRegister = linewise let originalCaret = selectedRange().location @@ -386,12 +385,14 @@ extension NotesTextView { return } var deletion = range - var replacement = "" + var replacement = NSAttributedString(string: "") + var attributes = range.location < source.length ? source.attributes(at: range.location, effectiveRange: nil) : typingAttributes + attributes[.attachment] = nil if linewise { let text = source.string as NSString if operation == "c" { // cc keeps the following line separate while opening a blank line for insertion. - if NSMaxRange(range) < text.length { replacement = "\n" } + if NSMaxRange(range) < text.length { replacement = NSAttributedString(string: "\n", attributes: attributes) } } else if NSMaxRange(range) == text.length, range.location > 0 { let previous = text.rangeOfComposedCharacterSequence(at: range.location - 1) if text.substring(with: previous).allSatisfy(\.isNewline) { @@ -400,10 +401,9 @@ extension NotesTextView { } } } - replaceMarkdown(replacement, range: deletion) - if operation == "c" { - setSelectedRange(renderedRange(for: NSRange(location: deletion.location, length: 0))) - } else { normalizeVimCaret() } + replaceDocument(replacement, range: deletion, selection: NSRange(location: deletion.location, length: 0)) + if operation == "c" { typingAttributes = attributes } + else { normalizeVimCaret() } } private func pasteVimRegister(before: Bool, count: Int, origin: Int, buffer: VimBuffer) { @@ -412,11 +412,10 @@ extension NotesTextView { for _ in 0.. 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, ""), ((attributes[.strikethroughStyle] as? Int ?? 0) != 0, "~~")] where enabled { + desired.append((delimiter, delimiter == "" ? "" : 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.. 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.. 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() == "" { underline.append((output.length, html.rawHTML)); continue } + if html.rawHTML.lowercased() == "", 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.. 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) } + } +} diff --git a/Sources/ConjureKit/NoteMarkdownExport.swift b/Sources/ConjureKit/NoteMarkdownExport.swift index e9f92ae..6355f08 100644 --- a/Sources/ConjureKit/NoteMarkdownExport.swift +++ b/Sources/ConjureKit/NoteMarkdownExport.swift @@ -10,6 +10,7 @@ public struct NoteMarkdownExport { private let images: [(name: String, data: Data)] public init(contents: NSAttributedString) throws { + let contents = NoteDocument.markdown(contents) let source = contents.string as NSString var markdown = "" var clipboard = "" diff --git a/Sources/ConjureKit/NotesStore.swift b/Sources/ConjureKit/NotesStore.swift index b9f3ca8..307a938 100644 --- a/Sources/ConjureKit/NotesStore.swift +++ b/Sources/ConjureKit/NotesStore.swift @@ -24,6 +24,7 @@ public struct Note: Identifiable { public final class NotesStore { public private(set) var notes: [Note] = [] private let directory: URL + private var legacyIDs: Set = [] public init(directory: URL = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask @@ -34,16 +35,26 @@ public final class NotesStore { at: directory, includingPropertiesForKeys: nil ).filter { $0.pathExtension.lowercased() == "rtfd" } .sorted { $0.lastPathComponent < $1.lastPathComponent } - notes = try files.map { url in + var loaded: [Note] = [] + for url in files { guard let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent) else { throw CocoaError(.fileReadCorruptFile) } - let contents = try NSAttributedString( - url: url, options: [.documentType: NSAttributedString.DocumentType.rtfd], - documentAttributes: nil - ) - return Note(id: id, contents: contents) + let wrapper = try FileWrapper(url: url, options: .immediate) + guard wrapper.isDirectory else { throw CocoaError(.fileReadCorruptFile) } + let contents: NSAttributedString + if wrapper.fileWrappers?[NoteDocument.metadataFilename] != nil { + contents = try NoteDocument.contents(from: wrapper) + } else { + guard let legacy = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil) else { + throw CocoaError(.fileReadCorruptFile) + } + contents = NoteDocument.importMarkdown(legacy) + legacyIDs.insert(id) + } + loaded.append(Note(id: id, contents: contents)) } + notes = loaded } public func create() throws -> UUID { @@ -69,14 +80,29 @@ public final class NotesStore { } try FileManager.default.removeItem(at: directory.appendingPathComponent(id.uuidString + ".rtfd")) notes.remove(at: index) + legacyIDs.remove(id) } private func save(_ note: Note) throws { - let wrapper = try note.contents.fileWrapper( - from: NSRange(location: 0, length: note.contents.length), - documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] - ) + let wrapper = try NoteDocument.fileWrapper(note.contents) let url = directory.appendingPathComponent(note.id.uuidString + ".rtfd", isDirectory: true) + if legacyIDs.contains(note.id) { + let backups = directory.appendingPathComponent("LegacyBackups", isDirectory: true) + try FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true) + let backup = backups.appendingPathComponent(note.id.uuidString + ".rtfd", isDirectory: true) + if !FileManager.default.fileExists(atPath: backup.path) { + // copyItem leaves the live legacy package untouched if backup creation fails. + try FileManager.default.copyItem(at: url, to: backup) + } else { + // A previous failed save may already have preserved the original package. + let original = try FileWrapper(url: backup, options: .immediate) + guard original.isDirectory, + NSAttributedString(rtfdFileWrapper: original, documentAttributes: nil) != nil else { + throw CocoaError(.fileWriteFileExists) + } + } + } try wrapper.write(to: url, options: .atomic, originalContentsURL: url) + legacyIDs.remove(note.id) } } diff --git a/TODO.md b/TODO.md index dcaf499..5caca07 100644 --- a/TODO.md +++ b/TODO.md @@ -12,7 +12,7 @@ - typing `#{1,6} ` at the visible paragraph start sets the absolute heading level, replacing any existing level; fenced code remains literal. - Backspace at heading start deletes the preceding newline, not the heading prefix; at document start it does nothing. - joining onto nonempty prose inherits the preceding paragraph's type: `Text\n## Heading` becomes `TextHeading`; removing an empty line above preserves the heading. - - keep native attributed-text editing, Undo, images, and Markdown serialization; retain RTFD autosaves rather than introducing a full document-model rewrite. + - keep native attributed-text editing, Undo, images, Markdown export, and RTFD autosaves; structured paragraph metadata is stored alongside RTFD content. - [x] add whole-note Markdown export actions to the Notes Actions menu: - Copy as Markdown: replace images with inline-code placeholders such as ``. - Export as Markdown…: save a single `.md` file without images; with images, create a folder containing the Markdown and image files, using relative links. @@ -21,10 +21,14 @@ - [x] keep the caret on the heading's visible line after Backspace and Return, not on the preceding line. - [x] improve: - add cmd+shift+s for strikethrough and cmd+shift+u for underline - - don't hide the backticks surrounding a codeblock + - code-block fences were made visible in source editing; superseded by the structured editing cutover below. - give inline codeblocks (e.g. `some code snippet`) an inline container and an orange text color matching the color scheme of the conjure application - [x] feature: vim motions - opt-in Normal/Insert/Visual editing, counted motions, delete/change/yank, rich-text paste, and native Undo/Redo. + - [x] use Notion-style structured editing instead of hidden Markdown source: + - Markdown input shortcuts become attributed marks and paragraph types; native editing and Vim share visible offsets and Undo. + - preserve image bytes and versioned semantic metadata in RTFD packages; back up legacy packages before their first structured save. + - generate normalized Markdown for export; plain-text paste stays literal. - [ ] feature: configurable color scheme - general design should stay consistent - only colors should be configurable; not the way the colors are used/presented (e.g. frosted glass effect with color blobs behind shouldn't change) diff --git a/Tests/ConjureKitTests/CodeHighlightingTests.swift b/Tests/ConjureKitTests/CodeHighlightingTests.swift index 6a2d883..ea1bd7a 100644 --- a/Tests/ConjureKitTests/CodeHighlightingTests.swift +++ b/Tests/ConjureKitTests/CodeHighlightingTests.swift @@ -1,17 +1,18 @@ import AppKit +import ConjureKit import Testing @testable import Conjure -@Test @MainActor func fencedCodeHighlightsTokensWithoutChangingSourceOrApplyingMarkdown() throws { +@Test @MainActor +func codeBlocksHighlightTokensWithoutInterpretingTheirLiteralMarkdown() throws { _ = NSApplication.shared for fence in ["```", "~~~~"] { - let text = "# Heading 🦊\n\(fence)py\ndef main():\n # comment\n return \"**not bold**\"\n\(fence)\nAfter\n```unknown-language\n# literal\n```" - let editor = NSTextView() - editor.string = text - MarkdownHighlighting.apply(to: editor) + let source = "# Heading 🦊\n\(fence)py\ndef main():\n # comment\n return \"**not bold**\"\n\(fence)\nAfter\n```unknown-language\n# literal\n```" + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 500)) + editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source))) let storage = try #require(editor.textStorage) func attributes(_ token: String) -> [NSAttributedString.Key: Any] { - storage.attributes(at: (text as NSString).range(of: token).location, effectiveRange: nil) + storage.attributes(at: (editor.string as NSString).range(of: token).location, effectiveRange: nil) } let keyword = try #require(attributes("def")[.foregroundColor] as? NSColor) let name = try #require(attributes("main")[.foregroundColor] as? NSColor) @@ -19,20 +20,23 @@ import Testing #expect(keyword != name) #expect(comment != keyword) #expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true) - #expect(storage.string == text) - editor.string = "```python\ndef main():\n pass" - MarkdownHighlighting.apply(to: editor) + #expect(attributes("**not bold**")[NoteAttributes.bold] as? Bool != true) + #expect(attributes("# literal")[NoteAttributes.headingLevel] == nil) + #expect(attributes("# literal")[NoteAttributes.block] as? String == "code") + #expect(editor.string == "Heading 🦊\ndef main():\n # comment\n return \"**not bold**\"\nAfter\n# literal") + editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "```python\ndef main():\n pass"))) let unfinished = try #require(editor.textStorage) - #expect(unfinished.attribute(.foregroundColor, at: 10, effectiveRange: nil) as? NSColor == keyword) + #expect(unfinished.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == keyword) + #expect(editor.string == "def main():\n pass") } } -@Test @MainActor func noteHeadingsShrinkByLevelWithoutChangingMarkdownOrCode() throws { +@Test @MainActor +func headingLevelsUseDescendingSizesAndCodeRemainsBodySized() throws { _ = NSApplication.shared - let editor = NSTextView() - let text = "# One\n## Two\n### Three\n#### Four\n##### Five\n###### Six\nBody\n## **Bold** and *italic*\n```text\n# Literal\n```\n####### Not a heading" - editor.string = text - MarkdownHighlighting.apply(to: editor) + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 600)) + let source = "# One\n## Two\n### Three\n#### Four\n##### Five\n###### Six\nBody\n## **Bold** and *italic*\n```text\n# Literal\n```\n####### Not a heading" + editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source))) let storage = try #require(editor.textStorage) func font(_ token: String) throws -> NSFont { try #require(storage.attribute(.font, at: (editor.string as NSString).range(of: token).location, @@ -41,13 +45,14 @@ import Testing let sizes = try ["One", "Two", "Three", "Four", "Five", "Six"].map { try font($0).pointSize } for (larger, smaller) in zip(sizes, sizes.dropFirst()) { #expect(larger > smaller) } #expect(try font("Six").pointSize >= font("Body").pointSize) - #expect(try font("**Bold**").pointSize == font("Two").pointSize) - #expect(try font("*italic*").pointSize == font("Two").pointSize) + #expect(try font("Bold").pointSize == font("Two").pointSize) + #expect(try font("italic").pointSize == font("Two").pointSize) #expect(try font("Literal").pointSize == font("Body").pointSize) #expect(try font("Not a heading").pointSize == font("Body").pointSize) #expect(try font("Three").isFixedPitch) - #expect(editor.string == text) - storage.replaceCharacters(in: (editor.string as NSString).range(of: "### Three"), with: "Three") - MarkdownHighlighting.apply(to: editor) + let three = (editor.string as NSString).range(of: "Three") + editor.setSelectedRange(three) + for _ in 0..<4 { editor.format(0) } + #expect(editor.string.contains("Three")) #expect(try font("Three").pointSize == font("Body").pointSize) } diff --git a/Tests/ConjureKitTests/NoteDocumentTests.swift b/Tests/ConjureKitTests/NoteDocumentTests.swift new file mode 100644 index 0000000..b57339a --- /dev/null +++ b/Tests/ConjureKitTests/NoteDocumentTests.swift @@ -0,0 +1,184 @@ +import AppKit +import ConjureKit +import Testing + +@Test @MainActor +func markdownImportProducesVisibleTextAndSemanticFormatting() throws { + let markdown = "# Title 🦊\n**bold** *italic* ***both*** under ~~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\\* & 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)", "
literal **content**
", "| 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* under ~~strike~~ [link]() 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* under ~~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.. quote\n[link](https://example.com)\nnext", "quote\nlink\nnext"), + ("- item\n***both*** under\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 == "fox 🦊 \n") + contents.addAttribute(NoteAttributes.bold, value: true, range: marked) + #expect(NoteDocument.markdown(contents).string == "**fox 🦊** \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) + } +} diff --git a/Tests/ConjureKitTests/NoteMarkdownExportTests.swift b/Tests/ConjureKitTests/NoteMarkdownExportTests.swift index a781a52..2e9cb2b 100644 --- a/Tests/ConjureKitTests/NoteMarkdownExportTests.swift +++ b/Tests/ConjureKitTests/NoteMarkdownExportTests.swift @@ -7,7 +7,7 @@ func markdownExportPreservesWholeTextAndSnapshotsBeforeWriting() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) - let text = "\n # 日本語 🦊 \r\n**bold** and `code`\n- [ ] task\n\u{fffc}\n" + let text = "\n日本語 🦊 \r\nplain and code\nordinary task\n\u{fffc}\n" let source = NSMutableAttributedString(string: text) source.addAttribute(.attachment, value: NSTextAttachment(), range: NSRange(location: 0, length: 1)) let export = try NoteMarkdownExport(contents: source) @@ -32,13 +32,13 @@ func markdownExportWritesOriginalImageBytesAndEscapedLinksWithoutChangingSource( wrapper.preferredFilename = name let originalFilename = wrapper.filename let attachment = NSTextAttachment(fileWrapper: wrapper) - let source = NSMutableAttributedString(string: "\n # 🦊\n") + let source = NSMutableAttributedString(attributedString: NoteDocument.importMarkdown(NSAttributedString(string: "\n# 🦊\n"))) source.append(NSAttributedString(attachment: attachment)) - source.append(NSAttributedString(string: "\n- [ ] todo \n")) + source.append(NoteDocument.importMarkdown(NSAttributedString(string: "\n- [ ] todo \n"))) let before = NSAttributedString(attributedString: source) let export = try NoteMarkdownExport(contents: source) #expect(export.hasImages) - #expect(export.clipboardText == "\n # 🦊\n````\n- [ ] todo \n") + #expect(export.clipboardText == "\n# 🦊\n````\n- [ ] todo \n") #expect(source.isEqual(to: before)) #expect(wrapper.preferredFilename == name) #expect(wrapper.filename == originalFilename) @@ -46,7 +46,7 @@ func markdownExportWritesOriginalImageBytesAndEscapedLinksWithoutChangingSource( source.mutableString.setString("Changed") wrapper.preferredFilename = "changed.png" try export.write(to: directory) - let markdown = "\n # 🦊\n![pixel \\[x\\] \\`1\\` #%.png](pixel%20%5Bx%5D%20%601%60%20%23%25.png)\n- [ ] todo \n" + let markdown = "\n# 🦊\n![pixel \\[x\\] \\`1\\` #%.png](pixel%20%5Bx%5D%20%601%60%20%23%25.png)\n- [ ] todo \n" #expect(try String(contentsOf: directory.appendingPathComponent("note.md"), encoding: .utf8) == markdown) #expect(try Data(contentsOf: directory.appendingPathComponent(name)) == png) #expect(Set(try FileManager.default.contentsOfDirectory(atPath: directory.path)) == ["note.md", name]) diff --git a/Tests/ConjureKitTests/NotesCursorTests.swift b/Tests/ConjureKitTests/NotesCursorTests.swift index c57220c..8fd8118 100644 --- a/Tests/ConjureKitTests/NotesCursorTests.swift +++ b/Tests/ConjureKitTests/NotesCursorTests.swift @@ -8,7 +8,7 @@ func vimNormalCursorIsWideButInsertAndDisabledCursorsStayThin() throws { let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 50)) editor.font = NotesAppearance.font editor.drawsBackground = false - editor.setMarkdownContents(NSAttributedString(string: "text")) + editor.setDocumentContents(NSAttributedString(string: "text")) func paintedWidth() throws -> Int { let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 100, pixelsHigh: 50, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, diff --git a/Tests/ConjureKitTests/NotesEditingTests.swift b/Tests/ConjureKitTests/NotesEditingTests.swift index a192382..1d2c423 100644 --- a/Tests/ConjureKitTests/NotesEditingTests.swift +++ b/Tests/ConjureKitTests/NotesEditingTests.swift @@ -46,7 +46,7 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws defer { _ = controller.close(); window.orderOut(nil) } func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } let views = descendants(controller.view) - let editor = try #require(views.compactMap { $0 as? NSTextView }.first) + let editor = try #require(views.compactMap { $0 as? NotesTextView }.first) let browse = try #require(views.compactMap { $0 as? NSButton }.first { $0.title == "Browse Notes" }) func selectedID() throws -> UUID { try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:))) } func picker(_ tool: NotesController) throws -> NSSearchField { @@ -78,8 +78,9 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws } #expect(!controller.handleControlKey("j")) #expect(!controller.handle(#selector(NSResponder.insertNewline(_:)))) - editor.insertText("# Second 🦊\n**bold** and `code`", replacementRange: editor.selectedRange()) - let codeRange = (editor.string as NSString).range(of: "`code`") + editor.insertText(NoteDocument.importMarkdown(NSAttributedString(string: "# Second 🦊\n**bold** and `code`")), + replacementRange: editor.selectedRange()) + let codeRange = (editor.string as NSString).range(of: "code") let codeFont = try #require(editor.textStorage?.attribute(.font, at: codeRange.location, effectiveRange: nil) as? NSFont) #expect(codeFont.isFixedPitch) browse.performClick(nil) @@ -113,7 +114,10 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws #expect(first.contents.string == "First note\u{fffc}\u{fffc}\n") #expect(first.contents.attribute(.attachment, at: 10, effectiveRange: nil) is NSTextAttachment) #expect(first.contents.attribute(.attachment, at: 11, effectiveRange: nil) is NSTextAttachment) - #expect(restored.notes.first { $0.id == secondID }?.contents.string == "# Second 🦊\n**bold** and `code`") + let second = try #require(restored.notes.first { $0.id == secondID }) + #expect(second.contents.string == "Second 🦊\nbold and code") + #expect(second.contents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == 1) + #expect(second.contents.attribute(NoteAttributes.bold, at: "Second 🦊\n".utf16.count, effectiveRange: nil) as? Bool == true) let reopened = NotesController(defaults: defaults, directory: directory) let reopenedWindow = NSWindow(contentRect: window.frame, styleMask: [.borderless], backing: .buffered, defer: false) reopenedWindow.contentViewController = reopened @@ -121,14 +125,15 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws let reopenedViews = descendants(reopened.view) let reopenedEditor = try #require(reopenedViews.compactMap { $0 as? NotesTextView }.first) #expect(reopenedEditor.string == first.contents.string) - // Removing Markdown must also remove its previous visual style without changing the source. + // Replacing formatted content must not leave stale paragraph or inline styles. reopenedEditor.insertText("Plain text", replacementRange: NSRange(location: 0, length: reopenedEditor.string.utf16.count)) #expect(reopenedEditor.string == "Plain text") let backup = directory.appendingPathExtension("backup") defer { try? FileManager.default.removeItem(at: backup) } try FileManager.default.moveItem(at: directory, to: backup) try Data("Blocked storage".utf8).write(to: directory) - reopenedEditor.insertText("- [X] Unsaved", replacementRange: NSRange(location: 0, length: reopenedEditor.string.utf16.count)) + reopenedEditor.replaceDocument(NoteDocument.importMarkdown(NSAttributedString(string: "- [x] Unsaved")), + range: NSRange(location: 0, length: reopenedEditor.string.utf16.count)) var closed = false reopened.closed = { closed = true } reopened.goBack() @@ -137,8 +142,8 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws reopened.browseNotes() filter(try picker(reopened), "scnd") #expect(reopened.handle(#selector(NSResponder.insertNewline(_:)))) - #expect(reopenedEditor.string == "\u{fffc} Unsaved") - #expect(reopenedEditor.markdownString == "- [X] Unsaved") + #expect(reopenedEditor.string == "Unsaved") + #expect(reopenedEditor.documentContents.attribute(NoteAttributes.checked, at: 0, effectiveRange: nil) as? Bool == true) #expect(try selectedID() == firstID) reopened.browseNotes() #expect(!reopened.close()) @@ -147,7 +152,9 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws try FileManager.default.removeItem(at: directory) try FileManager.default.moveItem(at: backup, to: directory) #expect(reopened.save()) - #expect(try NotesStore(directory: directory).notes.first { $0.id == firstID }?.contents.string == "- [X] Unsaved") + let recovered = try #require(NotesStore(directory: directory).notes.first { $0.id == firstID }) + #expect(recovered.contents.string == "Unsaved") + #expect(recovered.contents.attribute(NoteAttributes.checked, at: 0, effectiveRange: nil) as? Bool == true) #expect(reopened.close()) #expect(closed) #expect(!descendants(reopened.view).contains { ($0 as? NSSearchField)?.accessibilityLabel() == "Search notes" }) @@ -291,7 +298,7 @@ func notesNativeClosePreservesUnsavedEditsAndRestoresBorderlessLauncher(_ blockS close.performClick(nil) if blockSaving { #expect(window.isVisible) - #expect(editor.markdownString == "Keep this note") + #expect(editor.string == "Keep this note") #expect(window.styleMask.contains(.titled)) try FileManager.default.removeItem(at: directory) try FileManager.default.moveItem(at: backup, to: directory) @@ -334,7 +341,8 @@ func notesRememberResizedDimensionsWithoutResizingOtherCommandsAndKeepTextMonosp func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } let content = try #require(window.contentView) let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first) - editor.insertText("Plain\n# Heading\n**bold** and *italic* and `code`", replacementRange: editor.selectedRange()) + editor.insertText(NoteDocument.importMarkdown(NSAttributedString(string: "Plain\n# Heading\n**bold** and *italic* and `code`")), + replacementRange: editor.selectedRange()) let storage = try #require(editor.textStorage) storage.enumerateAttribute(.font, in: NSRange(location: 0, length: storage.length)) { value, _, _ in #expect((value as? NSFont)?.isFixedPitch == true) @@ -368,298 +376,150 @@ func notesRememberResizedDimensionsWithoutResizingOtherCommandsAndKeepTextMonosp } @Test @MainActor -func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws { +func structuredListsContinueSplitOutdentAndUndoWithoutSourceMarkers() throws { _ = NSApplication.shared - let suite = "NotesLists.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite) - defer { - defaults.removePersistentDomain(forName: suite) - try? FileManager.default.removeItem(at: directory) - } - let controller = NotesController(defaults: defaults, directory: directory) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 390), - styleMask: .borderless, backing: .buffered, defer: false) - window.contentViewController = controller - defer { _ = controller.close(); window.orderOut(nil) } - func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } - let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first) + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 520, height: 300)) + editor.allowsUndo = true + let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false) + window.contentView = editor window.makeFirstResponder(editor) - let enter = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], - timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "\r", - charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36)) - func reset(_ text: String) { - editor.insertText(text, replacementRange: NSRange(location: 0, length: editor.string.utf16.count)) - editor.breakUndoCoalescing() + defer { window.orderOut(nil) } + func reset(_ source: String) { + editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source))) + editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) editor.undoManager?.removeAllActions() } - let examples = [ - ("- fox 🦊", "- fox 🦊\n- "), - (" * nested", " * nested\n * "), - ("\t+ nested", "\t+ nested\n\t+ "), - ("9. ninth", "9. ninth\n10. "), - (" 12) nested", " 12) nested\n 13) "), - ("- [x] finished", "- [x] finished\n- [ ] "), - (" \t+ [X]\tfinished", " \t+ [X]\tfinished\n \t+ [ ]\t"), - ("1. parent\n - [X] ", "1. parent\n2. "), - ("- [x] parent\n - [ ] ", "- [x] parent\n- [ ] "), - ("- ", ""), ("+ \t ", ""), ("4. ", ""), ("- [ ] ", ""), ("- [X]", ""), - ("- parent\n - ", "- parent\n- "), - ("1. parent\n * ", "1. parent\n2. "), - ("- parent\n\t+ child\n\t\t3) ", "- parent\n\t+ child\n\t+ "), - ("- first\n - child\n - ", "- first\n - child\n- "), - ("- parent\n continuation\n - ", "- parent\n continuation\n- "), - ("- parent\nparagraph\n - ", "- parent\nparagraph\n- "), - ("* * *\n - ", "* * *\n- "), - ("- - -\n 4) ", "- - -\n 4) "), - ("- parent\n + ", "- parent\n- "), - ("- parent\r\n - ", "- parent\r\n- "), - ("prose", "prose\n"), ("* * *", "* * *\n"), - ("```md\n- literal", "```md\n- literal\n"), - ("~~~\n 1. literal", "~~~\n 1. literal\n"), - ("```\n- literal\n```\n- real", "```\n- literal\n```\n- real\n- "), - ] - for (before, after) in examples { - reset(before) - editor.keyDown(with: enter) - #expect(editor.markdownString == after, "Return in \(before.debugDescription)") - #expect(editor.markdownContents.string == after) - #expect(editor.selectedRange() == NSRange(location: editor.string.utf16.count, length: 0)) - } - // Empty means whitespace-only body, even before or within the extra separators. - for marker in ["-", "12)", "- [X]", "3. []"] { - let source = "- parent\n " + marker + " \t " - let bodyStart = ("- parent\n " + marker + " ").utf16.count - for position in bodyStart...source.utf16.count { - editor.setMarkdownContents(NSAttributedString(string: source)) - editor.setSelectedRange(editor.renderedRange(for: NSRange(location: position, length: 0))) - editor.keyDown(with: enter) - #expect(editor.markdownString == "- parent\n- ", - "Empty Return at \(position) in \(source.debugDescription)") - } - } - for (source, steps) in [ - (" + \t ", [" + \t ", " + \t ", "+ \t ", ""]), - ("\t\t12) ", ["\t12) ", "12) ", ""]), - (" - [X] ", [" - [X] ", "- [X] ", ""]), + for (source, block, number) in [ + ("- fox 🦊", "bullet", 0), ("9. ninth", "ordered", 10), + ("- [x] finished", "task", 0), ] { - editor.setMarkdownContents(NSAttributedString(string: source)) - editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) - for expected in steps { - editor.keyDown(with: enter) - #expect(editor.markdownString == expected) - #expect(editor.selectedRange() == NSRange(location: editor.string.utf16.count, length: 0)) + reset(source) + let before = editor.string + editor.insertNewline(nil) + editor.insertText("next", replacementRange: editor.selectedRange()) + #expect(editor.string == before + "\nnext") + let index = before.utf16.count + 1 + #expect(editor.documentContents.attribute(NoteAttributes.block, at: index, effectiveRange: nil) as? String == block) + if number > 0 { + #expect(editor.documentContents.attribute(NoteAttributes.number, at: index, effectiveRange: nil) as? Int == number) + } + if block == "task" { + #expect(editor.documentContents.attribute(NoteAttributes.checked, at: index, effectiveRange: nil) as? Bool == false) } } reset("- ab🦊cd") editor.setSelectedRange((editor.string as NSString).range(of: "🦊")) - editor.keyDown(with: enter) - #expect(editor.string == "\u{fffc} ab\n\u{fffc} cd") - #expect(editor.markdownString == "- ab\n- cd") - #expect(editor.selectedRange() == NSRange(location: 7, length: 0)) - reset("- [X] ab🦊cd") - editor.setSelectedRange((editor.string as NSString).range(of: "🦊")) - editor.keyDown(with: enter) - #expect(editor.markdownString == "- [X] ab\n- [ ] cd") - #expect(editor.string == "\u{fffc} ab\n\u{fffc} cd") - #expect(editor.selectedRange() == NSRange(location: 7, length: 0)) + editor.insertNewline(nil) + #expect(editor.string == "ab\ncd") + #expect(editor.selectedRange() == NSRange(location: 3, length: 0)) + #expect(editor.documentContents.attribute(NoteAttributes.block, at: 3, effectiveRange: nil) as? String == "bullet") + editor.undoManager?.undo() + #expect(editor.string == "ab🦊cd") + #expect(editor.selectedRange() == NSRange(location: 2, length: 2)) + editor.undoManager?.redo() + #expect(editor.string == "ab\ncd") + reset("- parent\n - child\n - leaf") - editor.keyDown(with: enter) - #expect(editor.string == "\u{fffc} parent\n \u{fffc} child\n \u{fffc} leaf\n \u{fffc} ") - editor.keyDown(with: enter) - #expect(editor.string == "\u{fffc} parent\n \u{fffc} child\n \u{fffc} leaf\n \u{fffc} ") - editor.keyDown(with: enter) - #expect(editor.string == "\u{fffc} parent\n \u{fffc} child\n \u{fffc} leaf\n\u{fffc} ") - editor.keyDown(with: enter) - #expect(editor.string == "\u{fffc} parent\n \u{fffc} child\n \u{fffc} leaf\n") - // Nesting follows actual ancestor indentation, not a fixed depth or indent size. - let ancestors = (0..<80).map { String(repeating: " ", count: $0 * 2) + "- level \($0)" }.joined(separator: "\n") - let before = ancestors + "\n" + String(repeating: " ", count: 160) + "- leaf" - reset(before) - editor.keyDown(with: enter) - #expect(editor.markdownString == before + "\n" + String(repeating: " ", count: 160) + "- ") - for depth in stride(from: 79, through: 0, by: -1) { - editor.keyDown(with: enter) - #expect(editor.markdownString == before + "\n" + String(repeating: " ", count: depth * 2) + "- ") - } - editor.keyDown(with: enter) - #expect(editor.markdownString == before + "\n") - reset("- [X] parent\n - [ ] ") - editor.keyDown(with: enter) - editor.breakUndoCoalescing() - #expect(editor.markdownString == "- [X] parent\n- [ ] ") - #expect(editor.string == "\u{fffc} parent\n\u{fffc} ") - editor.undoManager?.undo() - #expect(editor.markdownString == "- [X] parent\n - [ ] ") - #expect(editor.string == "\u{fffc} parent\n \u{fffc} ") - #expect(editor.undoManager?.canUndo == false) - editor.undoManager?.redo() - #expect(editor.markdownString == "- [X] parent\n- [ ] ") - reset("7. saved") - editor.keyDown(with: enter) - editor.breakUndoCoalescing() - #expect(editor.string == "\u{fffc} saved\n\u{fffc} ") - #expect(editor.markdownString == "7. saved\n8. ") - editor.undoManager?.undo() - #expect(editor.string == "\u{fffc} saved") - #expect(editor.markdownString == "7. saved") - editor.undoManager?.redo() - #expect(editor.string == "\u{fffc} saved\n\u{fffc} ") - #expect(editor.markdownString == "7. saved\n8. ") - let restored = try NotesStore(directory: directory) - let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:))) - #expect(restored.notes.first { $0.id == id }?.contents.string == "7. saved\n8. ") + editor.insertNewline(nil) + editor.insertText("temporary", replacementRange: editor.selectedRange()) + let last = (editor.string as NSString).range(of: "temporary") + #expect(editor.documentContents.attribute(NoteAttributes.depth, at: last.location, effectiveRange: nil) as? Int == 2) + editor.insertText("", replacementRange: last) + editor.insertNewline(nil) + editor.insertText("sibling", replacementRange: editor.selectedRange()) + let sibling = (editor.string as NSString).range(of: "sibling") + #expect(editor.documentContents.attribute(NoteAttributes.depth, at: sibling.location, effectiveRange: nil) as? Int == 1) + editor.insertText("", replacementRange: sibling) + editor.insertNewline(nil) + editor.insertNewline(nil) + editor.insertText("body", replacementRange: editor.selectedRange()) + let body = (editor.string as NSString).range(of: "body") + #expect(editor.documentContents.attribute(NoteAttributes.block, at: body.location, effectiveRange: nil) == nil) + + reset("```md\n- literal\n```") + editor.insertNewline(nil) + editor.insertText("- still literal", replacementRange: editor.selectedRange()) + #expect(editor.string == "- literal\n- still literal") + #expect(editor.documentContents.attribute(NoteAttributes.block, at: editor.string.utf16.count - 1, effectiveRange: nil) as? String == "code") } @Test @MainActor -func listTabAndShiftTabPreserveMarkersSourceSelectionsUndoAndPersistence() throws { +func typedParagraphShortcutsConsumeMarkersWithoutCreatingAttachments() throws { _ = NSApplication.shared - let suite = "NotesIndent.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite) - defer { - defaults.removePersistentDomain(forName: suite) - try? FileManager.default.removeItem(at: directory) - } - let controller = NotesController(defaults: defaults, directory: directory) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 390), - styleMask: .borderless, backing: .buffered, defer: false) - window.contentViewController = controller - window.setContentSize(NSSize(width: 750, height: 390)) - 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) - func key(_ shift: Bool) throws -> NSEvent { - try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, - modifierFlags: shift ? .shift : [], timestamp: 1, windowNumber: window.windowNumber, - context: nil, characters: shift ? "\u{19}" : "\t", charactersIgnoringModifiers: "\t", - isARepeat: false, keyCode: 48)) - } - let tab = try key(false), backtab = try key(true) - func reset(_ source: String) { - editor.setMarkdownContents(NSAttributedString(string: source)) - editor.breakUndoCoalescing() - editor.undoManager?.removeAllActions() - } - for (before, after, shift) in [ - ("1. hello,\n2. world!", "1. hello,\n 1. world!", false), - ("1. parent\n2. world!\n3. tail", "1. parent\n 1. world!\n2. tail", false), - ("1. parent\n 1. child\n2. world!\n3. tail", "1. parent\n 1. child\n 2. world!\n2. tail", false), - ("1. parent\n 1. world!\n2. tail", "1. parent\n2. world!\n3. tail", true), - ("7) parent\n 1) child\n 2) world!\n8) tail", "7) parent\n 1) child\n8) world!\n9) tail", true), - ("1. parent\n - bullet\n2. world!", "1. parent\n - bullet\n 1. world!", false), - ("1. parent\n\t1. child\n 2. world!", "1. parent\n\t1. child\n 2. world!", false), - ("1. parent\n2. world!\n\n9. separate", "1. parent\n 1. world!\n\n9. separate", false), - ("9) parent\n 1) world!\n10) tail", "9) parent\n10) world!\n11) tail", true), - ("1. parent\n10. world!\n11. tail", "1. parent\n 1. world!\n2. tail", false), - ("1. parent\n2. world!\n3. tail\n 7. separate", "1. parent\n 1. world!\n2. tail\n 7. separate", false), - ("1. parent\n 1. world!\n 2. child\n2. tail", "1. parent\n2. world!\n 1. child\n3. tail", true), - ("1. parent\r\n2) [X]\tworld!\r\n3. tail", "1. parent\r\n 1) [X]\tworld!\r\n2. tail", false), - ] { - reset(before) - let selected = (editor.string as NSString).range(of: "world!") - editor.setSelectedRange(selected) - editor.keyDown(with: shift ? backtab : tab) - editor.breakUndoCoalescing() - #expect(editor.markdownString == after) - #expect((editor.string as NSString).substring(with: editor.selectedRange()) == "world!") - let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:))) - let saved = try NotesStore(directory: directory).notes.first { $0.id == id }?.contents.string - #expect(saved == after.replacingOccurrences(of: "\r\n", with: "\n")) - editor.undoManager?.undo() - #expect(editor.markdownString == before) - #expect(editor.selectedRange() == selected) - #expect(editor.undoManager?.canUndo == false) - editor.undoManager?.redo() - #expect(editor.markdownString == after) - } - for marker in ["-", "*", "+", "009)", "12.", "- [X]", "2) []"] { - let source = " " + marker + " \t🦊 café\n - child" - reset(source) - let body = (editor.string as NSString).range(of: "🦊 café") - // Visible indentation, marker, body start/middle/end, and a Unicode selection. - let selections = [NSRange(location: 0, length: 0), NSRange(location: 1, length: 0), - NSRange(location: 3, length: 0), NSRange(location: body.location, length: 0), - NSRange(location: body.location + 2, length: 0), - NSRange(location: NSMaxRange(body), length: 0), body] - for selection in selections { - reset(source) - editor.setSelectedRange(selection) - let logical = editor.markdownInsertionRange(for: selection) - editor.keyDown(with: tab) - editor.breakUndoCoalescing() - #expect(editor.markdownString == " " + source) - #expect(editor.markdownContents.string == " " + source) - #expect(editor.selectedRange() == editor.renderedRange(for: - NSRange(location: logical.location + 2, length: logical.length))) - editor.undoManager?.undo() - #expect(editor.markdownString == source) - #expect(editor.selectedRange() == selection) - #expect(editor.undoManager?.canUndo == false) - editor.undoManager?.redo() - #expect(editor.markdownString == " " + source) - editor.keyDown(with: backtab) - #expect(editor.markdownString == source) - #expect(editor.selectedRange() == editor.renderedRange(for: logical)) - } - } - for (indent, remaining) in [("\t ", " "), (" ", " "), (" ", ""), ("", "")] { - for marker in ["+", "12)", "- [ ]"] { - let source = indent + marker + " 🦊 café" - reset(source) - let body = (editor.string as NSString).range(of: "🦊 café") - editor.setSelectedRange(body) - editor.keyDown(with: backtab) - #expect(editor.markdownString == remaining + marker + " 🦊 café") - #expect((editor.string as NSString).substring(with: editor.selectedRange()) == "🦊 café") - if indent.isEmpty { #expect(editor.undoManager?.canUndo == false) } - } - } - for source in ["prose", "* * *", "```md\n- literal", "~~~\n 12) literal"] { - reset(source) - editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) - #expect(!controller.textView(editor, doCommandBy: #selector(NSResponder.insertTab(_:)))) - #expect(!controller.textView(editor, doCommandBy: #selector(NSResponder.insertBacktab(_:)))) - #expect(editor.markdownString == source) - } - reset("- root") - editor.setSelectedRange(NSRange(location: 0, length: 0)) - for _ in 0..<3 { editor.keyDown(with: backtab) } - #expect(editor.markdownString == "- root") - #expect(editor.undoManager?.canUndo == false) - // Tab completes a still-literal typed marker rather than indenting it. - for marker in ["-", "12)", "- [X]"] { - reset(" ") + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 520, height: 300)) + for (marker, block) in [("- ", "bullet"), ("* ", "bullet"), ("+ ", "bullet"), + ("12) ", "ordered"), ("- [ ] ", "task"), ("- [] ", "task"), + ("- [x] ", "task"), ("> ", "quote")] { + editor.setDocumentContents(NSAttributedString(string: "")) editor.setSelectedRange(NSRange(location: 0, length: 0)) - for character in marker { + for character in marker + "🦊 body" { editor.insertText(String(character), replacementRange: editor.selectedRange()) } - #expect(editor.string == marker + " ") - editor.keyDown(with: tab) - #expect(editor.markdownString == marker + "\t ") - #expect(editor.string == "\u{fffc}\t ") - editor.keyDown(with: tab) - #expect(editor.markdownString == " " + marker + "\t ") + #expect(editor.string == "🦊 body") + #expect(editor.selectedRange() == NSRange(location: "🦊 body".utf16.count, length: 0)) + #expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == block) + #expect(editor.documentContents.attribute(.attachment, at: 0, effectiveRange: nil) == nil) + if block == "task" { + #expect(editor.documentContents.attribute(NoteAttributes.checked, at: 0, effectiveRange: nil) as? Bool == marker.contains("[x]")) + } + if block == "ordered" { + #expect(editor.documentContents.attribute(NoteAttributes.number, at: 0, effectiveRange: nil) as? Int == 12) + } + } + for literal in ["-word", "1.word", "prose - item", "unmatched *text", "\\*escaped*"] { + editor.setDocumentContents(NSAttributedString(string: "")) + editor.setSelectedRange(NSRange(location: 0, length: 0)) + for character in literal { + editor.insertText(String(character), replacementRange: editor.selectedRange()) + } + #expect(editor.string == literal) + #expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) == nil) } - reset("- 🦊 café") - editor.setSelectedRange((editor.string as NSString).range(of: "café")) - editor.keyDown(with: tab) - editor.breakUndoCoalescing() - #expect(editor.markdownString == " - 🦊 café") - #expect((editor.string as NSString).substring(with: editor.selectedRange()) == "café") - #expect(controller.save()) - let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:))) - #expect(try NotesStore(directory: directory).notes.first { $0.id == id }?.contents.string == " - 🦊 café") - editor.undoManager?.undo() - #expect(editor.markdownString == "- 🦊 café") - #expect(editor.undoManager?.canUndo == false) - editor.undoManager?.redo() - #expect(editor.markdownString == " - 🦊 café") } @Test @MainActor -func taskCheckboxesToggleMarkdownWithUndoPersistenceAndListContinuation() throws { +func listIndentationPreservesActualUnicodeSelectionsAndUndo() throws { + _ = NSApplication.shared + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 520, height: 300)) + editor.allowsUndo = true + let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false) + window.contentView = editor + window.makeFirstResponder(editor) + defer { window.orderOut(nil) } + for source in ["- parent\n- 🦊 café", "1. parent\n2. 🦊 café\n3. tail", "- [ ] parent\n- [x] 🦊 café"] { + editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source))) + let selection = (editor.string as NSString).range(of: "🦊 café") + editor.setSelectedRange(selection) + editor.undoManager?.removeAllActions() + let before = editor.string + editor.insertTab(nil) + #expect(editor.string == before) + #expect(editor.selectedRange() == selection) + #expect(editor.documentContents.attribute(NoteAttributes.depth, at: selection.location, effectiveRange: nil) as? Int == 1) + if source.hasPrefix("1.") { + let tail = (editor.string as NSString).range(of: "tail").location + #expect(editor.documentContents.attribute(NoteAttributes.number, at: selection.location, effectiveRange: nil) as? Int == 1) + #expect(editor.documentContents.attribute(NoteAttributes.number, at: tail, effectiveRange: nil) as? Int == 2) + } + editor.undoManager?.undo() + #expect(editor.selectedRange() == selection) + #expect((editor.documentContents.attribute(NoteAttributes.depth, at: selection.location, effectiveRange: nil) as? Int ?? 0) == 0) + editor.undoManager?.redo() + #expect(editor.documentContents.attribute(NoteAttributes.depth, at: selection.location, effectiveRange: nil) as? Int == 1) + editor.insertBacktab(nil) + #expect(editor.string == before) + #expect(editor.selectedRange() == selection) + #expect((editor.documentContents.attribute(NoteAttributes.depth, at: selection.location, effectiveRange: nil) as? Int ?? 0) == 0) + if source.hasPrefix("1.") { + let tail = (editor.string as NSString).range(of: "tail").location + #expect(editor.documentContents.attribute(NoteAttributes.number, at: selection.location, effectiveRange: nil) as? Int == 2) + #expect(editor.documentContents.attribute(NoteAttributes.number, at: tail, effectiveRange: nil) as? Int == 3) + } + } +} + +@Test @MainActor +func taskCheckboxesToggleSemanticStateWithUndoAndPersistence() throws { _ = NSApplication.shared let suite = "NotesTasks.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) @@ -668,308 +528,10 @@ func taskCheckboxesToggleMarkdownWithUndoPersistenceAndListContinuation() throws defaults.removePersistentDomain(forName: suite) try? FileManager.default.removeItem(at: directory) } - let controller = NotesController(defaults: defaults, directory: directory) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 390), - styleMask: .borderless, backing: .buffered, defer: false) - window.contentViewController = controller - defer { _ = controller.close(); window.orderOut(nil) } - func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } - let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first) - window.makeFirstResponder(editor) - let before = "- [ ] buy 🦊\n \t+ [X]\tnested\n```md\n- [ ] literal\n```\nprose - [ ] not a task" - let rendered = "\u{fffc} buy 🦊\n \t\u{fffc}\tnested\n```md\n- [ ] literal\n```\nprose - [ ] not a task" - editor.insertText(before, replacementRange: editor.selectedRange()) - editor.breakUndoCoalescing() - editor.undoManager?.removeAllActions() - #expect(editor.string == rendered) - #expect(editor.markdownContents.string == before) - #expect(editor.textStorage?.attribute(.attachment, at: 0, effectiveRange: nil) is NSTextAttachment) - let boxes = editor.subviews.compactMap { $0 as? NSButton } - #expect(boxes.map(\.state) == [.off, .on]) - #expect(boxes.first?.cell?.accessibilityRole() == .checkBox) - #expect(boxes.first?.accessibilityLabel() == "buy 🦊") - let selection = editor.selectedRange() - let first = try #require(boxes.first) - first.performClick(nil) - let checked = before.replacingOccurrences(of: "- [ ] buy", with: "- [x] buy") - #expect(editor.string == rendered) - #expect(editor.markdownString == checked) - #expect(first.state == .on) - #expect(editor.selectedRange() == selection) - let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:))) - let saved = try #require(NotesStore(directory: directory).notes.first { $0.id == id }) - #expect(saved.contents.string == checked) - #expect(saved.contents.attribute(.attachment, at: 0, effectiveRange: nil) == nil) - editor.undoManager?.undo() - #expect(editor.markdownString == before) - #expect(editor.string == rendered) - #expect(editor.subviews.compactMap { $0 as? NSButton }.first?.state == .off) - editor.undoManager?.redo() - #expect(editor.markdownString == checked) - #expect(editor.string == rendered) - #expect(editor.subviews.compactMap { $0 as? NSButton }.first?.state == .on) - editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count)) - let board = NSPasteboard.withUniqueName() - defer { board.releaseGlobally() } - #expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes)) - #expect(board.string(forType: .string) == checked) - let reopened = NotesController(defaults: defaults, directory: directory) - defer { _ = reopened.close() } - let restoredEditor = try #require(descendants(reopened.view).compactMap { $0 as? NotesTextView }.first) - #expect(restoredEditor.markdownString == checked) - #expect(restoredEditor.string == rendered) - #expect(restoredEditor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.on, .on]) - editor.insertText("- [x] finished", replacementRange: NSRange(location: 0, length: editor.string.utf16.count)) - let enter = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], - timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "\r", - charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36)) - editor.keyDown(with: enter) - #expect(editor.markdownString == "- [x] finished\n- [ ] ") - #expect(editor.string == "\u{fffc} finished\n\u{fffc} ") - #expect(editor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.on, .off]) - editor.keyDown(with: enter) - #expect(editor.markdownString == "- [x] finished\n") - #expect(editor.string == "\u{fffc} finished\n") - #expect(editor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.on]) -} - -@Test @MainActor -func atomicListMarkersExpandAtDeletionBoundariesAndNormalizeLineJoinsInOneUndo() throws { - _ = NSApplication.shared - let suite = "NotesTaskDeletion.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite) - defer { - defaults.removePersistentDomain(forName: suite) - try? FileManager.default.removeItem(at: directory) - } - let controller = NotesController(defaults: defaults, directory: directory) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 390), - styleMask: .borderless, backing: .buffered, defer: false) - window.contentViewController = controller - defer { _ = controller.close(); window.orderOut(nil) } - func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } - let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first) - window.makeFirstResponder(editor) - func reset(_ source: String) -> String { - editor.insertText(source, replacementRange: NSRange(location: 0, length: editor.string.utf16.count)) - editor.breakUndoCoalescing() - editor.undoManager?.removeAllActions() - return editor.string - } - func delete(backward: Bool) { - if backward { editor.deleteBackward(nil) } - else { editor.deleteForward(nil) } - editor.breakUndoCoalescing() - } - let boundaries = [ - ("- [ ] buy", true, "- [ buy"), - ("\t+ [X]\t🦊", true, "\t+ [X\t🦊"), - (" 12)\t[x] done", true, " 12)\t[x done"), - ("- [ ] buy", false, " [ ] buy"), - ("\t+ [X]\t🦊", false, "\t [X]\t🦊"), - (" 12)\t[x] done", false, " 2)\t[x] done"), - ("- buy", true, " buy"), - ("\t+\t🦊", true, "\t\t🦊"), - (" 123)\tdone", true, " 123\tdone"), - ("* buy", false, " buy"), - (" 123. done", false, " 23. done"), - ] - for (source, backward, expanded) in boundaries { - let rendered = reset(source) - let token = (rendered as NSString).range(of: "\u{fffc}") - #expect(token.length == 1) - let marker = editor.markdownRange(for: token) - editor.setSelectedRange(NSRange(location: token.location + (backward ? 1 : 0), length: 0)) - delete(backward: backward) - #expect(editor.string == expanded) - #expect(editor.markdownString == expanded) - let caret = backward ? NSMaxRange(marker) - 1 : token.location - #expect(editor.selectedRange() == NSRange(location: caret, length: 0)) - editor.undoManager?.undo() - #expect(editor.string == rendered) - #expect(editor.markdownString == source) - #expect(editor.undoManager?.canUndo == false) - editor.undoManager?.redo() - #expect(editor.string == expanded) - #expect(editor.markdownString == expanded) - } - let joins = [ - ("prose\n- item", "prose- item", "prose- item"), - ("\n123) item", "123) item", "\u{fffc} item"), - ("prose\n- [ ] task", "prose- [ ] task", "prose- [ ] task"), - ("prose\n - [X] task", "prose - [X] task", "prose - [X] task"), - ("🦊\n\t12) [x]\tfinish", "🦊\t12) [x]\tfinish", "🦊\t12) [x]\tfinish"), - ("\n- [ ] task", "- [ ] task", "\u{fffc} task"), - (" \t\n + [X] task", " \t + [X] task", " \t \u{fffc} task"), - ] - for (source, joined, renderedJoin) in joins { - for backward in [true, false] { - let rendered = reset(source) - let newline = (rendered as NSString).range(of: "\n") - editor.setSelectedRange(NSRange(location: newline.location + (backward ? 1 : 0), length: 0)) - delete(backward: backward) - #expect(editor.markdownString == joined) - #expect(editor.string == renderedJoin) - editor.undoManager?.undo() - #expect(editor.markdownString == source) - #expect(editor.string == rendered) - #expect(editor.undoManager?.canUndo == false) - editor.undoManager?.redo() - #expect(editor.string == renderedJoin) - #expect(editor.markdownString == joined) - } - } -} - -@Test @MainActor -func listNavigationSkipsHiddenSeparatorsAndKeepsSelectionAnchorsAndDeletionUndo() throws { - _ = NSApplication.shared - let suite = "NotesVisualCaret.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite) - defer { - defaults.removePersistentDomain(forName: suite) - try? FileManager.default.removeItem(at: directory) - } - let controller = NotesController(defaults: defaults, directory: directory) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 390), - styleMask: .borderless, backing: .buffered, defer: false) - window.contentViewController = controller - defer { _ = controller.close(); window.orderOut(nil) } - func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } - let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first) - window.makeFirstResponder(editor) - let board = NSPasteboard.withUniqueName() - defer { board.releaseGlobally() } - let body = "🦊e\u{301} z" - // Prefix source, rendered prefix, marker, separator, backward/forward expansions. - let fixtures = [ - ("", "", "-", " ", "", ""), - ("\t", "\t", "+", "\t", "", ""), - (" ", " ", "009)", "\t", "009", "09)"), - ("- parent\n ", "\u{fffc} parent\n ", "- [X]", " ", "- [X", " [X]"), - ("\t", "\t", "12)\t[]", "\t", "12)\t[", "2)\t[]"), - ] - for (prefix, renderedPrefix, marker, separator, backwardExpansion, forwardExpansion) in fixtures { - let source = prefix + marker + separator + body - editor.insertText(source, replacementRange: NSRange(location: 0, length: editor.string.utf16.count)) - let rendered = renderedPrefix + "\u{fffc}" + separator + body - #expect(editor.string == rendered) - let before = renderedPrefix.utf16.count - let start = before + 1 + separator.utf16.count - func caret(_ index: Int) { editor.setSelectedRange(NSRange(location: index, length: 0)) } - func expectSelection(_ location: Int, _ length: Int = 0) { - #expect(editor.selectedRange() == NSRange(location: location, length: length)) - } - caret(before) - editor.moveRight(nil) - expectSelection(start) - editor.moveRight(nil) - expectSelection(start + 2) - editor.moveRight(nil) - expectSelection(start + 4) - editor.moveRight(nil) - expectSelection(start + 5) // Visible body spaces still have individual stops. - editor.moveLeft(nil) - expectSelection(start + 4) - editor.moveLeft(nil) - expectSelection(start + 2) - editor.moveLeft(nil) - expectSelection(start) - editor.moveLeft(nil) - expectSelection(before) - if before > 0 { - editor.moveLeft(nil) - expectSelection(before - 1) - editor.moveRight(nil) - expectSelection(before) - } - editor.moveRightAndModifySelection(nil) - expectSelection(before, start - before) - #expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes)) - #expect(board.string(forType: .string) == marker + separator) - editor.moveRightAndModifySelection(nil) - expectSelection(before, start + 2 - before) - editor.moveRightAndModifySelection(nil) - expectSelection(before, start + 4 - before) - editor.moveLeftAndModifySelection(nil) - expectSelection(before, start + 2 - before) - editor.moveLeftAndModifySelection(nil) - expectSelection(before, start - before) - editor.moveLeftAndModifySelection(nil) - expectSelection(before) - if before > 0 { - editor.moveLeftAndModifySelection(nil) - expectSelection(before - 1, 1) - editor.moveRightAndModifySelection(nil) - expectSelection(before) - } - caret(start) - editor.moveLeftAndModifySelection(nil) - expectSelection(before, start - before) - editor.moveRightAndModifySelection(nil) - expectSelection(start) - editor.moveRightAndModifySelection(nil) - expectSelection(start, 2) - editor.moveLeftAndModifySelection(nil) - expectSelection(start) - editor.moveLeftAndModifySelection(nil) - expectSelection(before, start - before) - editor.moveRightAndModifySelection(nil) - expectSelection(start) - editor.setSelectedRange(NSRange(location: 0, length: rendered.utf16.count)) - board.clearContents() - #expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes)) - #expect(board.string(forType: .string) == source) - #expect(controller.save()) - let persisted = try NotesStore(directory: directory) - #expect(persisted.notes.contains { $0.contents.string == source }) - for backward in [true, false] { - editor.setMarkdownContents(NSAttributedString(string: source)) - editor.breakUndoCoalescing() - editor.undoManager?.removeAllActions() - caret(before) - if backward { editor.moveRight(nil); editor.deleteBackward(nil) } - else { editor.deleteForward(nil) } - editor.breakUndoCoalescing() - let expansion = backward ? backwardExpansion : forwardExpansion - #expect(editor.markdownString == prefix + expansion + separator + body) - #expect(editor.selectedRange() == NSRange( - location: before + (backward ? expansion.utf16.count : 0), length: 0)) - editor.undoManager?.undo() - #expect(editor.string == rendered) - #expect(editor.markdownString == source) - #expect(editor.undoManager?.canUndo == false) - } - } -} - -@Test @MainActor -func atomicTasksCopySelectAndPersistMarkdownAlongsideUnicodeAndImages() throws { - _ = NSApplication.shared - let suite = "NotesTaskImages.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite) - defer { - defaults.removePersistentDomain(forName: suite) - try? FileManager.default.removeItem(at: directory) - } - let bitmap = try #require(NSBitmapImageRep( - bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1, - bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, - colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) - bitmap.setColor(.red, atX: 0, y: 0) - let png = try #require(bitmap.representation(using: .png, properties: [:])) - let imageFile = FileWrapper(regularFileWithContents: png) - imageFile.preferredFilename = "pixel.png" - let source = NSMutableAttributedString(string: "\t12)\t[X]\t🦊 ") - let imageLocation = source.length - source.append(NSAttributedString(attachment: NSTextAttachment(fileWrapper: imageFile))) - source.append(NSAttributedString(string: "\n - [ ] tail\n+ bullet\n009) numbered")) let store = try NotesStore(directory: directory) let id = try store.create() - try store.update(id: id, contents: source) + try store.update(id: id, contents: NoteDocument.importMarkdown(NSAttributedString(string: + "- [ ] buy 🦊\n - [x] nested\n```md\n- [ ] literal\n```\nprose - [ ] not a task"))) defaults.set(id.uuidString, forKey: "selectedNote") let controller = NotesController(defaults: defaults, directory: directory) let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 390), @@ -979,516 +541,123 @@ func atomicTasksCopySelectAndPersistMarkdownAlongsideUnicodeAndImages() throws { func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first) window.makeFirstResponder(editor) - let rendered = "\t\u{fffc}\t🦊 \u{fffc}\n \u{fffc} tail\n\u{fffc} bullet\n\u{fffc} numbered" - #expect(editor.string == rendered) - #expect(editor.markdownString == source.string) - let token = NSRange(location: 1, length: 1) - let marker = (source.string as NSString).range(of: "12)\t[X]") - #expect(editor.markdownRange(for: token) == marker) - #expect(editor.renderedRange(for: (source.string as NSString).range(of: "[X]")) == token) - #expect(editor.renderedRange(for: (source.string as NSString).range(of: "🦊")) == (rendered as NSString).range(of: "🦊")) - editor.setSelectedRange(NSRange(location: token.location, length: 0)) - editor.moveRight(nil) - #expect(editor.selectedRange() == NSRange(location: NSMaxRange(token) + 1, length: 0)) - editor.moveLeft(nil) - #expect(editor.selectedRange() == NSRange(location: token.location, length: 0)) - editor.moveRightAndModifySelection(nil) - #expect(editor.selectedRange() == NSRange(location: token.location, length: 2)) - let board = NSPasteboard.withUniqueName() - defer { board.releaseGlobally() } - editor.setSelectedRange(token) - #expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes)) - #expect(board.string(forType: .string) == "12)\t[X]") - editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count)) - board.clearContents() - #expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes)) - #expect(board.string(forType: .string) == source.string) - let copiedData = try #require(board.data(forType: .rtfd)) - let copied = try NSAttributedString(data: copiedData, options: [.documentType: NSAttributedString.DocumentType.rtfd], documentAttributes: nil) - #expect(copied.string == source.string) - #expect((copied.attribute(.attachment, at: imageLocation, effectiveRange: nil) as? NSTextAttachment)?.fileWrapper?.regularFileContents == png) + let before = editor.string + #expect(before == "buy 🦊\nnested\n- [ ] literal\nprose - [ ] not a task") + let boxes = editor.subviews.compactMap { $0 as? NSButton } + #expect(boxes.map(\.state) == [.off, .on]) + let first = try #require(boxes.first) + #expect(first.cell?.accessibilityRole() == .checkBox) + #expect(first.accessibilityLabel() == "buy 🦊") + let selection = NSRange(location: 2, length: 2) + editor.setSelectedRange(selection) editor.undoManager?.removeAllActions() - editor.setSelectedRange(token) - editor.deleteBackward(nil) - editor.breakUndoCoalescing() - #expect(editor.string == "\t\t🦊 \u{fffc}\n \u{fffc} tail\n\u{fffc} bullet\n\u{fffc} numbered") - #expect(editor.markdownString == (source.string as NSString).replacingCharacters(in: marker, with: "")) - editor.undoManager?.undo() - #expect(editor.string == rendered) - #expect(editor.markdownString == source.string) - #expect(editor.undoManager?.canUndo == false) + first.performClick(nil) + #expect(editor.string == before) + #expect(editor.selectedRange() == selection) + #expect(editor.documentContents.attribute(NoteAttributes.checked, at: 0, effectiveRange: nil) as? Bool == true) #expect(controller.save()) let saved = try #require(NotesStore(directory: directory).notes.first { $0.id == id }) - #expect(saved.contents.string == source.string) - var savedAttachments: [NSTextAttachment] = [] - saved.contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: saved.contents.length)) { value, _, _ in - if let attachment = value as? NSTextAttachment { savedAttachments.append(attachment) } - } - #expect(savedAttachments.count == 1) - #expect(savedAttachments.first?.fileWrapper?.regularFileContents == png) + #expect(saved.contents.string == before) + #expect(saved.contents.attribute(NoteAttributes.checked, at: 0, effectiveRange: nil) as? Bool == true) + editor.undoManager?.undo() + #expect(editor.documentContents.attribute(NoteAttributes.checked, at: 0, effectiveRange: nil) as? Bool == false) + #expect(editor.subviews.compactMap { $0 as? NSButton }.first?.state == .off) + editor.undoManager?.redo() + #expect(editor.subviews.compactMap { $0 as? NSButton }.first?.state == .on) + #expect(controller.save()) let reopened = NotesController(defaults: defaults, directory: directory) defer { _ = reopened.close() } - let reopenedEditor = try #require(descendants(reopened.view).compactMap { $0 as? NotesTextView }.first) - #expect(reopenedEditor.string == rendered) - #expect(reopenedEditor.markdownContents.string == source.string) + let restored = try #require(descendants(reopened.view).compactMap { $0 as? NotesTextView }.first) + #expect(restored.string == before) + #expect(restored.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.on, .on]) } @Test @MainActor -func taskTokensRequireTrailingWhitespaceAndAcceptEmptyBracketsWhileTyping() throws { +func listBodiesUseActualTextOffsetsForTypingPasteReplacementAndDeletion() throws { _ = NSApplication.shared - let suite = "TaskSyntax.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite) - defer { - defaults.removePersistentDomain(forName: suite) - try? FileManager.default.removeItem(at: directory) - } - let controller = NotesController(defaults: defaults, directory: directory) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 390), - styleMask: .borderless, backing: .buffered, defer: false) - window.contentViewController = controller - defer { _ = controller.close(); window.orderOut(nil) } - func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } - let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first) + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 520, height: 300)) + editor.allowsUndo = true + let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false) + window.contentView = editor window.makeFirstResponder(editor) - for marker in ["- [ ]", "- []", "- [x]", "- [X]", "\t+ []", "12) []"] { - editor.setMarkdownContents(NSAttributedString(string: "")) + defer { window.orderOut(nil) } + let board = NSPasteboard.withUniqueName() + defer { board.releaseGlobally() } + board.setString("**literal paste**", forType: .string) + for source in ["- ab🦊cd", "12. ab🦊cd", "- [x] ab🦊cd"] { + editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source))) + #expect(editor.string == "ab🦊cd") editor.setSelectedRange(NSRange(location: 0, length: 0)) - var typed = "" - for character in marker { - typed.append(character) - editor.insertText(String(character), replacementRange: editor.selectedRange()) - let ordinaryPrefix = ["- ", "\t+ ", "12) "].contains(typed) - let indentation = String(typed.prefix { $0 == " " || $0 == "\t" }) - #expect(editor.string == (ordinaryPrefix ? indentation + "\u{fffc} " : typed)) - #expect(editor.markdownString == typed) - } - editor.insertText(" ", replacementRange: editor.selectedRange()) - let indentation = String(marker.prefix { $0 == " " || $0 == "\t" }) - #expect(editor.string == indentation + "\u{fffc} ") - let checkbox = try #require(editor.subviews.compactMap { $0 as? NSButton }.first) - #expect(checkbox.state == (marker.hasSuffix("[x]") || marker.hasSuffix("[X]") ? .on : .off)) - editor.insertText("🦊", replacementRange: editor.selectedRange()) - #expect(editor.string == indentation + "\u{fffc} 🦊") - #expect(editor.markdownString == marker + " 🦊") + editor.moveRight(nil) + #expect(editor.selectedRange().location == 1) + editor.setSelectedRange((editor.string as NSString).range(of: "🦊")) + editor.undoManager?.removeAllActions() + editor.insertText("X", replacementRange: editor.selectedRange()) + #expect(editor.string == "abXcd") + editor.undoManager?.undo() + #expect(editor.string == "ab🦊cd") + #expect(editor.selectedRange() == NSRange(location: 2, length: 2)) + editor.undoManager?.redo() + #expect(editor.string == "abXcd") + editor.setSelectedRange(NSRange(location: 0, length: 0)) + #expect(editor.readSelection(from: board)) + #expect(editor.string == "**literal paste**abXcd") + #expect(editor.documentContents.attribute(NoteAttributes.bold, at: 2, effectiveRange: nil) as? Bool != true) + editor.insertText(" ", replacementRange: NSRange(location: 0, length: 0)) + editor.setSelectedRange(NSRange(location: 0, length: 0)) + editor.moveRight(nil) + #expect(editor.selectedRange().location == 1) + editor.moveRight(nil) + #expect(editor.selectedRange().location == 2) } - editor.setMarkdownContents(NSAttributedString(string: "- []\n")) - #expect(editor.string == "- []\n") - editor.setMarkdownContents(NSAttributedString(string: "- []\ttask")) - #expect(editor.string == "\u{fffc}\ttask") - editor.breakUndoCoalescing() + editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "prose\n- item"))) + editor.setSelectedRange(NSRange(location: "prose\n".utf16.count, length: 0)) editor.undoManager?.removeAllActions() - let box = try #require(editor.subviews.compactMap { $0 as? NSButton }.first) - box.performClick(nil) - #expect(editor.markdownString == "- [x]\ttask") - #expect(box.state == .on) + editor.deleteBackward(nil) + #expect(editor.string == "proseitem") editor.undoManager?.undo() - #expect(editor.markdownString == "- []\ttask") - #expect(box.state == .off) - editor.undoManager?.redo() - #expect(editor.markdownString == "- [x]\ttask") - box.performClick(nil) - #expect(editor.markdownString == "- [ ]\ttask") - let enter = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], - timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "\r", - charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36)) - editor.insertText("- [] task", replacementRange: NSRange(location: 0, length: editor.string.utf16.count)) - editor.keyDown(with: enter) - #expect(editor.markdownString == "- [] task\n- [ ] ") - #expect(editor.string == "\u{fffc} task\n\u{fffc} ") - editor.keyDown(with: enter) - #expect(editor.markdownString == "- [] task\n") - editor.insertText("- [] ", replacementRange: NSRange(location: 0, length: editor.string.utf16.count)) - editor.keyDown(with: enter) - #expect(editor.markdownString.isEmpty) - for source in ["- item", "* item", "+ item", "001) item", "09. item"] { - editor.setMarkdownContents(NSAttributedString(string: "")) - editor.setSelectedRange(NSRange(location: 0, length: 0)) - var typed = "" - for character in source { - typed.append(character) - editor.insertText(String(character), replacementRange: editor.selectedRange()) - #expect(editor.markdownString == typed) - } - #expect(editor.string == "\u{fffc} item") - #expect(editor.subviews.compactMap { $0 as? NSButton }.isEmpty) - } - for literal in [ - "---", "- - -", "* * *", "___", "-word", "1.word", "prose - item", - "```md\n- literal\n123) literal\n```", "~~~\n+ literal\n~~~", - "- [", "- []", "- [ ]", "12) [X]", "- []not a task", - ] { - editor.setMarkdownContents(NSAttributedString(string: literal)) - #expect(editor.string == literal) - #expect(editor.markdownString == literal) - #expect(editor.subviews.compactMap { $0 as? NSButton }.isEmpty) - } + #expect(editor.string == "prose\nitem") + #expect(editor.documentContents.attribute(NoteAttributes.block, at: 6, effectiveRange: nil) as? String == "bullet") } @Test @MainActor -func mixedListMarkersAlignBodyGlyphsAndWrappedLinesWithoutChangingMarkdown() throws { +func mixedListBodiesAndWrappedLinesShareColumnsAtEachDepth() throws { _ = NSApplication.shared - let suite = "NotesListAlignment.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite) - defer { - defaults.removePersistentDomain(forName: suite) - try? FileManager.default.removeItem(at: directory) - } - let controller = NotesController(defaults: defaults, directory: directory) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 520, height: 400), - styleMask: .borderless, backing: .buffered, defer: false) - window.contentViewController = controller - window.setContentSize(NSSize(width: 520, height: 400)) - defer { _ = controller.close(); window.orderOut(nil) } - func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } - let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first) - let continuation = String(repeating: " wrapped words", count: 24) - let rows = [ - ("", "-", " ", "bullet"), ("", "*", "\t", "asterisk"), - ("", "+", " ", "plus"), ("", "1.", " ", "ordered"), - ("", "123456789)", " ", "long"), ("", "- [ ]", " ", "task"), - (" ", "+", " ", "nestedBullet"), (" ", "00012.", "\t", "nestedNumber"), - (" ", "* [X]", " ", "nestedTask"), - ] - let source = rows.map { $0.0 + $0.1 + $0.2 + $0.3 + continuation }.joined(separator: "\n") - editor.insertText(source, replacementRange: editor.selectedRange()) - window.contentView?.layoutSubtreeIfNeeded() + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 800)) + let continuation = String(repeating: " wrapped words", count: 15) + let rows = [("- ", "bullet"), ("1. ", "ordered"), ("123456789. ", "wide"), + ("- [ ] ", "task"), (" - ", "nested"), (" - [x] ", "nestedTask")] + let source = rows.map { $0.0 + $0.1 + continuation }.joined(separator: "\n") + editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source))) let layout = try #require(editor.layoutManager) let container = try #require(editor.textContainer) layout.ensureLayout(for: container) - let rendered = editor.string as NSString func position(_ glyph: Int) -> NSPoint { let fragment = layout.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil) let location = layout.location(forGlyphAt: glyph) return NSPoint(x: fragment.minX + location.x, y: fragment.minY + location.y) } - var bodyColumns: [CGFloat] = [] - for (index, row) in rows.enumerated() { - let body = rendered.range(of: row.3 + continuation) - #expect(body.location != NSNotFound) + var columns: [CGFloat] = [] + for row in rows { + let body = (editor.string as NSString).range(of: row.1 + continuation) let glyphs = layout.glyphRange(forCharacterRange: body, actualCharacterRange: nil) let column = position(glyphs.location).x - bodyColumns.append(column) - let paragraph = rendered.paragraphRange(for: body) - let marker = rendered.range(of: "\u{fffc}", options: [], range: paragraph) - #expect(marker.length == 1) - let markerGlyphs = layout.glyphRange(forCharacterRange: marker, actualCharacterRange: nil) - #expect(layout.boundingRect(forGlyphRange: markerGlyphs, in: container).maxX <= column + 0.5) + columns.append(column) var lines = 0 layout.enumerateLineFragments(forGlyphRange: glyphs) { _, _, _, lineGlyphs, _ in - if lines > 0 { - #expect(abs(position(lineGlyphs.location).x - column) < 0.5, - "Wrapped body column for row \(index)") - } + #expect(abs(position(lineGlyphs.location).x - column) < 0.5) lines += 1 } - #expect(lines > 1, "Row \(index) must wrap to exercise hanging indentation") - let style = try #require(editor.textStorage?.attribute(.paragraphStyle, at: body.location, effectiveRange: nil) as? NSParagraphStyle) - #expect(style.headIndent > 0) + #expect(lines > 1) } - for column in bodyColumns.prefix(6) { #expect(abs(column - bodyColumns[0]) < 0.5) } - for column in bodyColumns.suffix(3) { #expect(abs(column - bodyColumns[6]) < 0.5) } - #expect(bodyColumns[6] > bodyColumns[0]) - #expect(editor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.off, .on]) - #expect(editor.markdownString == source) - let sourceMarker = (source as NSString).range(of: "123456789)") - let renderedMarker = editor.renderedRange(for: sourceMarker) - #expect(rendered.substring(with: renderedMarker) == "\u{fffc}") - #expect(editor.markdownRange(for: renderedMarker) == sourceMarker) - let board = NSPasteboard.withUniqueName() - defer { board.releaseGlobally() } - editor.setSelectedRange(renderedMarker) - #expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes)) - #expect(board.string(forType: .string) == "123456789)") - editor.setSelectedRange(NSRange(location: 0, length: rendered.length)) - board.clearContents() - #expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes)) - #expect(board.string(forType: .string) == source) - let copiedData = try #require(board.data(forType: .rtfd)) - let copied = try NSAttributedString(data: copiedData, - options: [.documentType: NSAttributedString.DocumentType.rtfd], documentAttributes: nil) - #expect(copied.string == source) - #expect(controller.save()) - let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:))) - let saved = try #require(NotesStore(directory: directory).notes.first { $0.id == id }) - for contents in [editor.markdownContents, copied, saved.contents] { - #expect(contents.string == source) - contents.enumerateAttribute(.paragraphStyle, in: NSRange(location: 0, length: contents.length)) { value, _, _ in - let style = value as? NSParagraphStyle - #expect((style?.headIndent ?? 0) == 0) - #expect((style?.firstLineHeadIndent ?? 0) == 0) - } - } - editor.breakUndoCoalescing() - editor.undoManager?.removeAllActions() - editor.setSelectedRange(renderedMarker) - editor.deleteBackward(nil) - editor.breakUndoCoalescing() - #expect(editor.markdownString == (source as NSString).replacingCharacters(in: sourceMarker, with: "")) - editor.undoManager?.undo() - #expect(editor.string == rendered as String) - #expect(editor.markdownString == source) - #expect(editor.undoManager?.canUndo == false) - let markerAttribute = NSAttributedString.Key("ListMarkerSourceTest") - let originalStyle = NSMutableParagraphStyle() - originalStyle.firstLineHeadIndent = 3 - originalStyle.headIndent = 7 - let attributedSource = NSMutableAttributedString(string: "123) item", attributes: [.paragraphStyle: originalStyle]) - attributedSource.addAttribute(markerAttribute, value: "first", range: NSRange(location: 0, length: 1)) - attributedSource.addAttribute(markerAttribute, value: "third", range: NSRange(location: 2, length: 1)) - editor.setMarkdownContents(attributedSource) - let roundTrip = editor.markdownContents - #expect(roundTrip.attribute(markerAttribute, at: 0, effectiveRange: nil) as? String == "first") - #expect(roundTrip.attribute(markerAttribute, at: 2, effectiveRange: nil) as? String == "third") - let restoredStyle = try #require(roundTrip.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle) - #expect(restoredStyle.firstLineHeadIndent == 3) - #expect(restoredStyle.headIndent == 7) - editor.undoManager?.removeAllActions() - editor.setSelectedRange(NSRange(location: 1, length: 0)) - editor.deleteBackward(nil) - editor.breakUndoCoalescing() - #expect(editor.markdownString == "123 item") - #expect(editor.markdownContents.attribute(markerAttribute, at: 2, effectiveRange: nil) as? String == "third") - editor.undoManager?.undo() - #expect(editor.markdownString == attributedSource.string) - #expect(editor.markdownContents.attribute(markerAttribute, at: 0, effectiveRange: nil) as? String == "first") - #expect(editor.markdownContents.attribute(markerAttribute, at: 2, effectiveRange: nil) as? String == "third") + for column in columns.prefix(4) { #expect(abs(column - columns[0]) < 0.5) } + #expect(abs(columns[4] - columns[5]) < 0.5) + #expect(columns[4] > columns[0]) + #expect(!editor.string.contains("\u{fffc}")) } @Test @MainActor -func renderedListMarkersPaintBulletsAndNumbersInsideTheirGlyphFrames() throws { - _ = NSApplication.shared - let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) - editor.isRichText = true - for source in ["- item", "* item", "+ item", "1. item", "123) item"] { - editor.setMarkdownContents(NSAttributedString(string: source)) - let layout = try #require(editor.layoutManager) - let container = try #require(editor.textContainer) - layout.ensureLayout(for: container) - let glyphs = layout.glyphRange(forCharacterRange: NSRange(location: 0, length: 1), actualCharacterRange: nil) - let frame = layout.boundingRect(forGlyphRange: glyphs, in: container) - let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 160, pixelsHigh: 48, - bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, - colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) - let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) - NSGraphicsContext.saveGraphicsState() - NSGraphicsContext.current = context - context.cgContext.clear(CGRect(x: 0, y: 0, width: 160, height: 48)) - layout.drawGlyphs(forGlyphRange: glyphs, at: NSPoint(x: 4 - frame.minX, y: 10 - frame.minY)) - NSGraphicsContext.restoreGraphicsState() - var minX = 160, maxX = -1 - for y in 0..<48 { - for x in 0..<160 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.1 { - minX = min(minX, x) - maxX = max(maxX, x) - } - } - #expect(maxX >= minX, "The actual TextKit draw path must paint the marker for \(source)") - if ["-", "*", "+"].contains(String(source.prefix(1))) { - #expect(abs(CGFloat(minX + maxX + 1) / 2 - (4 + frame.width / 2)) <= 1) - } else { - #expect(abs(CGFloat(maxX + 1) - frame.width) <= 3) - } - #expect(editor.markdownString == source) - } -} - -@Test @MainActor -func listCheckboxesStayCenteredInTheSharedColumnAtEachIndentation() throws { - _ = NSApplication.shared - let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 300)) - let source = "- bullet\n- [ ] task\n12345. wide number\n + nested bullet\n - [x] nested task" - editor.setMarkdownContents(NSAttributedString(string: source)) - MarkdownHighlighting.apply(to: editor) - editor.refreshTasks() - let layout = try #require(editor.layoutManager) - let container = try #require(editor.textContainer) - layout.ensureLayout(for: container) - editor.layout() - let text = editor.string as NSString - let checkboxes = editor.subviews.compactMap { $0 as? NSButton } - for (index, label) in ["task", "nested task"].enumerated() { - let body = text.range(of: label) - let line = text.lineRange(for: body) - let marker = text.range(of: "\u{fffc}", options: [], range: line) - let glyphs = layout.glyphRange(forCharacterRange: marker, actualCharacterRange: nil) - let frame = layout.boundingRect(forGlyphRange: glyphs, in: container) - .offsetBy(dx: editor.textContainerOrigin.x, dy: editor.textContainerOrigin.y) - let checkbox = try #require(checkboxes.indices.contains(index) ? checkboxes[index] : nil) - #expect(abs(checkbox.frame.midX - frame.midX) <= 0.5) - let bodyGlyph = layout.glyphIndexForCharacter(at: body.location) - let fragment = layout.lineFragmentRect(forGlyphAt: bodyGlyph, effectiveRange: nil) - let baseline = editor.textContainerOrigin.y + fragment.minY + layout.location(forGlyphAt: bodyGlyph).y - let font = try #require(editor.textStorage?.attribute(.font, at: body.location, effectiveRange: nil) as? NSFont) - #expect(abs(checkbox.frame.midY - (baseline - font.capHeight / 2)) <= 0.5) - } - #expect(editor.markdownString == source) -} - -@Test @MainActor -func listBodyInsertionUsesVisiblePositionForTypingPasteAndReplacementRanges() throws { - _ = NSApplication.shared - let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 520, height: 200)) - editor.isRichText = true - editor.allowsUndo = true - let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false) - window.contentView = editor - defer { window.orderOut(nil) } - window.makeFirstResponder(editor) - let board = NSPasteboard.withUniqueName() - defer { board.releaseGlobally() } - board.setString("pasted", forType: .string) - for marker in ["-", "*", "+", "1.", "123)", "- []", "- [ ]", "- [X]", "\t12) []"] { - let separator = " \t " - let original = marker + separator + "tail" - for offset in 0...separator.utf16.count { - for explicitRange in [false, true] { - editor.setMarkdownContents(NSAttributedString(string: original)) - let token = (editor.string as NSString).range(of: "\u{fffc}") - let insertion = NSRange(location: NSMaxRange(token) + offset, length: 0) - editor.setSelectedRange(insertion) - editor.breakUndoCoalescing() - editor.undoManager?.removeAllActions() - editor.insertText("🦊", replacementRange: explicitRange ? insertion : NSRange(location: NSNotFound, length: 0)) - let position = max(1, offset) - #expect(editor.markdownString == marker + String(separator.prefix(position)) + "🦊" + String(separator.dropFirst(position)) + "tail") - #expect((editor.string as NSString).range(of: "\u{fffc}").location != NSNotFound) - #expect(editor.selectedRange().location == NSMaxRange(token) + position + 2) - editor.breakUndoCoalescing() - editor.undoManager?.undo() - #expect(editor.markdownString == original) - editor.undoManager?.redo() - #expect(editor.markdownString == marker + String(separator.prefix(position)) + "🦊" + String(separator.dropFirst(position)) + "tail") - } - editor.setMarkdownContents(NSAttributedString(string: original)) - let token = (editor.string as NSString).range(of: "\u{fffc}") - editor.setSelectedRange(NSRange(location: NSMaxRange(token) + offset, length: 0)) - #expect(editor.readSelection(from: board)) - let position = max(1, offset) - #expect(editor.markdownString == marker + String(separator.prefix(position)) + "pasted" + String(separator.dropFirst(position)) + "tail") - } - editor.setMarkdownContents(NSAttributedString(string: "")) - editor.setSelectedRange(NSRange(location: 0, length: 0)) - for character in marker + " " { - editor.insertText(String(character), replacementRange: editor.selectedRange()) - } - let token = (editor.string as NSString).range(of: "\u{fffc}") - #expect(editor.selectedRange().location == NSMaxRange(token) + 1) - editor.insertText("text", replacementRange: NSRange(location: NSNotFound, length: 0)) - #expect(editor.markdownString == marker + " text") - } - editor.setMarkdownContents(NSAttributedString(string: "- ab🦊cd")) - let emoji = (editor.string as NSString).range(of: "🦊") - editor.insertText("X", replacementRange: emoji) - #expect(editor.markdownString == "- abXcd") - let separator = NSRange(location: 1, length: 3) - editor.insertText("!", replacementRange: separator) - #expect(editor.markdownString == "-!abXcd") -} - -@Test @MainActor -func extraListBodyWhitespaceIsVisibleAndHasNormalCursorStops() throws { - _ = NSApplication.shared - let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 520, height: 200)) - editor.isRichText = true - editor.allowsUndo = true - let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false) - window.contentView = editor - defer { window.orderOut(nil) } - window.makeFirstResponder(editor) - let layout = try #require(editor.layoutManager) - let container = try #require(editor.textContainer) - func bodyX() -> CGFloat { - layout.ensureLayout(for: container) - let range = (editor.string as NSString).range(of: "tail") - let glyph = layout.glyphIndexForCharacter(at: range.location) - return layout.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil).minX + layout.location(forGlyphAt: glyph).x - } - for marker in ["-", "+", "*", "12.", "99)", "- []", "- [x]", " - [ ]"] { - for whitespace in [" ", " ", "\t", "\t "] { - let source = marker + " tail" - editor.setMarkdownContents(NSAttributedString(string: source)) - let token = (editor.string as NSString).range(of: "\u{fffc}") - let start = NSMaxRange(token) + 1 - let originalX = bodyX() - editor.setSelectedRange(NSRange(location: start, length: 0)) - editor.breakUndoCoalescing() - editor.undoManager?.removeAllActions() - editor.insertText(whitespace, replacementRange: editor.selectedRange()) - #expect(editor.markdownString == marker + " " + whitespace + "tail") - #expect(bodyX() > originalX, "Additional body whitespace must advance the rendered text") - #expect(editor.selectedRange().location == start + whitespace.utf16.count) - editor.setSelectedRange(NSRange(location: token.location, length: 0)) - editor.moveRight(nil) - #expect(editor.selectedRange().location == start) - for index in 1...whitespace.utf16.count { - editor.moveRight(nil) - #expect(editor.selectedRange().location == start + index) - } - for index in stride(from: whitespace.utf16.count - 1, through: 0, by: -1) { - editor.moveLeft(nil) - #expect(editor.selectedRange().location == start + index) - } - editor.moveLeft(nil) - #expect(editor.selectedRange().location == token.location) - editor.breakUndoCoalescing() - editor.undoManager?.undo() - #expect(editor.markdownString == source) - #expect(abs(bodyX() - originalX) < 0.5) - editor.undoManager?.redo() - #expect(bodyX() > originalX) - } - } -} - -@Test @MainActor -func typingMarkersBeforeExistingWhitespaceWaitsForAnExplicitSeparator() throws { - _ = NSApplication.shared - let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 520, height: 200)) - editor.isRichText = true - editor.allowsUndo = true - let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false) - window.contentView = editor - defer { window.orderOut(nil) } - window.makeFirstResponder(editor) - for marker in ["-", "*", "+", "12.", "9)", "- []", "- [x]"] { - for completion in [" ", "\t"] { - let prefix = "prose\n \t" - let tail = " \t" - editor.setMarkdownContents(NSAttributedString(string: prefix + tail)) - editor.setSelectedRange(NSRange(location: prefix.utf16.count, length: 0)) - for character in marker { - editor.insertText(String(character), replacementRange: editor.selectedRange()) - } - #expect(editor.string == prefix + marker + tail) - #expect(editor.markdownString == prefix + marker + tail) - editor.breakUndoCoalescing() - editor.undoManager?.removeAllActions() - editor.insertText(completion, replacementRange: editor.selectedRange()) - #expect(editor.string == prefix + "\u{fffc}" + completion + tail) - #expect(editor.markdownString == prefix + marker + completion + tail) - editor.breakUndoCoalescing() - editor.undoManager?.undo() - #expect(editor.string == prefix + marker + tail) - editor.undoManager?.redo() - #expect(editor.string == prefix + "\u{fffc}" + completion + tail) - editor.insertText("text", replacementRange: editor.selectedRange()) - #expect(editor.markdownString == prefix + marker + completion + "text" + tail) - // Existing Markdown still renders on loading or pasting, without a typing gate. - editor.setMarkdownContents(NSAttributedString(string: marker + tail)) - #expect(editor.string == "\u{fffc}" + tail) - editor.setMarkdownContents(NSAttributedString(string: tail)) - editor.setSelectedRange(NSRange(location: 0, length: 0)) - editor.insertText(NSAttributedString(string: marker + " pasted"), replacementRange: editor.selectedRange()) - #expect(editor.string == "\u{fffc} pasted" + tail) - #expect(editor.markdownString == marker + " pasted" + tail) - } - } -} - -@Test @MainActor -func notesFormattingPreservesAttachmentsMarkdownSelectionAndUndo() throws { +func notesToolbarFormattingPreservesAttachmentsSelectionAndUndo() throws { _ = NSApplication.shared let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let suite = "NotesFormatting.\(UUID().uuidString)" @@ -1499,66 +668,62 @@ func notesFormattingPreservesAttachmentsMarkdownSelectionAndUndo() throws { } 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) + 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 views = descendants(controller.view) let editor = try #require(views.compactMap { $0 as? NotesTextView }.first) func click(_ label: String) throws { - let button = try #require(views.compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == label }) - button.performClick(nil) + try #require(views.compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == label }).performClick(nil) } window.makeFirstResponder(editor) - let undo = try #require(editor.undoManager) - undo.groupsByEvent = false - undo.beginUndoGrouping() editor.insertText("🦊 first\nsecond\nuntouched", replacementRange: editor.selectedRange()) let selection = NSRange(location: 0, length: "🦊 first\nsecond\n".utf16.count) editor.setSelectedRange(selection) - try click("Bullet list") - #expect(editor.markdownString == "- 🦊 first\n- second\nuntouched") - try click("Ordered list") - #expect(editor.markdownString == "1. 🦊 first\n2. second\nuntouched") + for (label, block) in [("Bullet list", "bullet"), ("Ordered list", "ordered"), ("Checklist", "task")] { + try click(label) + #expect(editor.string == "🦊 first\nsecond\nuntouched") + #expect(editor.selectedRange() == selection) + #expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == block) + #expect(editor.documentContents.attribute(NoteAttributes.block, at: "🦊 first\n".utf16.count, effectiveRange: nil) as? String == block) + #expect(editor.documentContents.attribute(NoteAttributes.block, at: "🦊 first\nsecond\n".utf16.count, effectiveRange: nil) == nil) + } try click("Checklist") - #expect(editor.markdownString == "- [ ] 🦊 first\n- [ ] second\nuntouched") - try click("Checklist") - #expect(editor.markdownString == "🦊 first\nsecond\nuntouched") - let originalSelection = editor.selectedRange() + #expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) == nil) try click("Bold") - #expect(editor.markdownString == "**🦊 first\nsecond\n**untouched") + #expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true) try click("Bold") - #expect(editor.markdownString == "🦊 first\nsecond\nuntouched") - #expect(editor.selectedRange() == originalSelection) + #expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true) + #expect(editor.selectedRange() == selection) editor.setSelectedRange(NSRange(location: 3, length: 0)) try click("Code block") - #expect(editor.markdownString == "```\n🦊 first\n```\nsecond\nuntouched") + #expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "code") try click("Code block") - #expect(editor.markdownString == "🦊 first\nsecond\nuntouched") - editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) + #expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) == nil) let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) bitmap.setColor(.red, atX: 0, y: 0) - let file = FileWrapper(regularFileWithContents: try #require(bitmap.representation(using: .png, properties: [:]))) + let png = try #require(bitmap.representation(using: .png, properties: [:])) + let file = FileWrapper(regularFileWithContents: png) file.preferredFilename = "pixel.png" - let attachment = NSTextAttachment(fileWrapper: file) - editor.insertText(NSAttributedString(attachment: attachment), replacementRange: editor.selectedRange()) + editor.insertText(NSAttributedString(attachment: NSTextAttachment(fileWrapper: file)), + replacementRange: NSRange(location: editor.string.utf16.count, length: 0)) + let before = editor.string editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count)) - let before = editor.markdownString - editor.breakUndoCoalescing() - undo.endUndoGrouping() editor.undoManager?.removeAllActions() try click("Quote") - try #require(editor.markdownString == "> 🦊 first\n> second\n> untouched\u{fffc}") - #expect(editor.markdownContents.attribute(.attachment, at: editor.markdownContents.length - 1, effectiveRange: nil) is NSTextAttachment) + #expect(editor.string == before) + #expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "quote") editor.undoManager?.undo() - try #require(editor.markdownString == before) - #expect(editor.markdownContents.attribute(.attachment, at: editor.markdownContents.length - 1, effectiveRange: nil) is NSTextAttachment) + #expect(editor.string == before) + #expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) == nil) #expect(controller.save()) let saved = try #require(NotesStore(directory: directory).notes.first) #expect(saved.contents.string == before) - #expect(saved.contents.attribute(.attachment, at: saved.contents.length - 1, effectiveRange: nil) is NSTextAttachment) + let attachment = try #require(saved.contents.attribute(.attachment, at: saved.contents.length - 1, effectiveRange: nil) as? NSTextAttachment) + #expect(attachment.fileWrapper?.regularFileContents == png) } @Test @MainActor @@ -1609,7 +774,7 @@ func notesDeletionRequiresConfirmationPreservesFailuresAndReplacesTheLastNote() } try await delete(confirm: false) #expect(defaults.string(forKey: "selectedNote") == first.uuidString) - #expect(editor.markdownString == "Delete this note") + #expect(editor.string == "Delete this note") #expect(Set(try NotesStore(directory: directory).notes.map(\.id)) == Set([first, second])) // A failed save must not let confirmation discard an unsaved buffer. @@ -1618,7 +783,7 @@ func notesDeletionRequiresConfirmationPreservesFailuresAndReplacesTheLastNote() editor.insertText("Unsaved changes", replacementRange: NSRange(location: 0, length: editor.string.utf16.count)) try await delete(confirm: true) #expect(defaults.string(forKey: "selectedNote") == first.uuidString) - #expect(editor.markdownString == "Unsaved changes") + #expect(editor.string == "Unsaved changes") #expect(!controller.save()) try FileManager.default.removeItem(at: directory) try FileManager.default.moveItem(at: backup, to: directory) @@ -1627,17 +792,17 @@ func notesDeletionRequiresConfirmationPreservesFailuresAndReplacesTheLastNote() try await delete(confirm: true) #expect(try NotesStore(directory: directory).notes.map(\.id) == [second]) #expect(defaults.string(forKey: "selectedNote") == second.uuidString) - #expect(editor.markdownString == "Keep this note") + #expect(editor.string == "Keep this note") #expect(editor.undoManager?.canUndo != true) try await delete(confirm: true) let replacement = try #require(NotesStore(directory: directory).notes.first) #expect(replacement.id != first && replacement.id != second) #expect(replacement.contents.string.isEmpty) #expect(defaults.string(forKey: "selectedNote") == replacement.id.uuidString) - #expect(editor.markdownString.isEmpty) + #expect(editor.string.isEmpty) #expect(editor.isEditable) #expect(editor.undoManager?.canUndo != true) let reopened = NotesController(defaults: defaults, directory: directory) - #expect(descendants(reopened.view).compactMap { $0 as? NotesTextView }.first?.markdownString == "") + #expect(descendants(reopened.view).compactMap { $0 as? NotesTextView }.first?.string == "") #expect(defaults.string(forKey: "selectedNote") == replacement.id.uuidString) } diff --git a/Tests/ConjureKitTests/NotesHeadingTests.swift b/Tests/ConjureKitTests/NotesHeadingTests.swift index e7eff53..07b425d 100644 --- a/Tests/ConjureKitTests/NotesHeadingTests.swift +++ b/Tests/ConjureKitTests/NotesHeadingTests.swift @@ -4,7 +4,7 @@ import Testing @testable import Conjure @Test @MainActor -func headingsFollowVisibleParagraphEditingAndRoundTrip() throws { +func headingShortcutsConsumeSyntaxAndPersistAllSixLevels() throws { _ = NSApplication.shared let suite = "NotesHeadings.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) @@ -21,90 +21,60 @@ func headingsFollowVisibleParagraphEditingAndRoundTrip() throws { func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first) window.makeFirstResponder(editor) - func reset(_ source: String) { - editor.setMarkdownContents(NSAttributedString(string: source)) - MarkdownHighlighting.apply(to: editor) - editor.refreshTasks() - editor.breakUndoCoalescing() - editor.undoManager?.removeAllActions() - let title = (editor.string as NSString).range(of: "Title") - editor.setSelectedRange(NSRange(location: title.location, length: 0)) - } - for (before, after) in [ - ("## Title", "## Title"), - ("\n## Title", "## Title"), - ("Text\n## Title", "TextTitle"), - ("# Text\n## Title", "# TextTitle"), - ("Text\n\n## Title", "Text\n## Title") - ] { - reset(before) - editor.undoManager?.beginUndoGrouping() - editor.deleteBackward(nil) - editor.undoManager?.endUndoGrouping() - #expect(editor.markdownString == after) - if before != after { - editor.undoManager?.undo() - #expect(editor.markdownString == before) - editor.undoManager?.redo() - #expect(editor.markdownString == after) - } - } - for before in ["\n# Title", "\n## Title", "Text\n\n###### Title", "- [ ] 🦊\n\n## Title"] { - reset(before) - editor.undoManager?.beginUndoGrouping() - editor.doCommand(by: #selector(NSResponder.deleteBackward(_:))) - editor.undoManager?.endUndoGrouping() - let joined = editor.markdownString - editor.breakUndoCoalescing() - editor.undoManager?.removeAllActions() - editor.undoManager?.beginUndoGrouping() - editor.doCommand(by: #selector(NSResponder.insertNewline(_:))) - editor.undoManager?.endUndoGrouping() - #expect(editor.markdownString == before) - #expect(editor.selectedRange() == NSRange(location: (editor.string as NSString).range(of: "Title").location, length: 0)) - editor.undoManager?.undo() - #expect(editor.markdownString == joined) - editor.undoManager?.redo() - #expect(editor.markdownString == before) - #expect(controller.save()) - #expect(try NotesStore(directory: directory).notes.first?.contents.string == before) - } - for (before, position, after) in [ - ("## Title", 3, "\n## Title"), - ("## Title", 5, "## Ti\ntle"), - ("## Title", 8, "## Title\n"), - ("Text\n## Title", 4, "Text\n\n## Title"), - ("```text\n## Title\n```", 11, "```text\n## \nTitle\n```") - ] { - reset(before) - editor.setSelectedRange(NSRange(location: position, length: 0)) - editor.doCommand(by: #selector(NSResponder.insertNewline(_:))) - #expect(editor.markdownString == after) - } for level in 1...6 { - reset("### Title") - editor.undoManager?.beginUndoGrouping() + editor.setDocumentContents(NSAttributedString(string: "Title")) + editor.setSelectedRange(NSRange(location: 0, length: 0)) + editor.beginInsertionSession() for character in String(repeating: "#", count: level) + " " { editor.insertText(String(character), replacementRange: editor.selectedRange()) } - editor.undoManager?.endUndoGrouping() - let expected = String(repeating: "#", count: level) + " Title" - #expect(editor.markdownString == expected) - let title = (editor.string as NSString).range(of: "Title") - #expect(editor.selectedRange().location == title.location) - let sizes: [CGFloat] = [26, 23, 21, 19, 17, 15] - #expect((editor.textStorage?.attribute(.font, at: title.location, effectiveRange: nil) as? NSFont)?.pointSize == sizes[level - 1]) + editor.endInsertionSession() + #expect(editor.string == "Title") + #expect(editor.selectedRange() == NSRange(location: 0, length: 0)) + #expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "heading") + #expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level) editor.undoManager?.undo() - #expect(editor.markdownString == "### Title") + #expect(editor.string == "Title") + #expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) == nil) editor.undoManager?.redo() - #expect(editor.markdownString == expected) + #expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level) #expect(controller.save()) let reloaded = try #require(NotesStore(directory: directory).notes.first) - #expect(reloaded.contents.string == expected) + #expect(reloaded.contents.string == "Title") + #expect(reloaded.contents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level) } - reset("```text\n## Title\n```") - editor.insertText("# ", replacementRange: editor.selectedRange()) - #expect(editor.markdownString == "```text\n## # Title\n```") +} + +@Test @MainActor +func headingReturnSplitsVisibleParagraphAndUndoRestoresItsSemantics() throws { + _ = NSApplication.shared + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 500)) + editor.allowsUndo = true + let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false) + window.contentView = editor + window.makeFirstResponder(editor) + defer { window.orderOut(nil) } + for position in [2, 5] { + editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "## Title"))) + editor.setSelectedRange(NSRange(location: position, length: 0)) + editor.undoManager?.removeAllActions() + editor.insertNewline(nil) + #expect(editor.string == (position == 2 ? "Ti\ntle" : "Title\n")) + #expect(editor.selectedRange() == NSRange(location: position + 1, length: 0)) + #expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == 2) + editor.insertText("body", replacementRange: editor.selectedRange()) + #expect(editor.documentContents.attribute(NoteAttributes.block, at: position + 1, effectiveRange: nil) == nil) + editor.undoManager?.undo() + editor.undoManager?.undo() + #expect(editor.string == "Title") + #expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == 2) + } + editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "```text\n## Title\n```"))) + editor.setSelectedRange(NSRange(location: 0, length: 0)) + for character in "# " { editor.insertText(String(character), replacementRange: editor.selectedRange()) } + #expect(editor.string == "# ## Title") + #expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "code") + #expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) == nil) } @Test @MainActor @@ -116,7 +86,8 @@ func headingCommandsKeepCaretOnTheVisibleBodyLine() throws { window.makeFirstResponder(editor) defer { window.orderOut(nil) } for source in ["\n# Title", "Text\n\n## Title", "Text\n\n\n###### Title"] { - editor.setMarkdownContents(NSAttributedString(string: source)) + editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source))) + let before = editor.string editor.setSelectedRange(NSRange(location: (editor.string as NSString).range(of: "Title").location, length: 0)) for command in [#selector(NSResponder.deleteBackward(_:)), #selector(NSResponder.insertNewline(_:))] { editor.doCommand(by: command) @@ -128,6 +99,6 @@ func headingCommandsKeepCaretOnTheVisibleBodyLine() throws { #expect(caretRect.origin == titleRect.origin) #expect(caretRect.height == titleRect.height) } - #expect(editor.markdownString == source) + #expect(editor.string == before) } } diff --git a/Tests/ConjureKitTests/NotesMarkupTests.swift b/Tests/ConjureKitTests/NotesMarkupTests.swift index 89d7296..debab91 100644 --- a/Tests/ConjureKitTests/NotesMarkupTests.swift +++ b/Tests/ConjureKitTests/NotesMarkupTests.swift @@ -4,92 +4,98 @@ import Testing @testable import Conjure @Test @MainActor -func noteFormattingHidesSyntaxButPreservesMarkdownAndLiteralCode() throws { +func importedFormattingBecomesVisibleTextAndNativeSemanticMarks() throws { _ = NSApplication.shared let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 800)) - let source = "# Heading\n## Subheading\n*italic* **bold** _emphasis_ __strong__ ~~gone~~ underlined `*literal*`\n> Quote\n[Label](https://example.com)\n```text\n# Code *literal*\n```\nUnmatched * and \\*escaped*" - editor.setMarkdownContents(NSAttributedString(string: source)) - MarkdownHighlighting.apply(to: editor) - editor.refreshTasks() - let layout = try #require(editor.layoutManager) - let container = try #require(editor.textContainer) - layout.ensureLayout(for: container) - func visibleText() -> String { - var result = "" - let text = editor.string as NSString - for index in 0.. Any? { + editor.documentContents.attribute(key, at: (editor.string as NSString).range(of: token).location, effectiveRange: nil) } - #expect(visibleText() == "Heading\nSubheading\nitalic bold emphasis strong gone underlined *literal*\nQuote\nLabel\n```text\n# Code *literal*\n```\nUnmatched * and \\*escaped*") - #expect(editor.markdownString == source) - editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count)) - let pasteboard = NSPasteboard.withUniqueName() - defer { pasteboard.releaseGlobally() } - #expect(editor.writeSelection(to: pasteboard, types: editor.writablePasteboardTypes)) - #expect(pasteboard.string(forType: .string) == source) - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let store = try NotesStore(directory: directory) - let id = try store.create() - try store.update(id: id, contents: editor.markdownContents) - let reloaded = try #require(NotesStore(directory: directory).notes.first) - #expect(reloaded.contents.string == source) + #expect(attribute(NoteAttributes.headingLevel, in: "Heading") as? Int == 1) + #expect(attribute(NoteAttributes.headingLevel, in: "Subheading") as? Int == 2) + for token in ["italic", "emphasis"] { #expect(attribute(NoteAttributes.italic, in: token) as? Bool == true) } + for token in ["bold", "strong"] { #expect(attribute(NoteAttributes.bold, in: token) as? Bool == true) } + #expect(attribute(.strikethroughStyle, in: "gone") as? Int == NSUnderlineStyle.single.rawValue) + #expect(attribute(.underlineStyle, in: "underlined") as? Int == NSUnderlineStyle.single.rawValue) + #expect(attribute(NoteAttributes.inlineCode, in: "*literal*") as? Bool == true) + #expect(attribute(NoteAttributes.block, in: "Quote") as? String == "quote") + #expect(String(describing: try #require(attribute(.link, in: "Label"))) == "https://example.com") + #expect(attribute(NoteAttributes.block, in: "# Code") as? String == "code") + #expect(attribute(NoteAttributes.bold, in: "# Code") as? Bool != true) + #expect(attribute(NoteAttributes.headingLevel, in: "# Code") == nil) } -private final class NotesLayoutProbe: NSLayoutManager { - var observeFormatting: ((NSTextStorage) -> Void)? - - override func processEditing(for textStorage: NSTextStorage, edited editMask: NSTextStorageEditActions, - range newCharRange: NSRange, changeInLength delta: Int, - invalidatedRange invalidatedCharRange: NSRange) { - observeFormatting?(textStorage) - super.processEditing(for: textStorage, edited: editMask, range: newCharRange, - changeInLength: delta, invalidatedRange: invalidatedCharRange) +@Test @MainActor +func completedInlineShortcutsConsumeDelimitersAndKeepUndoableNativeMarks() throws { + _ = NSApplication.shared + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) + editor.allowsUndo = true + let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false) + window.contentView = editor + window.makeFirstResponder(editor) + defer { window.orderOut(nil) } + for (source, key) in [("**fox 🦊**", NoteAttributes.bold), ("*fox 🦊*", NoteAttributes.italic), + ("`fox 🦊`", NoteAttributes.inlineCode), ("~~fox 🦊~~", .strikethroughStyle), + ("fox 🦊", .underlineStyle), ("[fox 🦊](https://example.com)", .link)] { + editor.setDocumentContents(NSAttributedString(string: "")) + editor.setSelectedRange(NSRange(location: 0, length: 0)) + editor.beginInsertionSession() + for character in source { editor.insertText(String(character), replacementRange: editor.selectedRange()) } + editor.endInsertionSession() + #expect(editor.string == "fox 🦊") + #expect(editor.selectedRange() == NSRange(location: "fox 🦊".utf16.count, length: 0)) + let mark = try #require(editor.documentContents.attribute(key, at: 0, effectiveRange: nil)) + if key == .link { + #expect(String(describing: mark) == "https://example.com") + } else if key == .underlineStyle || key == .strikethroughStyle { + #expect(mark as? Int == NSUnderlineStyle.single.rawValue) + } else { + #expect(mark as? Bool == true) + } + editor.undoManager?.undo() + #expect(editor.string.isEmpty) + editor.undoManager?.redo() + #expect(editor.string == "fox 🦊") + #expect(editor.documentContents.attribute(key, at: 0, effectiveRange: nil) != nil) } } @Test @MainActor -func typingDoesNotExposeUnformattedExistingTextToLayout() throws { +func richCopyPasteKeepsNativeMarksUnicodeAndImageBytesWithoutMarkdownParsing() throws { _ = NSApplication.shared - let suite = "NotesFlashing.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite) - defer { - defaults.removePersistentDomain(forName: suite) - try? FileManager.default.removeItem(at: directory) - } - let store = try NotesStore(directory: directory) - let id = try store.create() - try store.update(id: id, contents: NSAttributedString(string: "# Heading\n- [ ] Task\nPlain")) - defaults.set(id.uuidString, forKey: "selectedNote") - let controller = NotesController(defaults: defaults, directory: directory) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 570, height: 404), - styleMask: .borderless, backing: .buffered, defer: false) - window.contentViewController = controller - defer { _ = controller.close(); window.orderOut(nil) } - func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } - let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first) - let layout = NotesLayoutProbe() - try #require(editor.textContainer).replaceLayoutManager(layout) - editor.refreshTasks() - window.makeFirstResponder(editor) - layout.observeFormatting = { storage in - let text = storage.string as NSString - let heading = text.range(of: "Heading") - let task = text.range(of: "Task") - #expect((storage.attribute(.font, at: heading.location, effectiveRange: nil) as? NSFont)?.pointSize == 26) - #expect((storage.attribute(.paragraphStyle, at: task.location, effectiveRange: nil) as? NSParagraphStyle)?.headIndent == 22) - #expect(storage.attribute(MarkdownHighlighting.hiddenMarkup, at: 0, effectiveRange: nil) as? Bool == true) - } - defer { layout.observeFormatting = nil } - editor.insertText("!", replacementRange: NSRange(location: editor.string.utf16.count, length: 0)) - #expect(editor.markdownString == "# Heading\n- [ ] Task\nPlain!") - #expect(try NotesStore(directory: directory).notes.first?.contents.string == editor.markdownString) + let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) + bitmap.setColor(.red, atX: 0, y: 0) + let png = try #require(bitmap.representation(using: .png, properties: [:])) + let file = FileWrapper(regularFileWithContents: png) + file.preferredFilename = "pixel.png" + let contents = NSMutableAttributedString(attributedString: + NoteDocument.importMarkdown(NSAttributedString(string: "- [x] **🦊 café** "))) + contents.append(NSAttributedString(attachment: NSTextAttachment(fileWrapper: file))) + let source = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) + source.setDocumentContents(contents) + source.setSelectedRange(NSRange(location: 0, length: source.string.utf16.count)) + let board = NSPasteboard.withUniqueName() + defer { board.releaseGlobally() } + #expect(source.writeSelection(to: board, types: source.writablePasteboardTypes)) + #expect(board.string(forType: .string) == source.string) + let destination = NotesTextView(frame: source.frame) + destination.allowsUndo = true + #expect(destination.readSelection(from: board)) + #expect(destination.string == source.string) + #expect(destination.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true) + #expect(destination.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "task") + #expect(destination.documentContents.attribute(NoteAttributes.checked, at: 0, effectiveRange: nil) as? Bool == true) + let image = try #require(destination.documentContents.attribute(.attachment, + at: destination.string.utf16.count - 1, effectiveRange: nil) as? NSTextAttachment) + #expect(image.fileWrapper?.regularFileContents == png) + destination.undoManager?.undo() + #expect(destination.string.isEmpty) + destination.undoManager?.redo() + #expect(destination.string == source.string) } @Test @MainActor @@ -112,7 +118,7 @@ func noteImagesRenderRoundedWithoutChangingSavedImageData() throws { #expect(editor.readSelection(from: pasteboard)) func checkRoundedDrawing() throws { - MarkdownHighlighting.apply(to: editor) + editor.applyDocumentStyle() let layout = try #require(editor.layoutManager) layout.ensureLayout(for: try #require(editor.textContainer)) let output = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 200, pixelsHigh: 120, @@ -146,60 +152,40 @@ func noteImagesRenderRoundedWithoutChangingSavedImageData() throws { defer { try? FileManager.default.removeItem(at: directory) } let store = try NotesStore(directory: directory) let id = try store.create() - try store.update(id: id, contents: editor.markdownContents) + try store.update(id: id, contents: editor.documentContents) let reloaded = try #require(NotesStore(directory: directory).notes.first) let attachment = try #require(reloaded.contents.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment) #expect(attachment.fileWrapper?.regularFileContents == png) - editor.setMarkdownContents(reloaded.contents) + editor.setDocumentContents(reloaded.contents) try checkRoundedDrawing() } @Test @MainActor -func hiddenNoteMarkupKeepsTypingDeletionFormattingAndUndoAtVisibleBoundaries() throws { +func nativeFormattingSurvivesVisibleBoundaryEditingAndUndo() throws { _ = NSApplication.shared - let suite = "NotesMarkup.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite) - defer { - defaults.removePersistentDomain(forName: suite) - try? FileManager.default.removeItem(at: directory) - } - let controller = NotesController(defaults: defaults, directory: directory) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 570, height: 404), - styleMask: .borderless, backing: .buffered, defer: false) - window.contentViewController = controller - defer { _ = controller.close(); window.orderOut(nil) } - func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } - let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first) + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) + editor.allowsUndo = true + let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false) + window.contentView = editor window.makeFirstResponder(editor) - editor.insertText("# Title\n*word*", replacementRange: NSRange(location: 0, length: 0)) - editor.setSelectedRange(NSRange(location: 0, length: 0)) - editor.insertText("A", replacementRange: editor.selectedRange()) - #expect(editor.markdownString == "# ATitle\n*word*") - editor.breakUndoCoalescing() - editor.undoManager?.removeAllActions() - editor.undoManager?.beginUndoGrouping() + defer { window.orderOut(nil) } + editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "# Title\n*word*"))) + editor.insertText("A", replacementRange: NSRange(location: 0, length: 0)) + #expect(editor.string == "ATitle\nword") + #expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == 1) editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) + editor.undoManager?.removeAllActions() editor.deleteBackward(nil) - editor.undoManager?.endUndoGrouping() - #expect(editor.markdownString == "# ATitle\n*wor*") + #expect(editor.string == "ATitle\nwor") + #expect(editor.documentContents.attribute(NoteAttributes.italic, at: "ATitle\n".utf16.count, effectiveRange: nil) as? Bool == true) editor.undoManager?.undo() - #expect(editor.markdownString == "# ATitle\n*word*") + #expect(editor.string == "ATitle\nword") let word = (editor.string as NSString).range(of: "word") - editor.setSelectedRange(NSRange(location: word.location, length: 0)) - editor.moveRight(nil) - #expect(editor.selectedRange().location == word.location + 1) - editor.setSelectedRange(NSRange(location: NSMaxRange(word), length: 0)) - editor.moveRight(nil) - #expect(editor.selectedRange().location == editor.string.utf16.count) - editor.setSelectedRange(NSRange(location: word.location, length: 0)) - let italic = try #require(descendants(controller.view).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Italic" }) - // Selecting the body and toggling formatting must remove its hidden delimiters. editor.setSelectedRange(word) - italic.performClick(nil) - #expect(editor.markdownString == "# ATitle\nword") - #expect(controller.save()) - #expect(try NotesStore(directory: directory).notes.first?.contents.string == "# ATitle\nword") + editor.format(2) + #expect(editor.string == "ATitle\nword") + #expect(editor.selectedRange() == word) + #expect(editor.documentContents.attribute(NoteAttributes.italic, at: word.location, effectiveRange: nil) as? Bool != true) } @Test @MainActor @@ -234,62 +220,51 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws { } editor.insertText("fox 🦊", replacementRange: editor.selectedRange()) editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count)) + let selection = editor.selectedRange() #expect(try shortcut("b")) - #expect(editor.markdownString == "**fox 🦊**") + #expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true) #expect(try shortcut("i")) - #expect(editor.markdownString == "***fox 🦊***") - let font = try #require(editor.textStorage?.attribute(.font, at: editor.selectedRange().location, - effectiveRange: nil) as? NSFont) + #expect(editor.documentContents.attribute(NoteAttributes.italic, at: 0, effectiveRange: nil) as? Bool == true) + let font = try #require(editor.textStorage?.attribute(.font, at: 0, effectiveRange: nil) as? NSFont) let traits = NSFontManager.shared.traits(of: font) #expect(traits.contains(.boldFontMask) && traits.contains(.italicFontMask)) #expect(try shortcut("i")) - #expect(editor.markdownString == "**fox 🦊**") #expect(try shortcut("b")) - #expect(editor.markdownString == "fox 🦊") - + #expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true) + #expect(editor.documentContents.attribute(NoteAttributes.italic, at: 0, effectiveRange: nil) as? Bool != true) #expect(try shortcut("s", shift: true)) - #expect(editor.markdownString == "~~fox 🦊~~") - #expect(editor.textStorage?.attribute(.strikethroughStyle, at: editor.selectedRange().location, - effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue) + #expect(editor.documentContents.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue) #expect(try shortcut("s", shift: true)) - #expect(editor.markdownString == "fox 🦊") - editor.breakUndoCoalescing() + #expect(editor.documentContents.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) == nil) editor.undoManager?.removeAllActions() - editor.undoManager?.beginUndoGrouping() #expect(try shortcut("u", shift: true)) - editor.undoManager?.endUndoGrouping() - #expect(editor.markdownString == "fox 🦊") - #expect(editor.textStorage?.attribute(.underlineStyle, at: editor.selectedRange().location, - effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue) + #expect(editor.documentContents.attribute(.underlineStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue) + #expect(editor.string == "fox 🦊") + #expect(editor.selectedRange() == selection) #expect(controller.save()) - #expect(try NotesStore(directory: directory).notes.first?.contents.string == "fox 🦊") - #expect(try NoteMarkdownExport(contents: editor.markdownContents).clipboardText == "fox 🦊") + let saved = try #require(NotesStore(directory: directory).notes.first) + #expect(saved.contents.string == "fox 🦊") + #expect(saved.contents.attribute(.underlineStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue) + #expect(try NoteMarkdownExport(contents: editor.documentContents).clipboardText == "fox 🦊") editor.undoManager?.undo() - #expect(editor.markdownString == "fox 🦊") - #expect(try shortcut("u", shift: true)) - #expect(try shortcut("u", shift: true)) - #expect(editor.markdownString == "fox 🦊") - #expect(editor.textStorage?.attribute(.underlineStyle, at: 0, effectiveRange: nil) == nil) + #expect(editor.documentContents.attribute(.underlineStyle, at: 0, effectiveRange: nil) == nil) #expect(try shortcut("l", shift: true)) - #expect(editor.markdownString == "- fox 🦊") + #expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "bullet") #expect(try shortcut("l", shift: true)) - #expect(editor.markdownString == "fox 🦊") + #expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) == nil) #expect(!(try shortcut("b", option: true))) - #expect(editor.markdownString == "fox 🦊") - - editor.breakUndoCoalescing() + #expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true) editor.undoManager?.removeAllActions() - editor.undoManager?.beginUndoGrouping() #expect(try shortcut("e")) - editor.undoManager?.endUndoGrouping() - #expect(editor.markdownString == "`fox 🦊`") + #expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: 0, effectiveRange: nil) as? Bool == true) editor.undoManager?.undo() - #expect(editor.markdownString == "fox 🦊") + #expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: 0, effectiveRange: nil) as? Bool != true) #expect(try shortcut("k")) let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == "Search notes actions" }) #expect(window.firstResponder !== editor) _ = try shortcut("b") - #expect(editor.markdownString == "fox 🦊") + #expect(editor.string == "fox 🦊") + #expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true) #expect(search.stringValue.isEmpty) #expect(controller.handle(#selector(NSResponder.cancelOperation(_:)))) #expect(controller.save()) @@ -297,30 +272,28 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws { } @Test @MainActor -func inlineCodeContainerStaysWithinCodeAndDisappearsWhenDelimitersAreRemoved() throws { +func inlineCodeContainerIsLimitedToItsMarkAndDisappearsWhenToggledOff() throws { _ = NSApplication.shared let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 300)) let source = "Before `some *literal* code 🦊` after\n```text\n`not inline` literal\n```" - editor.setMarkdownContents(NSAttributedString(string: source)) + editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source))) let storage = try #require(editor.textStorage) - let layout = try #require(editor.layoutManager) - layout.ensureLayout(for: try #require(editor.textContainer)) - let body = (source as NSString).range(of: "some *literal* code 🦊") + let body = (editor.string as NSString).range(of: "some *literal* code 🦊") for index in body.location..literal
") + let literalUnderline = (editor.string as NSString).range(of: "literal") #expect(storage.attribute(.underlineStyle, at: literalUnderline.location, effectiveRange: nil) == nil) - #expect(editor.markdownString == source) - #expect(editor.markdownContents.attribute(.backgroundColor, at: body.location, effectiveRange: nil) == nil) - editor.replaceMarkdown("some *literal* code 🦊", - range: NSRange(location: body.location - 1, length: body.length + 2)) - let plain = (editor.string as NSString).range(of: "some") - #expect(storage.attribute(.backgroundColor, at: plain.location, effectiveRange: nil) == nil) - #expect(storage.attribute(.foregroundColor, at: plain.location, effectiveRange: nil) as? NSColor == SunsetPalette.text) + let before = editor.string + editor.setSelectedRange(body) + editor.format(4) + #expect(editor.string == before) + #expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: body.location, effectiveRange: nil) as? Bool != true) + #expect(storage.attribute(.backgroundColor, at: body.location, effectiveRange: nil) == nil) + #expect(storage.attribute(.foregroundColor, at: body.location, effectiveRange: nil) as? NSColor == SunsetPalette.text) } diff --git a/Tests/ConjureKitTests/NotesStoreTests.swift b/Tests/ConjureKitTests/NotesStoreTests.swift index cca6dcc..59ff130 100644 --- a/Tests/ConjureKitTests/NotesStoreTests.swift +++ b/Tests/ConjureKitTests/NotesStoreTests.swift @@ -22,30 +22,30 @@ func notesRoundTripIndependentMarkdownUnicodeAndImageContents() throws { let imageFile = FileWrapper(regularFileWithContents: png) imageFile.preferredFilename = "pixel.png" let attachment = NSTextAttachment(fileWrapper: imageFile) - let first = NSMutableAttributedString(string: "\n # 日本語 🦊 \n**bold** and `code`\n") + let first = NSMutableAttributedString(attributedString: NoteDocument.importMarkdown(NSAttributedString(string: "\n # 日本語 🦊 \n**bold** and `code`\n"))) first.append(NSAttributedString(attachment: attachment)) let expectedFirstText = first.string try store.update(id: firstID, contents: first) first.mutableString.setString("Mutated caller buffer") #expect(store.notes.first { $0.id == firstID }?.contents.string == expectedFirstText) - #expect(store.notes.first { $0.id == firstID }?.title == "# 日本語 🦊") + #expect(store.notes.first { $0.id == firstID }?.title == "日本語 🦊") try store.update(id: secondID, contents: NSAttributedString(string: "Second note")) - try store.update(id: secondID, contents: NSAttributedString(string: "## Edited\n- café\n- Ελληνικά")) + try store.update(id: secondID, contents: NoteDocument.importMarkdown(NSAttributedString(string: "## Edited\n- café\n- Ελληνικά"))) #expect(store.notes.first { $0.id == firstID }?.contents.string == expectedFirstText) let reloaded = try NotesStore(directory: directory) #expect(Set(reloaded.notes.map(\.id)) == Set([firstID, secondID])) let restoredFirst = try #require(reloaded.notes.first { $0.id == firstID }) #expect(restoredFirst.contents.string == expectedFirstText) - #expect(restoredFirst.title == "# 日本語 🦊") + #expect(restoredFirst.title == "日本語 🦊") let restoredAttachment = try #require(restoredFirst.contents.attribute( .attachment, at: restoredFirst.contents.length - 1, effectiveRange: nil ) as? NSTextAttachment) let restoredPNG = try #require(restoredAttachment.fileWrapper?.regularFileContents) #expect(restoredPNG == png) #expect(NSImage(data: restoredPNG) != nil) - #expect(reloaded.notes.first { $0.id == secondID }?.contents.string == "## Edited\n- café\n- Ελληνικά") + #expect(reloaded.notes.first { $0.id == secondID }?.contents.string == "Edited\ncafé\nΕλληνικά") } @Test @MainActor @@ -131,3 +131,85 @@ func deletingNotesPersistsWithoutChangingOtherNotesAndPreservesMemoryOnFailure() #expect(store.notes.isEmpty) #expect(try NotesStore(directory: directory).notes.isEmpty) } + +@Test @MainActor +func legacyNotesConvertWithoutWritingAndBackUpOriginalPackageOnlyOnFirstSave() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let id = UUID() + let url = directory.appendingPathComponent(id.uuidString + ".rtfd") + let legacy = NSMutableAttributedString(string: "# Heading\n- [ ] task\n**bold** and `code`\n") + let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) + bitmap.setColor(.blue, atX: 0, y: 0) + let png = try #require(bitmap.representation(using: .png, properties: [:])) + let image = FileWrapper(regularFileWithContents: png) + image.preferredFilename = "legacy.png" + legacy.append(NSAttributedString(attachment: NSTextAttachment(fileWrapper: image))) + let wrapper = try legacy.fileWrapper(from: NSRange(location: 0, length: legacy.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]) + try wrapper.write(to: url, options: .atomic, originalContentsURL: nil) + let originalRTF = try Data(contentsOf: url.appendingPathComponent("TXT.rtf")) + let store = try NotesStore(directory: directory) + let note = try #require(store.notes.first) + #expect(note.contents.string == "Heading\ntask\nbold and code\n\u{fffc}") + #expect(note.title == "Heading") + #expect(try Data(contentsOf: url.appendingPathComponent("TXT.rtf")) == originalRTF) + #expect(!FileManager.default.fileExists(atPath: url.appendingPathComponent("ConjureNote.json").path)) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent("LegacyBackups").path)) + + try store.update(id: id, contents: note.contents) + let backup = directory.appendingPathComponent("LegacyBackups").appendingPathComponent(id.uuidString + ".rtfd") + #expect(try Data(contentsOf: backup.appendingPathComponent("TXT.rtf")) == originalRTF) + #expect(try Data(contentsOf: backup.appendingPathComponent("legacy.png")) == png) + #expect(!FileManager.default.fileExists(atPath: backup.appendingPathComponent("ConjureNote.json").path)) + #expect(FileManager.default.fileExists(atPath: url.appendingPathComponent("ConjureNote.json").path)) + let reloaded = try NotesStore(directory: directory) + #expect(reloaded.notes.first?.contents.string == note.contents.string) + let restored = try #require(reloaded.notes.first?.contents) + let attachment = try #require(restored.attribute(.attachment, at: restored.length - 1, effectiveRange: nil) as? NSTextAttachment) + #expect(attachment.fileWrapper?.regularFileContents == png) + try reloaded.update(id: id, contents: NSAttributedString(string: "Changed")) + #expect(try Data(contentsOf: backup.appendingPathComponent("TXT.rtf")) == originalRTF) + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.appendingPathComponent("LegacyBackups").path) == [id.uuidString + ".rtfd"]) +} + +@Test @MainActor +func failedLegacyBackupBlocksMigrationAndPreservesMemoryAndOriginalSource() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let id = UUID() + let url = directory.appendingPathComponent(id.uuidString + ".rtfd") + let source = NSAttributedString(string: "## Original") + let wrapper = try source.fileWrapper(from: NSRange(location: 0, length: source.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]) + try wrapper.write(to: url, options: .atomic, originalContentsURL: nil) + let original = try Data(contentsOf: url.appendingPathComponent("TXT.rtf")) + let store = try NotesStore(directory: directory) + let blocker = directory.appendingPathComponent("LegacyBackups") + try Data("not a directory".utf8).write(to: blocker) + #expect(throws: (any Error).self) { try store.update(id: id, contents: NSAttributedString(string: "Unsaved")) } + #expect(store.notes.first?.contents.string == "Original") + #expect(try Data(contentsOf: url.appendingPathComponent("TXT.rtf")) == original) + #expect(!FileManager.default.fileExists(atPath: url.appendingPathComponent("ConjureNote.json").path)) + try FileManager.default.removeItem(at: blocker) + try store.update(id: id, contents: NSAttributedString(string: "Saved")) + #expect(try NotesStore(directory: directory).notes.first?.contents.string == "Saved") +} + +@Test @MainActor +func corruptStructuredMetadataIsNeverReinterpretedAsLegacyMarkdown() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = try NotesStore(directory: directory) + let id = try store.create() + try store.update(id: id, contents: NSAttributedString(string: "# literal")) + let package = directory.appendingPathComponent(id.uuidString + ".rtfd") + let metadata = package.appendingPathComponent("ConjureNote.json") + let corrupt = Data("invalid json".utf8) + try corrupt.write(to: metadata) + let originalRTF = try Data(contentsOf: package.appendingPathComponent("TXT.rtf")) + #expect(throws: NoteDocument.DocumentError.self) { try NotesStore(directory: directory) } + #expect(try Data(contentsOf: metadata) == corrupt) + #expect(try Data(contentsOf: package.appendingPathComponent("TXT.rtf")) == originalRTF) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent("LegacyBackups").path)) +} diff --git a/Tests/ConjureKitTests/NotesVimTests.swift b/Tests/ConjureKitTests/NotesVimTests.swift index 80e5220..e2972d8 100644 --- a/Tests/ConjureKitTests/NotesVimTests.swift +++ b/Tests/ConjureKitTests/NotesVimTests.swift @@ -16,6 +16,12 @@ private func vimKeys(_ keys: String, editor: NotesTextView) throws { for character in keys { try vimKey(String(character), editor: editor) } } +@MainActor +private func vimEventTurn() { + // Let AppKit close its ordinary event-scoped undo groups between key events. + CFRunLoopRunInMode(.defaultMode, 0.001, false) +} + @Test(arguments: [ ("one two three", "dw", "two three"), ("one,two three", "dw", ",two three"), @@ -52,11 +58,11 @@ private func vimKeys(_ keys: String, editor: NotesTextView) throws { @MainActor func vimOperatorsRespectVisibleTextAndBoundaries(_ scenario: (String, String, String)) throws { _ = NSApplication.shared let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) - editor.setMarkdownContents(NSAttributedString(string: scenario.0)) + editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: scenario.0))) editor.vimEnabled = true editor.setSelectedRange(NSRange(location: 0, length: 0)) try vimKeys(scenario.1, editor: editor) - #expect(editor.markdownString == scenario.2) + #expect(editor.documentContents.string == scenario.2) #expect(editor.vimMode == (scenario.1.contains("c") ? .insert : .normal)) } @@ -64,25 +70,29 @@ private func vimKeys(_ keys: String, editor: NotesTextView) throws { func vimMotionsCountCharactersWordsAndLogicalLines() throws { _ = NSApplication.shared let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 90, height: 400)) - editor.setMarkdownContents(NSAttributedString(string: "## 🧑🏽‍💻é abc,def\nx\n long final line")) + let contents = NSMutableAttributedString(string: "🧑🏽‍💻é abc,def\nx\n long final line") + contents.addAttributes([NoteAttributes.block: "heading", NoteAttributes.headingLevel: 2], + range: (contents.string as NSString).paragraphRange(for: NSRange(location: 0, length: 0))) + editor.setDocumentContents(contents) editor.vimEnabled = true - editor.setSelectedRange(NSRange(location: 3, length: 0)) + editor.setSelectedRange(NSRange(location: 0, length: 0)) try vimKeys("l", editor: editor) - #expect(editor.selectedRange().location == 10) // One composed emoji, not seven UTF-16 units. + #expect(editor.selectedRange().location == 7) // One composed emoji, not seven UTF-16 units. try vimKeys("l", editor: editor) - #expect(editor.selectedRange().location == 12) // Combining accent remains a single character. + #expect(editor.selectedRange().location == 9) // Combining accent remains a single character. try vimKeys("w", editor: editor) - #expect(editor.selectedRange().location == 13) + #expect(editor.selectedRange().location == 10) try vimKeys("e", editor: editor) - #expect(editor.selectedRange().location == 15) + #expect(editor.selectedRange().location == 12) try vimKeys("w", editor: editor) - #expect(editor.selectedRange().location == 16) - try vimKeys("b", editor: editor) #expect(editor.selectedRange().location == 13) + try vimKeys("b", editor: editor) + #expect(editor.selectedRange().location == 10) try vimKeys("0", editor: editor) - #expect(editor.selectedRange().location == 3) + #expect(editor.selectedRange().location == 0) try vimKeys("4ljj", editor: editor) - let final = (editor.string as NSString).range(of: " long final line").location + let finalRange = try #require(editor.string.range(of: " long final line")) + let final = NSRange(finalRange, in: editor.string).location #expect(editor.selectedRange().location == final + 4) // Column survives a shorter line. try vimKeys("^", editor: editor) #expect(editor.selectedRange().location == final + 2) @@ -91,13 +101,13 @@ func vimMotionsCountCharactersWordsAndLogicalLines() throws { try vimKeys("G$", editor: editor) #expect(editor.selectedRange().location == editor.string.utf16.count - 1) try vimKeys("gg0", editor: editor) - #expect(editor.selectedRange().location == 3) - editor.setMarkdownContents(NSAttributedString(string: "one two")) + #expect(editor.selectedRange().location == 0) + editor.setDocumentContents(NSAttributedString(string: "one two")) editor.setSelectedRange(NSRange(location: 0, length: 3)) try vimKeys("d", editor: editor) - #expect(editor.markdownString == " two") // Native mouse selections also accept Visual operators. + #expect(editor.documentContents.string == " two") // Native mouse selections also accept Visual operators. try vimKey("ø", editor: editor, flags: .option) - #expect(editor.markdownString == " two") // Modified text cannot leak into Normal mode. + #expect(editor.documentContents.string == " two") // Modified text cannot leak into Normal mode. } @Test @MainActor @@ -111,32 +121,37 @@ func vimYankPasteAndUndoPreserveRichAttachments() throws { editor.isRichText = true editor.allowsUndo = true let attachment = NSTextAttachment() - let source = NSMutableAttributedString(string: "## Image ") + let source = NSMutableAttributedString(string: "Image ") source.append(NSAttributedString(attachment: attachment)) source.append(NSAttributedString(string: "\nTail")) - editor.setMarkdownContents(source) + source.addAttributes([NoteAttributes.block: "heading", NoteAttributes.headingLevel: 2], range: NSRange(location: 0, length: 8)) + source.addAttributes([NoteAttributes.bold: true, .underlineStyle: NSUnderlineStyle.single.rawValue], range: NSRange(location: 0, length: 5)) + editor.setDocumentContents(source) editor.vimEnabled = true - editor.setSelectedRange(NSRange(location: 3, length: 0)) + editor.setSelectedRange(NSRange(location: 0, length: 0)) try vimKeys("yyp", editor: editor) - #expect(editor.markdownString == "## Image \u{fffc}\n## Image \u{fffc}\nTail") - let pasted = editor.markdownContents - #expect(pasted.attribute(.attachment, at: 20, effectiveRange: nil) as? NSTextAttachment === attachment) + #expect(editor.documentContents.string == "Image \u{fffc}\nImage \u{fffc}\nTail") + let pasted = editor.documentContents + #expect(pasted.attribute(.attachment, at: 14, effectiveRange: nil) as? NSTextAttachment === attachment) + #expect(pasted.attribute(NoteAttributes.headingLevel, at: 8, effectiveRange: nil) as? Int == 2) + #expect(pasted.attribute(NoteAttributes.bold, at: 8, effectiveRange: nil) as? Bool == true) + #expect(pasted.attribute(.underlineStyle, at: 8, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue) try vimKeys("u", editor: editor) - #expect(editor.markdownString == source.string) + #expect(editor.documentContents.string == source.string) try vimKey("r", editor: editor, flags: .control) - #expect(editor.markdownString == pasted.string) - editor.setMarkdownContents(NSAttributedString(string: "one two")) + #expect(editor.documentContents.string == pasted.string) + editor.setDocumentContents(NSAttributedString(string: "one two")) editor.undoManager?.removeAllActions() editor.setSelectedRange(NSRange(location: 0, length: 0)) try vimKeys("ywP", editor: editor) - #expect(editor.markdownString == "one one two") + #expect(editor.documentContents.string == "one one two") try vimKeys("u", editor: editor) - #expect(editor.markdownString == "one two") + #expect(editor.documentContents.string == "one two") editor.setSelectedRange(NSRange(location: 0, length: 0)) try vimKeys("cw", editor: editor) try vimKeys("new", editor: editor) try vimKey("\u{1b}", editor: editor, code: 53) - #expect(editor.markdownString == "new two") + #expect(editor.documentContents.string == "new two") #expect(editor.vimMode == .normal) #expect(editor.selectedRange().location == 2) } @@ -154,15 +169,120 @@ func vimInsertEntryAndLinewisePasteKeepParagraphsSeparate() throws { ("one", "o", "one\n"), ("one", "O", "\none") ] { - editor.setMarkdownContents(NSAttributedString(string: before)) + editor.setDocumentContents(NSAttributedString(string: before)) editor.vimEnabled = true editor.setSelectedRange(NSRange(location: 0, length: 0)) try vimKeys(keys, editor: editor) - #expect(editor.markdownString == after) + #expect(editor.documentContents.string == after) if keys == "O" { #expect(editor.selectedRange().location == 0) } } } +@Test @MainActor +func vimDeletingFinalBoldCharacterOnlyDeletesThatCharacter() throws { + _ = NSApplication.shared + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) + let contents = NSMutableAttributedString(string: "ab tail") + contents.addAttribute(NoteAttributes.bold, value: true, range: NSRange(location: 0, length: 2)) + editor.setDocumentContents(contents) + editor.vimEnabled = true + editor.setSelectedRange(NSRange(location: 1, length: 0)) + try vimKeys("x", editor: editor) + #expect(editor.documentContents.string == "a tail") + #expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true) + #expect(editor.vimState.register?.string == "b") + #expect(editor.vimState.register?.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true) + try vimKeys("P", editor: editor) + #expect(editor.documentContents.string == "ab tail") + #expect(editor.documentContents.attribute(NoteAttributes.bold, at: 1, effectiveRange: nil) as? Bool == true) +} + +@Test @MainActor +func vimCharacterRegisterPreservesNativeImageAttributes() throws { + _ = NSApplication.shared + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) + let attachment = NSTextAttachment() + let imageBytes = Data([0, 1, 2, 3]) + attachment.fileWrapper = FileWrapper(regularFileWithContents: imageBytes) + let contents = NSMutableAttributedString(attributedString: NSAttributedString(attachment: attachment)) + contents.append(NSAttributedString(string: " tail")) + editor.setDocumentContents(contents) + editor.vimEnabled = true + editor.setSelectedRange(NSRange(location: 0, length: 0)) + try vimKeys("ylp", editor: editor) + #expect(editor.documentContents.string == "\u{fffc}\u{fffc} tail") + let pasted = try #require(editor.documentContents.attribute(.attachment, at: 1, effectiveRange: nil) as? NSTextAttachment) + #expect(pasted.fileWrapper?.regularFileContents == imageBytes) +} + +@Test(arguments: ["escape", "focus", "toggle", "reset"]) +@MainActor func vimChangeAndTypingUndoTogetherAcrossEvents(_ ending: String) throws { + _ = NSApplication.shared + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) + let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false) + window.contentView = editor + window.makeFirstResponder(editor) + defer { window.orderOut(nil) } + editor.allowsUndo = true + let original = NSMutableAttributedString(string: "one two") + original.addAttribute(NoteAttributes.bold, value: true, range: NSRange(location: 0, length: 3)) + editor.setDocumentContents(original) + editor.vimEnabled = true + editor.setSelectedRange(NSRange(location: 0, length: 0)) + let undo = try #require(editor.undoManager) + undo.removeAllActions() + try vimKeys("cw", editor: editor) + vimEventTurn() + for character in "new" { + try vimKey(String(character), editor: editor) + vimEventTurn() + } + #expect(editor.documentContents.string == "new two") + switch ending { + case "escape": try vimKey("\u{1b}", editor: editor, code: 53) + case "focus": #expect(window.makeFirstResponder(nil)) + case "toggle": editor.vimEnabled = false + default: editor.resetVim() + } + vimEventTurn() + #expect(undo.canUndo) + undo.undo() + #expect(editor.documentContents.string == original.string) + #expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true) + #expect(!undo.canUndo) + undo.redo() + #expect(editor.documentContents.string == "new two") +} + +@Test @MainActor +func vimSeparateInsertionSessionsHaveSeparateUndoOperations() throws { + _ = NSApplication.shared + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) + let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false) + window.contentView = editor + window.makeFirstResponder(editor) + defer { window.orderOut(nil) } + editor.allowsUndo = true + editor.setDocumentContents(NSAttributedString(string: "tail")) + editor.vimEnabled = true + editor.setSelectedRange(NSRange(location: 0, length: 0)) + let undo = try #require(editor.undoManager) + undo.removeAllActions() + try vimKeys("ione ", editor: editor) + vimEventTurn() + #expect(editor.documentContents.string == "one tail") + #expect(editor.vimMode == .insert) + try vimKey("\u{1b}", editor: editor, code: 53) + try vimKeys("0itwo ", editor: editor) + vimEventTurn() + try vimKey("\u{1b}", editor: editor, code: 53) + #expect(editor.documentContents.string == "two one tail") + try vimKeys("u", editor: editor) + #expect(editor.documentContents.string == "one tail") + try vimKeys("u", editor: editor) + #expect(editor.documentContents.string == "tail") +} + @Test @MainActor func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws { _ = NSApplication.shared @@ -185,7 +305,7 @@ func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws { let toggle = try #require(descendants(content).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Toggle Vim editing" }) #expect(!editor.vimEnabled) try vimKeys("hello", editor: editor) - #expect(editor.markdownString == "hello") + #expect(editor.documentContents.string == "hello") toggle.performClick(nil) #expect(defaults.bool(forKey: "notesVimEnabled")) #expect(editor.vimMode == .normal) @@ -204,9 +324,9 @@ func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws { window.sendEvent(escape) #expect(window.firstResponder === editor) #expect(window.isVisible) - let beforeMotion = editor.markdownString + let beforeMotion = editor.documentContents.string try vimKeys("w", editor: editor) - #expect(editor.markdownString == beforeMotion) + #expect(editor.documentContents.string == beforeMotion) try vimKeys("d", editor: editor) window.sendEvent(escape) #expect(!window.isVisible) @@ -215,9 +335,9 @@ func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws { let reopened = try #require(descendants(reopenedContent).compactMap { $0 as? NotesTextView }.first) #expect(reopened.vimEnabled) #expect(reopened.vimMode == .normal) - let beforeReopenedMotion = reopened.markdownString + let beforeReopenedMotion = reopened.documentContents.string try vimKeys("w", editor: reopened) - #expect(reopened.markdownString == beforeReopenedMotion) + #expect(reopened.documentContents.string == beforeReopenedMotion) let newToggle = try #require(descendants(reopenedContent).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Toggle Vim editing" }) newToggle.performClick(nil) #expect(!defaults.bool(forKey: "notesVimEnabled")) diff --git a/Tests/ConjureKitTests/NumberBaselineTests.swift b/Tests/ConjureKitTests/NumberBaselineTests.swift index 5f6b8c5..9743281 100644 --- a/Tests/ConjureKitTests/NumberBaselineTests.swift +++ b/Tests/ConjureKitTests/NumberBaselineTests.swift @@ -1,41 +1,44 @@ import AppKit +import ConjureKit import Testing @testable import Conjure @Test @MainActor func numberedMarkersShareTheBodyTextBaseline() throws { _ = NSApplication.shared let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) - for source in ["1. 1", "123) 123", " 12. 12"] { - editor.setMarkdownContents(NSAttributedString(string: source)) - MarkdownHighlighting.apply(to: editor) - editor.refreshTasks() + editor.drawsBackground = false + let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false) + window.contentView = editor + defer { window.orderOut(nil) } + for source in ["1. 1", "123. 123", " 12. 12"] { + editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source))) let layout = try #require(editor.layoutManager) let container = try #require(editor.textContainer) layout.ensureLayout(for: container) - let text = editor.string as NSString - let marker = text.range(of: "\u{fffc}") - let body = NSRange(location: NSMaxRange(marker) + 1, length: text.length - NSMaxRange(marker) - 1) - var bottoms: [Int] = [] - for range in [marker, body] { - let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 400, pixelsHigh: 80, - bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, - colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) - let bitmapContext = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) - bitmapContext.cgContext.translateBy(x: 0, y: 80) - bitmapContext.cgContext.scaleBy(x: 1, y: -1) - let context = NSGraphicsContext(cgContext: bitmapContext.cgContext, flipped: true) - NSGraphicsContext.saveGraphicsState() - NSGraphicsContext.current = context - context.cgContext.clear(CGRect(x: 0, y: 0, width: 400, height: 80)) - layout.drawGlyphs(forGlyphRange: layout.glyphRange(forCharacterRange: range, actualCharacterRange: nil), at: .zero) - NSGraphicsContext.restoreGraphicsState() - var bottom = -1 - for y in 0..<80 { - for x in 0..<400 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.2 { bottom = y } + let glyph = layout.glyphIndexForCharacter(at: 0) + let fragment = layout.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil) + let bodyX = editor.textContainerOrigin.x + fragment.minX + layout.location(forGlyphAt: glyph).x + let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 400, pixelsHigh: 200, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) + let bitmapContext = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) + bitmapContext.cgContext.translateBy(x: 0, y: 200) + bitmapContext.cgContext.scaleBy(x: 1, y: -1) + let context = NSGraphicsContext(cgContext: bitmapContext.cgContext, flipped: true) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + context.cgContext.clear(CGRect(x: 0, y: 0, width: 400, height: 200)) + editor.draw(editor.bounds) + NSGraphicsContext.restoreGraphicsState() + var markerBottom = -1, bodyBottom = -1 + for y in 0..<200 { + for x in 0..<400 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.2 { + if CGFloat(x) < bodyX - 1 { markerBottom = y } + else { bodyBottom = y } } - try #require(bottom >= 0) - bottoms.append(bottom) } - #expect(abs(bottoms[0] - bottoms[1]) <= 1, "Marker/body ink baseline for \(source)") + try #require(markerBottom >= 0 && bodyBottom >= 0) + #expect(abs(markerBottom - bodyBottom) <= 1, "Marker/body ink baseline for \(source)") + #expect(!editor.string.contains("\u{fffc}")) } }