feat: improve note formatting shortcuts and code styling

This commit is contained in:
2026-10-22 09:35:51 +02:00
parent 5065ba59bd
commit 665691ddee
5 changed files with 87 additions and 22 deletions
+5 -4
View File
@@ -149,7 +149,7 @@ Notes actions are ordered Browse Notes, New Note, Copy as Markdown, Export as Ma
Notes Actions also includes **Copy as Markdown** and **Export as Markdown…**. Both export the whole note, regardless of the selection, without modifying the editor or RTFD autosaves. Clipboard exports replace images with inline-code filename placeholders such as `<photo.png>`. File exports use a native save dialog: notes without images become a single `.md` file; notes with images become a new folder containing `note.md` and the original image files, linked with relative paths. Unsafe or duplicate image filenames are made safe and unique. Image folders must not already exist; a failed export does not leave a partially written folder. Unsupported or unreadable images report an error rather than being dropped. Notes Actions also includes **Copy as Markdown** and **Export as Markdown…**. Both export the whole note, regardless of the selection, without modifying the editor or RTFD autosaves. Clipboard exports replace images with inline-code filename placeholders such as `<photo.png>`. File exports use a native save dialog: notes without images become a single `.md` file; notes with images become a new folder containing `note.md` and the original image files, linked with relative paths. Unsafe or duplicate image filenames are made safe and unique. Image folders must not already exist; a failed export does not leave a partially written folder. Unsupported or unreadable images report an error rather than being dropped.
The bottom strip applies Markdown headings, bold, italic, strikethrough, inline code, links, fenced code blocks, quotes, numbered lists, bullet lists, and checklists. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type removes its marker. The formatting strip is always visible. Successful autosaves stay unobtrusive; only save, load, deletion, or export errors appear below the editor. The bottom strip applies Markdown headings, bold, italic, underline, strikethrough, inline code, links, fenced code blocks, quotes, numbered lists, bullet lists, and checklists. Underline uses inline HTML `<u>…</u>` so it survives Markdown export and RTFD autosaves. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type removes its marker. The formatting strip is always visible. Successful autosaves stay unobtrusive; only save, load, deletion, or export errors appear below the editor.
Markdown highlighting and list alignment update in one formatting transaction per edit, so typing does not expose an intermediate unformatted layout. Markdown highlighting and list alignment update in one formatting transaction per edit, so typing does not expose an intermediate unformatted layout.
@@ -162,16 +162,17 @@ Formatting shortcuts use the same actions as the bottom strip. They apply to the
| Bold | Command-B | | Bold | Command-B |
| Italic | Command-I | | Italic | Command-I |
| Inline code | Command-E | | Inline code | Command-E |
| Strikethrough | Command-Shift-X | | Strikethrough | Command-Shift-S |
| Underline | Command-Shift-U |
| Link | Command-Shift-K | | Link | Command-Shift-K |
| Code block | Command-Shift-E | | Code block | Command-Shift-E |
| Heading | Command-Shift-H | | Heading | Command-Shift-H |
| Quote | Command-Shift-B | | Quote | Command-Shift-B |
| Numbered list | Command-Shift-O | | Numbered list | Command-Shift-O |
| Bullet list | Command-Shift-U | | Bullet list | Command-Shift-L |
| Checklist | Command-Shift-T | | Checklist | Command-Shift-T |
Edit Markdown directly: headings, bold, italic, strikethrough, links, quotes, inline code, and fenced code blocks render without their recognized formatting delimiters. Heading `#` prefixes, emphasis markers, link wrappers and destinations, quote prefixes, and code fences are hidden rather than deleted. Unmatched or escaped punctuation and literal code remain visible; fence lines retain their line breaks. Copying a selection containing hidden syntax and saving notes preserve the original Markdown. Arrow navigation and typing skip hidden syntax, while the formatting strip still toggles it. Bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and native checkbox markers share a marker column, so item text aligns at the same nesting depth even with long numbers; wrapped lines align with the item text, not the marker. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues lists at the same indentation, incrementing numbers and resetting task checkboxes. On an empty item, Return exits one nesting level; repeat to leave the list entirely. Nesting has no fixed depth limit. Prose and fenced code retain ordinary newlines. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note. Edit Markdown directly: headings, bold, italic, underline, strikethrough, links, quotes, and inline code render without their recognized formatting delimiters. Heading `#` prefixes, emphasis markers, underline tags, link wrappers and destinations, and quote prefixes are hidden rather than deleted. Code-block fences and their language labels stay visible, with syntax highlighting inside the block. Inline code uses Conjure’s orange accent text on a lightly tinted inline container; this background is display-only and stays out of saved contents. Unmatched or escaped punctuation and literal code remain visible. Copying a selection containing hidden syntax and saving notes preserve the original Markdown. Arrow navigation and typing skip hidden syntax, while the formatting strip still toggles it. Bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and native checkbox markers share a marker column, so item text aligns at the same nesting depth even with long numbers; wrapped lines align with the item text, not the marker. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues lists at the same indentation, incrementing numbers and resetting task checkboxes. On an empty item, Return exits one nesting level; repeat to leave the list entirely. Nesting has no fixed depth limit. Prose and fenced code retain ordinary newlines. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note.
Typing `# ` through `###### ` at the visible start of an existing heading replaces its level rather than adding to it. Backspace at a heading's visible start removes the preceding newline, leaving a heading at the start of the document unchanged. Removing an empty line above preserves the heading; joining onto nonempty text inherits the preceding paragraph's type (`Text\n## Heading` becomes `TextHeading`). Return at a heading's visible start inserts an empty line above it, keeping its hidden prefix with the heading text, including after Backspace. Both commands keep the caret on the heading's visible line rather than the preceding line. These edits support Undo and retain Markdown serialization inside the existing RTFD autosaves. Typing `# ` through `###### ` at the visible start of an existing heading replaces its level rather than adding to it. Backspace at a heading's visible start removes the preceding newline, leaving a heading at the start of the document unchanged. Removing an empty line above preserves the heading; joining onto nonempty text inherits the preceding paragraph's type (`Text\n## Heading` becomes `TextHeading`). Return at a heading's visible start inserts an empty line above it, keeping its hidden prefix with the heading text, including after Backspace. Both commands keep the caret on the heading's visible line rather than the preceding line. These edits support Undo and retain Markdown serialization inside the existing RTFD autosaves.
+21 -13
View File
@@ -25,9 +25,10 @@ final class NotesController: NSViewController, NSTextViewDelegate {
private let formatting = NSStackView() private let formatting = NSStackView()
private static let formattingKeys: [(key: String, modifiers: NSEvent.ModifierFlags)] = [ private static let formattingKeys: [(key: String, modifiers: NSEvent.ModifierFlags)] = [
("h", [.command, .shift]), ("b", .command), ("i", .command), ("h", [.command, .shift]), ("b", .command), ("i", .command),
("x", [.command, .shift]), ("e", .command), ("k", [.command, .shift]), ("s", [.command, .shift]), ("e", .command), ("k", [.command, .shift]),
("e", [.command, .shift]), ("b", [.command, .shift]), ("e", [.command, .shift]), ("b", [.command, .shift]),
("o", [.command, .shift]), ("u", [.command, .shift]), ("t", [.command, .shift]), ("o", [.command, .shift]), ("l", [.command, .shift]), ("t", [.command, .shift]),
("u", [.command, .shift]),
] ]
private let defaults: UserDefaults private let defaults: UserDefaults
private var store: NotesStore? private var store: NotesStore?
@@ -85,6 +86,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
("strikethrough", "Strikethrough"), ("chevron.left.forwardslash.chevron.right", "Inline code"), ("strikethrough", "Strikethrough"), ("chevron.left.forwardslash.chevron.right", "Inline code"),
("link", "Link"), ("curlybraces.square", "Code block"), ("text.quote", "Quote"), ("link", "Link"), ("curlybraces.square", "Code block"), ("text.quote", "Quote"),
("list.number", "Ordered list"), ("list.bullet", "Bullet list"), ("checklist", "Checklist"), ("list.number", "Ordered list"), ("list.bullet", "Bullet list"), ("checklist", "Checklist"),
("underline", "Underline"),
] ]
for (index, tool) in tools.enumerated() { for (index, tool) in tools.enumerated() {
let button = iconButton(tool.0, label: tool.1, action: #selector(formatNote(_:))) let button = iconButton(tool.0, label: tool.1, action: #selector(formatNote(_:)))
@@ -219,7 +221,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
if selection.length > 0, source.character(at: NSMaxRange(selection) - 1) == 10 { selection.length -= 1 } if selection.length > 0, source.character(at: NSMaxRange(selection) - 1) == 10 { selection.length -= 1 }
} }
let wrappers = [1: ("**", "**"), 2: ("*", "*"), 3: ("~~", "~~"), 4: ("`", "`"), let wrappers = [1: ("**", "**"), 2: ("*", "*"), 3: ("~~", "~~"), 4: ("`", "`"),
5: ("[", "](url)"), 6: ("```\n", "\n```")] 5: ("[", "](url)"), 6: ("```\n", "\n```"), 11: ("<u>", "</u>")]
if let (open, close) = wrappers[sender.tag] { if let (open, close) = wrappers[sender.tag] {
let before = selection.location - open.utf16.count let before = selection.location - open.utf16.count
let after = NSMaxRange(selection) let after = NSMaxRange(selection)
@@ -677,12 +679,14 @@ private final class RoundedImageAttachmentCell: NSTextAttachmentCell {
enum MarkdownHighlighting { enum MarkdownHighlighting {
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow. // ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
static let hiddenMarkup = NSAttributedString.Key("Conjure.hiddenMarkup") static let hiddenMarkup = NSAttributedString.Key("Conjure.hiddenMarkup")
static let codeBackground = NSAttributedString.Key("Conjure.codeBackground")
private static let inlineCode = try! NSRegularExpression(pattern: "(?<![\\\\`])(?<marker>`+)(?<body>[^`\\n]+)\\k<marker>(?!`)") private static let inlineCode = try! NSRegularExpression(pattern: "(?<![\\\\`])(?<marker>`+)(?<body>[^`\\n]+)\\k<marker>(?!`)")
private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [ private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>\\*{2,3}|_{2,3})(?<body>\\S(?:[^\\n]*?\\S)?)\\k<marker>(?![*_])"), [.font: NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize, weight: .bold)]), (try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>\\*{2,3}|_{2,3})(?<body>\\S(?:[^\\n]*?\\S)?)\\k<marker>(?![*_])"), [.font: NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize, weight: .bold)]),
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>[*_])(?<body>[^\\s*_](?:[^*_\\n]*?[^\\s*_])?)\\k<marker>(?![*_])"), [.font: NSFontManager.shared.convert(NotesAppearance.font, toHaveTrait: .italicFontMask)]), (try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>[*_])(?<body>[^\\s*_](?:[^*_\\n]*?[^\\s*_])?)\\k<marker>(?![*_])"), [.font: NSFontManager.shared.convert(NotesAppearance.font, toHaveTrait: .italicFontMask)]),
(try! NSRegularExpression(pattern: "(?<!\\\\)!?\\[(?<body>[^\\]\\n]+)\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]), (try! NSRegularExpression(pattern: "(?<!\\\\)!?\\[(?<body>[^\\]\\n]+)\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]),
(try! NSRegularExpression(pattern: "(?<!\\\\)~~(?<body>\\S(?:[^\\n]*?\\S)?)~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]), (try! NSRegularExpression(pattern: "(?<!\\\\)~~(?<body>\\S(?:[^\\n]*?\\S)?)~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]),
(try! NSRegularExpression(pattern: "(?<!\\\\)<u>(?<body>[^\\n]+?)</u>"), [.underlineStyle: NSUnderlineStyle.single.rawValue]),
(try! NSRegularExpression(pattern: "(?m)^(?:>[ \\t]*)+(?<body>[^\\n]+)"), [:]), (try! NSRegularExpression(pattern: "(?m)^(?:>[ \\t]*)+(?<body>[^\\n]+)"), [:]),
] ]
private static let heading = try! NSRegularExpression(pattern: "(?m)^(#{1,6}) (?<body>.+)$") private static let heading = try! NSRegularExpression(pattern: "(?m)^(#{1,6}) (?<body>.+)$")
@@ -717,6 +721,14 @@ enum MarkdownHighlighting {
return blocks return blocks
} }
static func removeCodeBackground(from contents: NSMutableAttributedString) {
let range = NSRange(location: 0, length: contents.length)
contents.enumerateAttribute(codeBackground, in: range) { value, run, _ in
if value as? Bool == true { contents.removeAttribute(.backgroundColor, range: run) }
}
contents.removeAttribute(codeBackground, range: range)
}
static func apply(to editor: NSTextView) { static func apply(to editor: NSTextView) {
guard let storage = editor.textStorage else { return } guard let storage = editor.textStorage else { return }
let text = editor.string let text = editor.string
@@ -735,6 +747,9 @@ enum MarkdownHighlighting {
} }
} }
storage.removeAttribute(.strikethroughStyle, range: range) storage.removeAttribute(.strikethroughStyle, range: range)
storage.removeAttribute(.underlineStyle, range: range)
removeCodeBackground(from: storage)
storage.addAttribute(codeBackground, value: false, range: range)
storage.addAttribute(hiddenMarkup, value: false, range: range) storage.addAttribute(hiddenMarkup, value: false, range: range)
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text, storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
.paragraphStyle: NotesAppearance.paragraph], range: range) .paragraphStyle: NotesAppearance.paragraph], range: range)
@@ -755,7 +770,9 @@ enum MarkdownHighlighting {
!blocks.contains { NSIntersectionRange($0.range, match.range).length > 0 } !blocks.contains { NSIntersectionRange($0.range, match.range).length > 0 }
} }
for match in codeSpans { for match in codeSpans {
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: NSColor.systemGreen], range: match.range) let body = match.range(withName: "body")
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.accent,
.backgroundColor: SunsetPalette.selectionFill, codeBackground: true], range: body)
hideSyntax(match) hideSyntax(match)
} }
for (expression, attributes) in rules { for (expression, attributes) in rules {
@@ -781,15 +798,6 @@ enum MarkdownHighlighting {
} }
} }
for block in blocks { for block in blocks {
let source = text as NSString
let opening = source.lineRange(for: NSRange(location: block.range.location, length: 0))
let openingText = source.substring(with: opening).trimmingCharacters(in: .newlines)
storage.addAttribute(hiddenMarkup, value: true,
range: NSRange(location: opening.location, length: openingText.utf16.count))
if NSMaxRange(block.body) < NSMaxRange(block.range) {
storage.addAttribute(hiddenMarkup, value: true,
range: NSRange(location: NSMaxRange(block.body), length: NSMaxRange(block.range) - NSMaxRange(block.body)))
}
storage.addAttributes([.font: NotesAppearance.font, storage.addAttributes([.font: NotesAppearance.font,
.foregroundColor: SunsetPalette.secondaryText], range: block.range) .foregroundColor: SunsetPalette.secondaryText], range: block.range)
storage.addAttribute(.foregroundColor, value: SunsetPalette.text, range: block.body) storage.addAttribute(.foregroundColor, value: SunsetPalette.text, range: block.body)
+4
View File
@@ -74,6 +74,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
private static func serialize(_ contents: NSAttributedString, keepingMetadata: Bool = false) -> NSAttributedString { private static func serialize(_ contents: NSAttributedString, keepingMetadata: Bool = false) -> NSAttributedString {
let result = NSMutableAttributedString(attributedString: contents) let result = NSMutableAttributedString(attributedString: contents)
restoreListLayout(in: result) restoreListLayout(in: result)
MarkdownHighlighting.removeCodeBackground(from: result)
result.removeAttribute(MarkdownHighlighting.hiddenMarkup, range: NSRange(location: 0, length: result.length)) result.removeAttribute(MarkdownHighlighting.hiddenMarkup, range: NSRange(location: 0, length: result.length))
var replacements: [(NSRange, ListAttachment)] = [] var replacements: [(NSRange, ListAttachment)] = []
contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: contents.length)) { value, range, _ in contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: contents.length)) { value, range, _ in
@@ -210,6 +211,9 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
} }
attributes.removeValue(forKey: listWhitespace) attributes.removeValue(forKey: listWhitespace)
attributes.removeValue(forKey: MarkdownHighlighting.hiddenMarkup) attributes.removeValue(forKey: MarkdownHighlighting.hiddenMarkup)
if attributes.removeValue(forKey: MarkdownHighlighting.codeBackground) as? Bool == true {
attributes.removeValue(forKey: .backgroundColor)
}
return attributes return attributes
} }
+1 -1
View File
@@ -19,7 +19,7 @@
- exports leave the original note and RTFD autosaves unchanged. - exports leave the original note and RTFD autosaves unchanged.
- [x] bug: pressing backspace at the start of a heading will move it back a line (as expected), but a subsequent return will cause the heading `#`s to appear on the first and the heading text to move down a line. - [x] bug: pressing backspace at the start of a heading will move it back a line (as expected), but a subsequent return will cause the heading `#`s to appear on the first and the heading text to move down a line.
- [x] keep the caret on the heading's visible line after Backspace and Return, not on the preceding line. - [x] keep the caret on the heading's visible line after Backspace and Return, not on the preceding line.
- [ ] improve: - [x] improve:
- add cmd+shift+s for strikethrough and cmd+shift+u for underline - add cmd+shift+s for strikethrough and cmd+shift+u for underline
- don't hide the backticks surrounding a codeblock - don't hide the backticks surrounding a codeblock
- give inline codeblocks (e.g. `some code snippet`) an inline container and an orange text color matching the color scheme of the conjure application - give inline codeblocks (e.g. `some code snippet`) an inline container and an orange text color matching the color scheme of the conjure application
+56 -4
View File
@@ -7,7 +7,7 @@ import Testing
func noteFormattingHidesSyntaxButPreservesMarkdownAndLiteralCode() throws { func noteFormattingHidesSyntaxButPreservesMarkdownAndLiteralCode() throws {
_ = NSApplication.shared _ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 800)) let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 800))
let source = "# Heading\n## Subheading\n*italic* **bold** _emphasis_ __strong__ ~~gone~~ `*literal*`\n> Quote\n[Label](https://example.com)\n```text\n# Code *literal*\n```\nUnmatched * and \\*escaped*" let source = "# Heading\n## Subheading\n*italic* **bold** _emphasis_ __strong__ ~~gone~~ <u>underlined</u> `*literal*`\n> Quote\n[Label](https://example.com)\n```text\n# Code *literal*\n```\nUnmatched * and \\*escaped*"
editor.setMarkdownContents(NSAttributedString(string: source)) editor.setMarkdownContents(NSAttributedString(string: source))
MarkdownHighlighting.apply(to: editor) MarkdownHighlighting.apply(to: editor)
editor.refreshTasks() editor.refreshTasks()
@@ -25,7 +25,7 @@ func noteFormattingHidesSyntaxButPreservesMarkdownAndLiteralCode() throws {
} }
return result return result
} }
#expect(visibleText() == "Heading\nSubheading\nitalic bold emphasis strong gone *literal*\nQuote\nLabel\n\n# Code *literal*\n\nUnmatched * and \\*escaped*") #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) #expect(editor.markdownString == source)
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count)) editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
let pasteboard = NSPasteboard.withUniqueName() let pasteboard = NSPasteboard.withUniqueName()
@@ -247,9 +247,32 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
#expect(try shortcut("b")) #expect(try shortcut("b"))
#expect(editor.markdownString == "fox 🦊") #expect(editor.markdownString == "fox 🦊")
#expect(try shortcut("x", shift: true)) #expect(try shortcut("s", shift: true))
#expect(editor.markdownString == "~~fox 🦊~~") #expect(editor.markdownString == "~~fox 🦊~~")
#expect(try shortcut("x", shift: true)) #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 == "<u>fox 🦊</u>")
#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 == "<u>fox 🦊</u>")
#expect(try NoteMarkdownExport(contents: editor.markdownContents).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(try shortcut("l", shift: true))
#expect(editor.markdownString == "- fox 🦊")
#expect(try shortcut("l", shift: true))
#expect(editor.markdownString == "fox 🦊") #expect(editor.markdownString == "fox 🦊")
#expect(!(try shortcut("b", option: true))) #expect(!(try shortcut("b", option: true)))
#expect(editor.markdownString == "fox 🦊") #expect(editor.markdownString == "fox 🦊")
@@ -272,3 +295,32 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
#expect(controller.save()) #expect(controller.save())
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "fox 🦊") #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` <u>literal</u>\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..<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 literalUnderline = (source 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)
}