import AppKit import ConjureKit import Testing @testable import Conjure @Test @MainActor func noteFormattingHidesSyntaxButPreservesMarkdownAndLiteralCode() 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.. 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 typingDoesNotExposeUnformattedExistingTextToLayout() 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) } @Test @MainActor func noteImagesRenderRoundedWithoutChangingSavedImageData() throws { _ = NSApplication.shared let image = NSImage(size: NSSize(width: 80, height: 48), flipped: false) { rect in NSColor.red.setFill() rect.fill() return true } let tiff = try #require(image.tiffRepresentation) let bitmap = try #require(NSBitmapImageRep(data: tiff)) let png = try #require(bitmap.representation(using: .png, properties: [:])) let pasteboard = NSPasteboard.withUniqueName() defer { pasteboard.releaseGlobally() } pasteboard.setData(png, forType: .png) let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) editor.isRichText = true editor.importsGraphics = true #expect(editor.readSelection(from: pasteboard)) func checkRoundedDrawing() throws { MarkdownHighlighting.apply(to: editor) let layout = try #require(editor.layoutManager) layout.ensureLayout(for: try #require(editor.textContainer)) let output = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 200, pixelsHigh: 120, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) let context = try #require(NSGraphicsContext(bitmapImageRep: output)) NSGraphicsContext.saveGraphicsState() defer { NSGraphicsContext.restoreGraphicsState() } NSGraphicsContext.current = context context.cgContext.clear(CGRect(x: 0, y: 0, width: 200, height: 120)) layout.drawGlyphs(forGlyphRange: layout.glyphRange(forCharacterRange: NSRange(location: 0, length: 1), actualCharacterRange: nil), at: .zero) var left = 200, right = -1, top = 120, bottom = -1 for y in 0..<120 { for x in 0..<200 where (output.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.5 { left = min(left, x); right = max(right, x) top = min(top, y); bottom = max(bottom, y) } } try #require(right > left && bottom > top) for x in [left, right] { for y in [top, bottom] { #expect(try #require(output.colorAt(x: x, y: y)).alphaComponent < 0.1) } } let center = try #require(output.colorAt(x: (left + right) / 2, y: (top + bottom) / 2)?.usingColorSpace(.deviceRGB)) #expect(center.redComponent > 0.9 && center.greenComponent < 0.1 && center.alphaComponent > 0.9) } try checkRoundedDrawing() 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) let attachment = try #require(reloaded.contents.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment) #expect(attachment.fileWrapper?.regularFileContents == png) editor.setMarkdownContents(reloaded.contents) try checkRoundedDrawing() } @Test @MainActor func hiddenNoteMarkupKeepsTypingDeletionFormattingAndUndoAtVisibleBoundaries() 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) 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() editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) editor.deleteBackward(nil) editor.undoManager?.endUndoGrouping() #expect(editor.markdownString == "# ATitle\n*wor*") editor.undoManager?.undo() #expect(editor.markdownString == "# ATitle\n*word*") let word = (editor.string as NSString).range(of: "word") editor.setSelectedRange(NSRange(location: word.location, length: 0)) editor.moveRight(nil) #expect(editor.selectedRange().location == word.location + 1) editor.setSelectedRange(NSRange(location: NSMaxRange(word), length: 0)) editor.moveRight(nil) #expect(editor.selectedRange().location == editor.string.utf16.count) editor.setSelectedRange(NSRange(location: word.location, length: 0)) let italic = try #require(descendants(controller.view).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Italic" }) // Selecting the body and toggling formatting must remove its hidden delimiters. editor.setSelectedRange(word) italic.performClick(nil) #expect(editor.markdownString == "# ATitle\nword") #expect(controller.save()) #expect(try NotesStore(directory: directory).notes.first?.contents.string == "# ATitle\nword") } @Test @MainActor func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws { _ = NSApplication.shared let suite = "NotesFormattingKeys.\(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 launcher = LauncherWindowController( history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }), defaults: defaults, notesDirectory: directory) let window = try #require(launcher.window) defer { launcher.dismiss(restoringFocus: false) } launcher.present(command: .notes) let content = try #require(window.contentView) func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first) let controller = try #require(editor.delegate as? NotesController) func shortcut(_ key: String, shift: Bool = false, option: Bool = false) throws -> Bool { var flags: NSEvent.ModifierFlags = .command if shift { flags.insert(.shift) } if option { flags.insert(.option) } let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: shift ? key.uppercased() : key, charactersIgnoringModifiers: shift ? key.uppercased() : key, isARepeat: false, keyCode: 0)) return window.performKeyEquivalent(with: event) } editor.insertText("fox 🦊", replacementRange: editor.selectedRange()) editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count)) #expect(try shortcut("b")) #expect(editor.markdownString == "**fox 🦊**") #expect(try shortcut("i")) #expect(editor.markdownString == "***fox 🦊***") let font = try #require(editor.textStorage?.attribute(.font, at: editor.selectedRange().location, 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(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(try shortcut("s", shift: true)) #expect(editor.markdownString == "fox 🦊") editor.breakUndoCoalescing() 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(controller.save()) #expect(try NotesStore(directory: directory).notes.first?.contents.string == "fox 🦊") #expect(try NoteMarkdownExport(contents: editor.markdownContents).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(try shortcut("l", shift: true)) #expect(editor.markdownString == "- fox 🦊") #expect(try shortcut("l", shift: true)) #expect(editor.markdownString == "fox 🦊") #expect(!(try shortcut("b", option: true))) #expect(editor.markdownString == "fox 🦊") editor.breakUndoCoalescing() editor.undoManager?.removeAllActions() editor.undoManager?.beginUndoGrouping() #expect(try shortcut("e")) editor.undoManager?.endUndoGrouping() #expect(editor.markdownString == "`fox 🦊`") editor.undoManager?.undo() #expect(editor.markdownString == "fox 🦊") #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(search.stringValue.isEmpty) #expect(controller.handle(#selector(NSResponder.cancelOperation(_:)))) #expect(controller.save()) #expect(try NotesStore(directory: directory).notes.first?.contents.string == "fox 🦊") } @Test @MainActor func inlineCodeContainerStaysWithinCodeAndDisappearsWhenDelimitersAreRemoved() 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)) 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 🦊") for index in body.location..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) }