refactor: use structured rich text for Notes editing
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@Test @MainActor func fencedCodeHighlightsTokensWithoutChangingSourceOrApplyingMarkdown() throws {
|
||||
@Test @MainActor
|
||||
func codeBlocksHighlightTokensWithoutInterpretingTheirLiteralMarkdown() throws {
|
||||
_ = NSApplication.shared
|
||||
for fence in ["```", "~~~~"] {
|
||||
let text = "# Heading 🦊\n\(fence)py\ndef main():\n # comment\n return \"**not bold**\"\n\(fence)\nAfter\n```unknown-language\n# literal\n```"
|
||||
let editor = NSTextView()
|
||||
editor.string = text
|
||||
MarkdownHighlighting.apply(to: editor)
|
||||
let source = "# Heading 🦊\n\(fence)py\ndef main():\n # comment\n return \"**not bold**\"\n\(fence)\nAfter\n```unknown-language\n# literal\n```"
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 500))
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
|
||||
let storage = try #require(editor.textStorage)
|
||||
func attributes(_ token: String) -> [NSAttributedString.Key: Any] {
|
||||
storage.attributes(at: (text as NSString).range(of: token).location, effectiveRange: nil)
|
||||
storage.attributes(at: (editor.string as NSString).range(of: token).location, effectiveRange: nil)
|
||||
}
|
||||
let keyword = try #require(attributes("def")[.foregroundColor] as? NSColor)
|
||||
let name = try #require(attributes("main")[.foregroundColor] as? NSColor)
|
||||
@@ -19,20 +20,23 @@ import Testing
|
||||
#expect(keyword != name)
|
||||
#expect(comment != keyword)
|
||||
#expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true)
|
||||
#expect(storage.string == text)
|
||||
editor.string = "```python\ndef main():\n pass"
|
||||
MarkdownHighlighting.apply(to: editor)
|
||||
#expect(attributes("**not bold**")[NoteAttributes.bold] as? Bool != true)
|
||||
#expect(attributes("# literal")[NoteAttributes.headingLevel] == nil)
|
||||
#expect(attributes("# literal")[NoteAttributes.block] as? String == "code")
|
||||
#expect(editor.string == "Heading 🦊\ndef main():\n # comment\n return \"**not bold**\"\nAfter\n# literal")
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "```python\ndef main():\n pass")))
|
||||
let unfinished = try #require(editor.textStorage)
|
||||
#expect(unfinished.attribute(.foregroundColor, at: 10, effectiveRange: nil) as? NSColor == keyword)
|
||||
#expect(unfinished.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == keyword)
|
||||
#expect(editor.string == "def main():\n pass")
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func noteHeadingsShrinkByLevelWithoutChangingMarkdownOrCode() throws {
|
||||
@Test @MainActor
|
||||
func headingLevelsUseDescendingSizesAndCodeRemainsBodySized() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NSTextView()
|
||||
let text = "# One\n## Two\n### Three\n#### Four\n##### Five\n###### Six\nBody\n## **Bold** and *italic*\n```text\n# Literal\n```\n####### Not a heading"
|
||||
editor.string = text
|
||||
MarkdownHighlighting.apply(to: editor)
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 600))
|
||||
let source = "# One\n## Two\n### Three\n#### Four\n##### Five\n###### Six\nBody\n## **Bold** and *italic*\n```text\n# Literal\n```\n####### Not a heading"
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
|
||||
let storage = try #require(editor.textStorage)
|
||||
func font(_ token: String) throws -> NSFont {
|
||||
try #require(storage.attribute(.font, at: (editor.string as NSString).range(of: token).location,
|
||||
@@ -41,13 +45,14 @@ import Testing
|
||||
let sizes = try ["One", "Two", "Three", "Four", "Five", "Six"].map { try font($0).pointSize }
|
||||
for (larger, smaller) in zip(sizes, sizes.dropFirst()) { #expect(larger > smaller) }
|
||||
#expect(try font("Six").pointSize >= font("Body").pointSize)
|
||||
#expect(try font("**Bold**").pointSize == font("Two").pointSize)
|
||||
#expect(try font("*italic*").pointSize == font("Two").pointSize)
|
||||
#expect(try font("Bold").pointSize == font("Two").pointSize)
|
||||
#expect(try font("italic").pointSize == font("Two").pointSize)
|
||||
#expect(try font("Literal").pointSize == font("Body").pointSize)
|
||||
#expect(try font("Not a heading").pointSize == font("Body").pointSize)
|
||||
#expect(try font("Three").isFixedPitch)
|
||||
#expect(editor.string == text)
|
||||
storage.replaceCharacters(in: (editor.string as NSString).range(of: "### Three"), with: "Three")
|
||||
MarkdownHighlighting.apply(to: editor)
|
||||
let three = (editor.string as NSString).range(of: "Three")
|
||||
editor.setSelectedRange(three)
|
||||
for _ in 0..<4 { editor.format(0) }
|
||||
#expect(editor.string.contains("Three"))
|
||||
#expect(try font("Three").pointSize == font("Body").pointSize)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
import Testing
|
||||
|
||||
@Test @MainActor
|
||||
func markdownImportProducesVisibleTextAndSemanticFormatting() throws {
|
||||
let markdown = "# Title 🦊\n**bold** *italic* ***both*** <u>under</u> ~~gone~~ `let x = 1` [site](https://example.com)\n\n> quote\n\n- [ ] todo\n- [x] done\n - nested\n\n3. third\n4. fourth\n\n```swift\n# literal **code**\n```\n"
|
||||
let document = NoteDocument.importMarkdown(NSAttributedString(string: markdown))
|
||||
#expect(document.string == "Title 🦊\nbold italic both under gone let x = 1 site\n\nquote\n\ntodo\ndone\nnested\n\nthird\nfourth\n\n# literal **code**\n")
|
||||
func attribute(_ key: NSAttributedString.Key, _ text: String) throws -> Any? {
|
||||
let range = (document.string as NSString).range(of: text)
|
||||
#expect(range.location != NSNotFound)
|
||||
return document.attribute(key, at: range.location, effectiveRange: nil)
|
||||
}
|
||||
#expect(try attribute(NoteAttributes.block, "Title") as? String == "heading")
|
||||
#expect(try attribute(NoteAttributes.headingLevel, "Title") as? Int == 1)
|
||||
#expect(try attribute(NoteAttributes.bold, "bold") as? Bool == true)
|
||||
#expect(try attribute(NoteAttributes.italic, "italic") as? Bool == true)
|
||||
#expect(try attribute(NoteAttributes.bold, "both") as? Bool == true)
|
||||
#expect(try attribute(NoteAttributes.italic, "both") as? Bool == true)
|
||||
#expect(try attribute(.underlineStyle, "under") as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(try attribute(.strikethroughStyle, "gone") as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(try attribute(NoteAttributes.inlineCode, "let x") as? Bool == true)
|
||||
#expect(try attribute(.link, "site") as? String == "https://example.com")
|
||||
#expect(try attribute(NoteAttributes.block, "quote") as? String == "quote")
|
||||
#expect(try attribute(NoteAttributes.checked, "todo") as? Bool == false)
|
||||
#expect(try attribute(NoteAttributes.checked, "done") as? Bool == true)
|
||||
#expect(try attribute(NoteAttributes.depth, "nested") as? Int == 1)
|
||||
#expect(try attribute(NoteAttributes.number, "third") as? Int == 3)
|
||||
#expect(try attribute(NoteAttributes.number, "fourth") as? Int == 4)
|
||||
#expect(try attribute(NoteAttributes.block, "# literal") as? String == "code")
|
||||
#expect(try attribute(NoteAttributes.language, "# literal") as? String == "swift")
|
||||
#expect(try attribute(NoteAttributes.bold, "**code**") == nil)
|
||||
let restored = NoteDocument.importMarkdown(NoteDocument.markdown(document))
|
||||
#expect(restored.string == document.string)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func markdownImportHandlesEveryHeadingEscapesAndUnsupportedLiteralContent() {
|
||||
for level in 1...6 {
|
||||
let document = NoteDocument.importMarkdown(NSAttributedString(string: String(repeating: "#", count: level) + " Heading"))
|
||||
#expect(document.string == "Heading")
|
||||
#expect(document.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level)
|
||||
}
|
||||
let escaped = NoteDocument.importMarkdown(NSAttributedString(string: "\\*literal\\* & café 🦊"))
|
||||
#expect(escaped.string == "*literal* & café 🦊")
|
||||
#expect(escaped.attribute(NoteAttributes.italic, at: 0, effectiveRange: nil) == nil)
|
||||
for literal in ["", "<div>literal **content**</div>", "| A | B |\n| - | - |\n| x | y |", "---"] {
|
||||
#expect(NoteDocument.importMarkdown(NSAttributedString(string: literal)).string == literal)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func markdownConversionPreservesNativeAttachmentsAndLiteralCode() throws {
|
||||
let attachment = NSTextAttachment()
|
||||
let source = NSMutableAttributedString(string: "**fox ")
|
||||
source.append(NSAttributedString(attachment: attachment))
|
||||
source.append(NSAttributedString(string: "**\n```text\n`literal` **not bold**\n```"))
|
||||
let document = NoteDocument.importMarkdown(source)
|
||||
#expect(document.string == "fox \u{fffc}\n`literal` **not bold**")
|
||||
#expect(document.attribute(.attachment, at: 4, effectiveRange: nil) as? NSTextAttachment === attachment)
|
||||
let markdown = NoteDocument.markdown(document)
|
||||
#expect(markdown.string.contains("```text\n`literal` **not bold**\n```"))
|
||||
let location = (markdown.string as NSString).range(of: "\u{fffc}").location
|
||||
#expect(markdown.attribute(.attachment, at: location, effectiveRange: nil) as? NSTextAttachment === attachment)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func markdownExportRecognizesNativeMarksAndEscapesLiteralSyntax() {
|
||||
let native = NSMutableAttributedString(string: "bold italic under strike link literal *stars* #tag")
|
||||
let text = native.string as NSString
|
||||
native.addAttribute(.font, value: NSFont.boldSystemFont(ofSize: 14), range: text.range(of: "bold"))
|
||||
native.addAttribute(.font, value: NSFontManager.shared.convert(NSFont.systemFont(ofSize: 14), toHaveTrait: .italicFontMask), range: text.range(of: "italic"))
|
||||
native.addAttribute(.underlineStyle, value: 1, range: text.range(of: "under"))
|
||||
native.addAttribute(.strikethroughStyle, value: 1, range: text.range(of: "strike"))
|
||||
native.addAttribute(.link, value: URL(string: "https://example.com")!, range: text.range(of: "link"))
|
||||
let output = NoteDocument.markdown(native)
|
||||
#expect(output.string == "**bold** *italic* <u>under</u> ~~strike~~ [link](<https://example.com>) literal \\*stars\\* \\#tag")
|
||||
#expect(NoteDocument.importMarkdown(output).string == native.string)
|
||||
native.addAttribute(NoteAttributes.bold, value: false, range: text.range(of: "bold"))
|
||||
#expect(NoteDocument.markdown(native).string.hasPrefix("bold "))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func structuredPackageRoundTripsAllSemanticAttributesIncludingFalseAndZero() throws {
|
||||
let document = NSMutableAttributedString(attributedString: NoteDocument.importMarkdown(NSAttributedString(string: "###### Heading\n- [ ] todo\n 0. nested\n```swift\nlet x = 1\n```\n**bold** *italic* <u>under</u> ~~strike~~ `code` [link](https://example.com)")))
|
||||
document.addAttributes([NoteAttributes.bold: false, NoteAttributes.italic: false, NoteAttributes.inlineCode: false, NoteAttributes.depth: 0], range: NSRange(location: 0, length: 1))
|
||||
let wrapper = try NoteDocument.fileWrapper(document)
|
||||
let serialized = try #require(wrapper.serializedRepresentation)
|
||||
let received = try #require(FileWrapper(serializedRepresentation: serialized))
|
||||
let restored = try NoteDocument.contents(from: received)
|
||||
#expect(restored.string == document.string)
|
||||
let keys: [NSAttributedString.Key] = [NoteAttributes.block, NoteAttributes.headingLevel, NoteAttributes.depth, NoteAttributes.number, NoteAttributes.checked, NoteAttributes.language, NoteAttributes.inlineCode, NoteAttributes.bold, NoteAttributes.italic, .underlineStyle, .strikethroughStyle, .link]
|
||||
for index in 0..<document.length {
|
||||
for key in keys {
|
||||
let expected = document.attribute(key, at: index, effectiveRange: nil)
|
||||
let actual = restored.attribute(key, at: index, effectiveRange: nil)
|
||||
if let expected { #expect((expected as? NSObject)?.isEqual(actual) == true) }
|
||||
else { #expect(actual == nil || (key == .underlineStyle || key == .strikethroughStyle) && actual as? Int == 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func structuredPackagesRejectMissingCorruptMismatchedAndUnsafeMetadata() throws {
|
||||
let document = NSAttributedString(string: "safe text")
|
||||
for mutation in ["version", "length", "digest", "runs"] {
|
||||
let wrapper = try NoteDocument.fileWrapper(document)
|
||||
let original = try #require(wrapper.fileWrappers?["ConjureNote.json"])
|
||||
let data = try #require(original.regularFileContents)
|
||||
var metadata = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
switch mutation {
|
||||
case "version": metadata[mutation] = 999
|
||||
case "length": metadata[mutation] = 999
|
||||
case "digest": metadata[mutation] = "not the text digest"
|
||||
default: metadata[mutation] = [["location": 1, "length": 9, "values": [:]]]
|
||||
}
|
||||
wrapper.removeFileWrapper(original)
|
||||
let replacement = FileWrapper(regularFileWithContents: try JSONSerialization.data(withJSONObject: metadata))
|
||||
replacement.preferredFilename = "ConjureNote.json"
|
||||
wrapper.addFileWrapper(replacement)
|
||||
#expect(throws: NoteDocument.DocumentError.self) { try NoteDocument.contents(from: wrapper) }
|
||||
}
|
||||
let missing = try document.fileWrapper(from: NSRange(location: 0, length: document.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd])
|
||||
#expect(throws: NoteDocument.DocumentError.self) { try NoteDocument.contents(from: missing) }
|
||||
let unsafe = try NoteDocument.fileWrapper(document)
|
||||
let link = FileWrapper(symbolicLinkWithDestinationURL: URL(fileURLWithPath: "/etc/passwd"))
|
||||
link.preferredFilename = "outside"
|
||||
unsafe.addFileWrapper(link)
|
||||
#expect(throws: NoteDocument.DocumentError.self) { try NoteDocument.contents(from: unsafe) }
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func structuredPackagesRejectNonPackageRootsAndReservedAttachmentFilenames() throws {
|
||||
#expect(throws: NoteDocument.DocumentError.self) {
|
||||
try NoteDocument.contents(from: FileWrapper(regularFileWithContents: Data()))
|
||||
}
|
||||
let attachment = FileWrapper(regularFileWithContents: Data("attachment".utf8))
|
||||
attachment.preferredFilename = "ConjureNote.json"
|
||||
#expect(throws: NoteDocument.DocumentError.self) {
|
||||
try NoteDocument.fileWrapper(NSAttributedString(attachment: NSTextAttachment(fileWrapper: attachment)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func markdownLazyContinuationUsesASTTextWithoutDuplicatingSourceOrLosingLinebreaks() {
|
||||
for (source, expected) in [
|
||||
("- [ ] task\nnext", "task\nnext"),
|
||||
("- [ ] task\n**bold** and `code`\nlast", "task\nbold and code\nlast"),
|
||||
("> quote\n[link](https://example.com)\nnext", "quote\nlink\nnext"),
|
||||
("- item\n***both*** <u>under</u>\nlast", "item\nboth under\nlast"),
|
||||
] {
|
||||
let contents = NoteDocument.importMarkdown(NSAttributedString(string: source))
|
||||
#expect(contents.string == expected)
|
||||
}
|
||||
let attachment = NSTextAttachment()
|
||||
let source = NSMutableAttributedString(string: "# Heading\n- [ ] task\n**bold** and `code`\n")
|
||||
source.append(NSAttributedString(attachment: attachment))
|
||||
let contents = NoteDocument.importMarkdown(source)
|
||||
#expect(contents.string == "Heading\ntask\nbold and code\n\u{fffc}")
|
||||
#expect(contents.attribute(.attachment, at: contents.length - 1, effectiveRange: nil) as? NSTextAttachment === attachment)
|
||||
let bold = (contents.string as NSString).range(of: "bold")
|
||||
#expect(contents.attribute(NoteAttributes.bold, at: bold.location, effectiveRange: nil) as? Bool == true)
|
||||
let code = (contents.string as NSString).range(of: "code")
|
||||
#expect(contents.attribute(NoteAttributes.inlineCode, at: code.location, effectiveRange: nil) as? Bool == true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func markdownExportGroupsSemanticMarksAcrossDisplayRunsAndKeepsWhitespace() {
|
||||
let contents = NSMutableAttributedString(string: "fox 🦊 \n")
|
||||
let marked = NSRange(location: 0, length: (contents.string as NSString).range(of: " \n").location)
|
||||
contents.addAttributes([.underlineStyle: 1, NoteAttributes.bold: false, NoteAttributes.italic: false], range: NSRange(location: 0, length: contents.length))
|
||||
contents.addAttribute(.font, value: NSFont.systemFont(ofSize: 14), range: NSRange(location: 0, length: 4))
|
||||
contents.addAttributes([.font: NSFont.systemFont(ofSize: 18), .foregroundColor: NSColor.red], range: NSRange(location: 4, length: marked.length - 4))
|
||||
#expect(NoteDocument.markdown(contents).string == "<u>fox 🦊 </u>\n")
|
||||
contents.addAttribute(NoteAttributes.bold, value: true, range: marked)
|
||||
#expect(NoteDocument.markdown(contents).string == "**<u>fox 🦊</u>**<u> </u>\n")
|
||||
|
||||
for source in ["- [ ] todo \n", "first \nsecond \t\n", "trailing "] {
|
||||
let imported = NoteDocument.importMarkdown(NSAttributedString(string: source))
|
||||
let expected = source.hasPrefix("- [ ] ") ? String(source.dropFirst(6)) : source
|
||||
#expect(imported.string == expected)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ func markdownExportPreservesWholeTextAndSnapshotsBeforeWriting() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
|
||||
let text = "\n # 日本語 🦊 \r\n**bold** and `code`\n- [ ] task\n\u{fffc}\n"
|
||||
let text = "\n日本語 🦊 \r\nplain and code\nordinary task\n\u{fffc}\n"
|
||||
let source = NSMutableAttributedString(string: text)
|
||||
source.addAttribute(.attachment, value: NSTextAttachment(), range: NSRange(location: 0, length: 1))
|
||||
let export = try NoteMarkdownExport(contents: source)
|
||||
@@ -32,13 +32,13 @@ func markdownExportWritesOriginalImageBytesAndEscapedLinksWithoutChangingSource(
|
||||
wrapper.preferredFilename = name
|
||||
let originalFilename = wrapper.filename
|
||||
let attachment = NSTextAttachment(fileWrapper: wrapper)
|
||||
let source = NSMutableAttributedString(string: "\n # 🦊\n")
|
||||
let source = NSMutableAttributedString(attributedString: NoteDocument.importMarkdown(NSAttributedString(string: "\n# 🦊\n")))
|
||||
source.append(NSAttributedString(attachment: attachment))
|
||||
source.append(NSAttributedString(string: "\n- [ ] todo \n"))
|
||||
source.append(NoteDocument.importMarkdown(NSAttributedString(string: "\n- [ ] todo \n")))
|
||||
let before = NSAttributedString(attributedString: source)
|
||||
let export = try NoteMarkdownExport(contents: source)
|
||||
#expect(export.hasImages)
|
||||
#expect(export.clipboardText == "\n # 🦊\n``<pixel [x] `1` #%.png>``\n- [ ] todo \n")
|
||||
#expect(export.clipboardText == "\n# 🦊\n``<pixel [x] `1` #%.png>``\n- [ ] todo \n")
|
||||
#expect(source.isEqual(to: before))
|
||||
#expect(wrapper.preferredFilename == name)
|
||||
#expect(wrapper.filename == originalFilename)
|
||||
@@ -46,7 +46,7 @@ func markdownExportWritesOriginalImageBytesAndEscapedLinksWithoutChangingSource(
|
||||
source.mutableString.setString("Changed")
|
||||
wrapper.preferredFilename = "changed.png"
|
||||
try export.write(to: directory)
|
||||
let markdown = "\n # 🦊\n![pixel \\[x\\] \\`1\\` #%.png](pixel%20%5Bx%5D%20%601%60%20%23%25.png)\n- [ ] todo \n"
|
||||
let markdown = "\n# 🦊\n![pixel \\[x\\] \\`1\\` #%.png](pixel%20%5Bx%5D%20%601%60%20%23%25.png)\n- [ ] todo \n"
|
||||
#expect(try String(contentsOf: directory.appendingPathComponent("note.md"), encoding: .utf8) == markdown)
|
||||
#expect(try Data(contentsOf: directory.appendingPathComponent(name)) == png)
|
||||
#expect(Set(try FileManager.default.contentsOfDirectory(atPath: directory.path)) == ["note.md", name])
|
||||
|
||||
@@ -8,7 +8,7 @@ func vimNormalCursorIsWideButInsertAndDisabledCursorsStayThin() throws {
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 50))
|
||||
editor.font = NotesAppearance.font
|
||||
editor.drawsBackground = false
|
||||
editor.setMarkdownContents(NSAttributedString(string: "text"))
|
||||
editor.setDocumentContents(NSAttributedString(string: "text"))
|
||||
func paintedWidth() throws -> Int {
|
||||
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 100, pixelsHigh: 50,
|
||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,92 +4,98 @@ import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@Test @MainActor
|
||||
func noteFormattingHidesSyntaxButPreservesMarkdownAndLiteralCode() throws {
|
||||
func importedFormattingBecomesVisibleTextAndNativeSemanticMarks() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 800))
|
||||
let source = "# Heading\n## Subheading\n*italic* **bold** _emphasis_ __strong__ ~~gone~~ <u>underlined</u> `*literal*`\n> Quote\n[Label](https://example.com)\n```text\n# Code *literal*\n```\nUnmatched * and \\*escaped*"
|
||||
editor.setMarkdownContents(NSAttributedString(string: source))
|
||||
MarkdownHighlighting.apply(to: editor)
|
||||
editor.refreshTasks()
|
||||
let layout = try #require(editor.layoutManager)
|
||||
let container = try #require(editor.textContainer)
|
||||
layout.ensureLayout(for: container)
|
||||
func visibleText() -> String {
|
||||
var result = ""
|
||||
let text = editor.string as NSString
|
||||
for index in 0..<text.length {
|
||||
let glyph = layout.glyphIndexForCharacter(at: index)
|
||||
if !layout.propertyForGlyph(at: glyph).contains(.null) {
|
||||
result += text.substring(with: NSRange(location: index, length: 1))
|
||||
}
|
||||
}
|
||||
return result
|
||||
let source = "# Heading\n## Subheading\n*italic* **bold** _emphasis_ __strong__ ~~gone~~ <u>underlined</u> `*literal*`\n> Quote\n[Label](https://example.com)\n```text\n# Code *literal*\n```"
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
|
||||
#expect(editor.string == "Heading\nSubheading\nitalic bold emphasis strong gone underlined *literal*\nQuote\nLabel\n# Code *literal*")
|
||||
func attribute(_ key: NSAttributedString.Key, in token: String) -> Any? {
|
||||
editor.documentContents.attribute(key, at: (editor.string as NSString).range(of: token).location, effectiveRange: nil)
|
||||
}
|
||||
#expect(visibleText() == "Heading\nSubheading\nitalic bold emphasis strong gone underlined *literal*\nQuote\nLabel\n```text\n# Code *literal*\n```\nUnmatched * and \\*escaped*")
|
||||
#expect(editor.markdownString == source)
|
||||
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
|
||||
let pasteboard = NSPasteboard.withUniqueName()
|
||||
defer { pasteboard.releaseGlobally() }
|
||||
#expect(editor.writeSelection(to: pasteboard, types: editor.writablePasteboardTypes))
|
||||
#expect(pasteboard.string(forType: .string) == source)
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = try NotesStore(directory: directory)
|
||||
let id = try store.create()
|
||||
try store.update(id: id, contents: editor.markdownContents)
|
||||
let reloaded = try #require(NotesStore(directory: directory).notes.first)
|
||||
#expect(reloaded.contents.string == source)
|
||||
#expect(attribute(NoteAttributes.headingLevel, in: "Heading") as? Int == 1)
|
||||
#expect(attribute(NoteAttributes.headingLevel, in: "Subheading") as? Int == 2)
|
||||
for token in ["italic", "emphasis"] { #expect(attribute(NoteAttributes.italic, in: token) as? Bool == true) }
|
||||
for token in ["bold", "strong"] { #expect(attribute(NoteAttributes.bold, in: token) as? Bool == true) }
|
||||
#expect(attribute(.strikethroughStyle, in: "gone") as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(attribute(.underlineStyle, in: "underlined") as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(attribute(NoteAttributes.inlineCode, in: "*literal*") as? Bool == true)
|
||||
#expect(attribute(NoteAttributes.block, in: "Quote") as? String == "quote")
|
||||
#expect(String(describing: try #require(attribute(.link, in: "Label"))) == "https://example.com")
|
||||
#expect(attribute(NoteAttributes.block, in: "# Code") as? String == "code")
|
||||
#expect(attribute(NoteAttributes.bold, in: "# Code") as? Bool != true)
|
||||
#expect(attribute(NoteAttributes.headingLevel, in: "# Code") == nil)
|
||||
}
|
||||
|
||||
private final class NotesLayoutProbe: NSLayoutManager {
|
||||
var observeFormatting: ((NSTextStorage) -> Void)?
|
||||
|
||||
override func processEditing(for textStorage: NSTextStorage, edited editMask: NSTextStorageEditActions,
|
||||
range newCharRange: NSRange, changeInLength delta: Int,
|
||||
invalidatedRange invalidatedCharRange: NSRange) {
|
||||
observeFormatting?(textStorage)
|
||||
super.processEditing(for: textStorage, edited: editMask, range: newCharRange,
|
||||
changeInLength: delta, invalidatedRange: invalidatedCharRange)
|
||||
@Test @MainActor
|
||||
func completedInlineShortcutsConsumeDelimitersAndKeepUndoableNativeMarks() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||
editor.allowsUndo = true
|
||||
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.contentView = editor
|
||||
window.makeFirstResponder(editor)
|
||||
defer { window.orderOut(nil) }
|
||||
for (source, key) in [("**fox 🦊**", NoteAttributes.bold), ("*fox 🦊*", NoteAttributes.italic),
|
||||
("`fox 🦊`", NoteAttributes.inlineCode), ("~~fox 🦊~~", .strikethroughStyle),
|
||||
("<u>fox 🦊</u>", .underlineStyle), ("[fox 🦊](https://example.com)", .link)] {
|
||||
editor.setDocumentContents(NSAttributedString(string: ""))
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
editor.beginInsertionSession()
|
||||
for character in source { editor.insertText(String(character), replacementRange: editor.selectedRange()) }
|
||||
editor.endInsertionSession()
|
||||
#expect(editor.string == "fox 🦊")
|
||||
#expect(editor.selectedRange() == NSRange(location: "fox 🦊".utf16.count, length: 0))
|
||||
let mark = try #require(editor.documentContents.attribute(key, at: 0, effectiveRange: nil))
|
||||
if key == .link {
|
||||
#expect(String(describing: mark) == "https://example.com")
|
||||
} else if key == .underlineStyle || key == .strikethroughStyle {
|
||||
#expect(mark as? Int == NSUnderlineStyle.single.rawValue)
|
||||
} else {
|
||||
#expect(mark as? Bool == true)
|
||||
}
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.string.isEmpty)
|
||||
editor.undoManager?.redo()
|
||||
#expect(editor.string == "fox 🦊")
|
||||
#expect(editor.documentContents.attribute(key, at: 0, effectiveRange: nil) != nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func typingDoesNotExposeUnformattedExistingTextToLayout() throws {
|
||||
func richCopyPasteKeepsNativeMarksUnicodeAndImageBytesWithoutMarkdownParsing() throws {
|
||||
_ = NSApplication.shared
|
||||
let suite = "NotesFlashing.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
let store = try NotesStore(directory: directory)
|
||||
let id = try store.create()
|
||||
try store.update(id: id, contents: NSAttributedString(string: "# Heading\n- [ ] Task\nPlain"))
|
||||
defaults.set(id.uuidString, forKey: "selectedNote")
|
||||
let controller = NotesController(defaults: defaults, directory: directory)
|
||||
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 570, height: 404),
|
||||
styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.contentViewController = controller
|
||||
defer { _ = controller.close(); window.orderOut(nil) }
|
||||
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
|
||||
let layout = NotesLayoutProbe()
|
||||
try #require(editor.textContainer).replaceLayoutManager(layout)
|
||||
editor.refreshTasks()
|
||||
window.makeFirstResponder(editor)
|
||||
layout.observeFormatting = { storage in
|
||||
let text = storage.string as NSString
|
||||
let heading = text.range(of: "Heading")
|
||||
let task = text.range(of: "Task")
|
||||
#expect((storage.attribute(.font, at: heading.location, effectiveRange: nil) as? NSFont)?.pointSize == 26)
|
||||
#expect((storage.attribute(.paragraphStyle, at: task.location, effectiveRange: nil) as? NSParagraphStyle)?.headIndent == 22)
|
||||
#expect(storage.attribute(MarkdownHighlighting.hiddenMarkup, at: 0, effectiveRange: nil) as? Bool == true)
|
||||
}
|
||||
defer { layout.observeFormatting = nil }
|
||||
editor.insertText("!", replacementRange: NSRange(location: editor.string.utf16.count, length: 0))
|
||||
#expect(editor.markdownString == "# Heading\n- [ ] Task\nPlain!")
|
||||
#expect(try NotesStore(directory: directory).notes.first?.contents.string == editor.markdownString)
|
||||
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1,
|
||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
|
||||
bitmap.setColor(.red, atX: 0, y: 0)
|
||||
let png = try #require(bitmap.representation(using: .png, properties: [:]))
|
||||
let file = FileWrapper(regularFileWithContents: png)
|
||||
file.preferredFilename = "pixel.png"
|
||||
let contents = NSMutableAttributedString(attributedString:
|
||||
NoteDocument.importMarkdown(NSAttributedString(string: "- [x] **🦊 café** ")))
|
||||
contents.append(NSAttributedString(attachment: NSTextAttachment(fileWrapper: file)))
|
||||
let source = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200))
|
||||
source.setDocumentContents(contents)
|
||||
source.setSelectedRange(NSRange(location: 0, length: source.string.utf16.count))
|
||||
let board = NSPasteboard.withUniqueName()
|
||||
defer { board.releaseGlobally() }
|
||||
#expect(source.writeSelection(to: board, types: source.writablePasteboardTypes))
|
||||
#expect(board.string(forType: .string) == source.string)
|
||||
let destination = NotesTextView(frame: source.frame)
|
||||
destination.allowsUndo = true
|
||||
#expect(destination.readSelection(from: board))
|
||||
#expect(destination.string == source.string)
|
||||
#expect(destination.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
|
||||
#expect(destination.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "task")
|
||||
#expect(destination.documentContents.attribute(NoteAttributes.checked, at: 0, effectiveRange: nil) as? Bool == true)
|
||||
let image = try #require(destination.documentContents.attribute(.attachment,
|
||||
at: destination.string.utf16.count - 1, effectiveRange: nil) as? NSTextAttachment)
|
||||
#expect(image.fileWrapper?.regularFileContents == png)
|
||||
destination.undoManager?.undo()
|
||||
#expect(destination.string.isEmpty)
|
||||
destination.undoManager?.redo()
|
||||
#expect(destination.string == source.string)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -112,7 +118,7 @@ func noteImagesRenderRoundedWithoutChangingSavedImageData() throws {
|
||||
#expect(editor.readSelection(from: pasteboard))
|
||||
|
||||
func checkRoundedDrawing() throws {
|
||||
MarkdownHighlighting.apply(to: editor)
|
||||
editor.applyDocumentStyle()
|
||||
let layout = try #require(editor.layoutManager)
|
||||
layout.ensureLayout(for: try #require(editor.textContainer))
|
||||
let output = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 200, pixelsHigh: 120,
|
||||
@@ -146,60 +152,40 @@ func noteImagesRenderRoundedWithoutChangingSavedImageData() throws {
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = try NotesStore(directory: directory)
|
||||
let id = try store.create()
|
||||
try store.update(id: id, contents: editor.markdownContents)
|
||||
try store.update(id: id, contents: editor.documentContents)
|
||||
let reloaded = try #require(NotesStore(directory: directory).notes.first)
|
||||
let attachment = try #require(reloaded.contents.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment)
|
||||
#expect(attachment.fileWrapper?.regularFileContents == png)
|
||||
editor.setMarkdownContents(reloaded.contents)
|
||||
editor.setDocumentContents(reloaded.contents)
|
||||
try checkRoundedDrawing()
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func hiddenNoteMarkupKeepsTypingDeletionFormattingAndUndoAtVisibleBoundaries() throws {
|
||||
func nativeFormattingSurvivesVisibleBoundaryEditingAndUndo() throws {
|
||||
_ = NSApplication.shared
|
||||
let suite = "NotesMarkup.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
let controller = NotesController(defaults: defaults, directory: directory)
|
||||
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 570, height: 404),
|
||||
styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.contentViewController = controller
|
||||
defer { _ = controller.close(); window.orderOut(nil) }
|
||||
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||
editor.allowsUndo = true
|
||||
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.contentView = editor
|
||||
window.makeFirstResponder(editor)
|
||||
editor.insertText("# Title\n*word*", replacementRange: NSRange(location: 0, length: 0))
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
editor.insertText("A", replacementRange: editor.selectedRange())
|
||||
#expect(editor.markdownString == "# ATitle\n*word*")
|
||||
editor.breakUndoCoalescing()
|
||||
editor.undoManager?.removeAllActions()
|
||||
editor.undoManager?.beginUndoGrouping()
|
||||
defer { window.orderOut(nil) }
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "# Title\n*word*")))
|
||||
editor.insertText("A", replacementRange: NSRange(location: 0, length: 0))
|
||||
#expect(editor.string == "ATitle\nword")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == 1)
|
||||
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
||||
editor.undoManager?.removeAllActions()
|
||||
editor.deleteBackward(nil)
|
||||
editor.undoManager?.endUndoGrouping()
|
||||
#expect(editor.markdownString == "# ATitle\n*wor*")
|
||||
#expect(editor.string == "ATitle\nwor")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.italic, at: "ATitle\n".utf16.count, effectiveRange: nil) as? Bool == true)
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.markdownString == "# ATitle\n*word*")
|
||||
#expect(editor.string == "ATitle\nword")
|
||||
let word = (editor.string as NSString).range(of: "word")
|
||||
editor.setSelectedRange(NSRange(location: word.location, length: 0))
|
||||
editor.moveRight(nil)
|
||||
#expect(editor.selectedRange().location == word.location + 1)
|
||||
editor.setSelectedRange(NSRange(location: NSMaxRange(word), length: 0))
|
||||
editor.moveRight(nil)
|
||||
#expect(editor.selectedRange().location == editor.string.utf16.count)
|
||||
editor.setSelectedRange(NSRange(location: word.location, length: 0))
|
||||
let italic = try #require(descendants(controller.view).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Italic" })
|
||||
// Selecting the body and toggling formatting must remove its hidden delimiters.
|
||||
editor.setSelectedRange(word)
|
||||
italic.performClick(nil)
|
||||
#expect(editor.markdownString == "# ATitle\nword")
|
||||
#expect(controller.save())
|
||||
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "# ATitle\nword")
|
||||
editor.format(2)
|
||||
#expect(editor.string == "ATitle\nword")
|
||||
#expect(editor.selectedRange() == word)
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.italic, at: word.location, effectiveRange: nil) as? Bool != true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -234,62 +220,51 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
|
||||
}
|
||||
editor.insertText("fox 🦊", replacementRange: editor.selectedRange())
|
||||
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
|
||||
let selection = editor.selectedRange()
|
||||
#expect(try shortcut("b"))
|
||||
#expect(editor.markdownString == "**fox 🦊**")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
|
||||
#expect(try shortcut("i"))
|
||||
#expect(editor.markdownString == "***fox 🦊***")
|
||||
let font = try #require(editor.textStorage?.attribute(.font, at: editor.selectedRange().location,
|
||||
effectiveRange: nil) as? NSFont)
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.italic, at: 0, effectiveRange: nil) as? Bool == true)
|
||||
let font = try #require(editor.textStorage?.attribute(.font, at: 0, effectiveRange: nil) as? NSFont)
|
||||
let traits = NSFontManager.shared.traits(of: font)
|
||||
#expect(traits.contains(.boldFontMask) && traits.contains(.italicFontMask))
|
||||
#expect(try shortcut("i"))
|
||||
#expect(editor.markdownString == "**fox 🦊**")
|
||||
#expect(try shortcut("b"))
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true)
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.italic, at: 0, effectiveRange: nil) as? Bool != true)
|
||||
#expect(try shortcut("s", shift: true))
|
||||
#expect(editor.markdownString == "~~fox 🦊~~")
|
||||
#expect(editor.textStorage?.attribute(.strikethroughStyle, at: editor.selectedRange().location,
|
||||
effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(editor.documentContents.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(try shortcut("s", shift: true))
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
editor.breakUndoCoalescing()
|
||||
#expect(editor.documentContents.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) == nil)
|
||||
editor.undoManager?.removeAllActions()
|
||||
editor.undoManager?.beginUndoGrouping()
|
||||
#expect(try shortcut("u", shift: true))
|
||||
editor.undoManager?.endUndoGrouping()
|
||||
#expect(editor.markdownString == "<u>fox 🦊</u>")
|
||||
#expect(editor.textStorage?.attribute(.underlineStyle, at: editor.selectedRange().location,
|
||||
effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(editor.documentContents.attribute(.underlineStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(editor.string == "fox 🦊")
|
||||
#expect(editor.selectedRange() == selection)
|
||||
#expect(controller.save())
|
||||
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "<u>fox 🦊</u>")
|
||||
#expect(try NoteMarkdownExport(contents: editor.markdownContents).clipboardText == "<u>fox 🦊</u>")
|
||||
let saved = try #require(NotesStore(directory: directory).notes.first)
|
||||
#expect(saved.contents.string == "fox 🦊")
|
||||
#expect(saved.contents.attribute(.underlineStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(try NoteMarkdownExport(contents: editor.documentContents).clipboardText == "<u>fox 🦊</u>")
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
#expect(try shortcut("u", shift: true))
|
||||
#expect(try shortcut("u", shift: true))
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
#expect(editor.textStorage?.attribute(.underlineStyle, at: 0, effectiveRange: nil) == nil)
|
||||
#expect(editor.documentContents.attribute(.underlineStyle, at: 0, effectiveRange: nil) == nil)
|
||||
#expect(try shortcut("l", shift: true))
|
||||
#expect(editor.markdownString == "- fox 🦊")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "bullet")
|
||||
#expect(try shortcut("l", shift: true))
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) == nil)
|
||||
#expect(!(try shortcut("b", option: true)))
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
|
||||
editor.breakUndoCoalescing()
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true)
|
||||
editor.undoManager?.removeAllActions()
|
||||
editor.undoManager?.beginUndoGrouping()
|
||||
#expect(try shortcut("e"))
|
||||
editor.undoManager?.endUndoGrouping()
|
||||
#expect(editor.markdownString == "`fox 🦊`")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: 0, effectiveRange: nil) as? Bool == true)
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: 0, effectiveRange: nil) as? Bool != true)
|
||||
#expect(try shortcut("k"))
|
||||
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == "Search notes actions" })
|
||||
#expect(window.firstResponder !== editor)
|
||||
_ = try shortcut("b")
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
#expect(editor.string == "fox 🦊")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true)
|
||||
#expect(search.stringValue.isEmpty)
|
||||
#expect(controller.handle(#selector(NSResponder.cancelOperation(_:))))
|
||||
#expect(controller.save())
|
||||
@@ -297,30 +272,28 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func inlineCodeContainerStaysWithinCodeAndDisappearsWhenDelimitersAreRemoved() throws {
|
||||
func inlineCodeContainerIsLimitedToItsMarkAndDisappearsWhenToggledOff() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 300))
|
||||
let source = "Before `some *literal* code 🦊` after\n```text\n`not inline` <u>literal</u>\n```"
|
||||
editor.setMarkdownContents(NSAttributedString(string: source))
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
|
||||
let storage = try #require(editor.textStorage)
|
||||
let layout = try #require(editor.layoutManager)
|
||||
layout.ensureLayout(for: try #require(editor.textContainer))
|
||||
let body = (source as NSString).range(of: "some *literal* code 🦊")
|
||||
let body = (editor.string as NSString).range(of: "some *literal* code 🦊")
|
||||
for index in body.location..<NSMaxRange(body) {
|
||||
#expect(storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.accent)
|
||||
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.selectionFill)
|
||||
}
|
||||
for token in ["Before", "after", "not inline"] {
|
||||
let index = (source as NSString).range(of: token).location
|
||||
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) == nil)
|
||||
let index = (editor.string as NSString).range(of: token).location
|
||||
#expect(storage.attribute(NoteAttributes.inlineCode, at: index, effectiveRange: nil) as? Bool != true)
|
||||
}
|
||||
let literalUnderline = (source as NSString).range(of: "<u>literal</u>")
|
||||
let literalUnderline = (editor.string as NSString).range(of: "<u>literal</u>")
|
||||
#expect(storage.attribute(.underlineStyle, at: literalUnderline.location, effectiveRange: nil) == nil)
|
||||
#expect(editor.markdownString == source)
|
||||
#expect(editor.markdownContents.attribute(.backgroundColor, at: body.location, effectiveRange: nil) == nil)
|
||||
editor.replaceMarkdown("some *literal* code 🦊",
|
||||
range: NSRange(location: body.location - 1, length: body.length + 2))
|
||||
let plain = (editor.string as NSString).range(of: "some")
|
||||
#expect(storage.attribute(.backgroundColor, at: plain.location, effectiveRange: nil) == nil)
|
||||
#expect(storage.attribute(.foregroundColor, at: plain.location, effectiveRange: nil) as? NSColor == SunsetPalette.text)
|
||||
let before = editor.string
|
||||
editor.setSelectedRange(body)
|
||||
editor.format(4)
|
||||
#expect(editor.string == before)
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: body.location, effectiveRange: nil) as? Bool != true)
|
||||
#expect(storage.attribute(.backgroundColor, at: body.location, effectiveRange: nil) == nil)
|
||||
#expect(storage.attribute(.foregroundColor, at: body.location, effectiveRange: nil) as? NSColor == SunsetPalette.text)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user