Compare commits
8 Commits
090c9045a4
...
c22881b9d1
| Author | SHA1 | Date | |
|---|---|---|---|
| c22881b9d1 | |||
| 70c224e6f1 | |||
| fe252cb648 | |||
| 9e92dc3d7f | |||
| 07dae870f7 | |||
| c53ad3515a | |||
| 04bfd785e7 | |||
| 6f37910797 |
@@ -139,13 +139,29 @@ Commands explicitly declare `Application.Command.presentsView`. The shared dispa
|
|||||||
|
|
||||||
Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it a global shortcut in Settings. Notes starts with a compact 570 × 404 layout inspired by Raycast Notes, with rounded corners and a centered muted note title, while using Conjure’s shared warm gradient background, cream text, and amber list markers and caret. Drag a window edge or corner to resize it; Notes remembers its size across reopening and app restarts, independently of other launcher views. The minimum size is 570 × 260, and restored dimensions fit the current screen’s visible area. The title bar offers Close, **Actions** (⌘), **Browse Notes** (note icon), and **New Note** (+). Browse Notes (Command-P) opens Conjure’s styled, scrollable searchable picker below the title bar, shared with translation language selection—not a native menu. Fuzzy-search note titles, then select with Up/Down, Control-J/K, and Return or by clicking a note. A checkmark marks the current note, which is selected and scrolled into view on opening. Reopening Notes restores the last selection. Each note’s first nonempty line becomes its title; duplicate titles remain independently selectable.
|
Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it a global shortcut in Settings. Notes starts with a compact 570 × 404 layout inspired by Raycast Notes, with rounded corners and a centered muted note title, while using Conjure’s shared warm gradient background, cream text, and amber list markers and caret. Drag a window edge or corner to resize it; Notes remembers its size across reopening and app restarts, independently of other launcher views. The minimum size is 570 × 260, and restored dimensions fit the current screen’s visible area. The title bar offers Close, **Actions** (⌘), **Browse Notes** (note icon), and **New Note** (+). Browse Notes (Command-P) opens Conjure’s styled, scrollable searchable picker below the title bar, shared with translation language selection—not a native menu. Fuzzy-search note titles, then select with Up/Down, Control-J/K, and Return or by clicking a note. A checkmark marks the current note, which is selected and scrolled into view on opening. Reopening Notes restores the last selection. Each note’s first nonempty line becomes its title; duplicate titles remain independently selectable.
|
||||||
|
|
||||||
Notes uses a standard macOS window with system-inherited appearance and only the native Close button visible; minimize and zoom are hidden. The editor uses monospaced text, including Markdown headings, bold, and italic, while retaining Conjure’s warm palette. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless with their existing dark appearance.
|
Notes uses a standard macOS window with system-inherited appearance and only the native Close button visible; minimize and zoom are hidden. The editor uses 15-point monospaced body text, including bold, italic, and code, while retaining Conjure’s warm palette. Heading sizes decrease from H1 through H6 (26, 23, 21, 19, 17, and 15 points); inline emphasis retains the heading’s size, and fenced code is not treated as headings. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless with their existing dark appearance.
|
||||||
|
|
||||||
Actions (Command-K) opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P), **New Note** (Command-N), and **Delete Note**. Delete Note asks for confirmation before permanently deleting the currently selected note and its embedded images. Delete is focused by default: Return confirms and Escape cancels; deletion cannot be undone. After deletion, Notes selects a remaining note or creates a fresh blank note if none remain. Save or deletion failures preserve the current note and editor buffer. Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its title-bar controls are covered. Escape or clicking outside closes an open popup and returns focus to the editor without leaving Notes; another Escape dismisses the launcher. Control-J/K navigates only while a popup is open, preserving normal editor commands when it is closed.
|
Actions (Command-K) opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P), **New Note** (Command-N), and **Delete Note**. Delete Note asks for confirmation before permanently deleting the currently selected note and its embedded images. Delete is focused by default: Return confirms and Escape cancels; deletion cannot be undone. After deletion, Notes selects a remaining note or creates a fresh blank note if none remain. Save or deletion failures preserve the current note and editor buffer. Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its title-bar controls are covered. Escape or clicking outside closes an open popup and returns focus to the editor without leaving Notes; another Escape dismisses the launcher. Control-J/K navigates only while a popup is open, preserving normal editor commands when it is closed.
|
||||||
|
|
||||||
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 strip’s × hides formatting controls; the text-format icon restores them. Successful autosaves stay unobtrusive; only save, load, or deletion errors appear below the editor.
|
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, or deletion errors appear below the editor.
|
||||||
|
|
||||||
Edit Markdown directly: headings, bold, italic, lists, links, inline code, and fenced code blocks are highlighted while the source remains visible, except for list markers rendered as inline tokens. 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.
|
Formatting shortcuts use the same actions as the bottom strip. They apply to the editor’s current selection or caret, never to text in an open picker. Hover over a formatting button to see its shortcut. Command-K remains Actions.
|
||||||
|
|
||||||
|
| Formatting | Shortcut |
|
||||||
|
| --- | --- |
|
||||||
|
| Bold | Command-B |
|
||||||
|
| Italic | Command-I |
|
||||||
|
| Inline code | Command-E |
|
||||||
|
| Strikethrough | Command-Shift-X |
|
||||||
|
| Link | Command-Shift-K |
|
||||||
|
| Code block | Command-Shift-E |
|
||||||
|
| Heading | Command-Shift-H |
|
||||||
|
| Quote | Command-Shift-B |
|
||||||
|
| Numbered list | Command-Shift-O |
|
||||||
|
| Bullet list | Command-Shift-U |
|
||||||
|
| 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.
|
||||||
|
|
||||||
Tab anywhere on a bullet, numbered, or task item indents that line by two spaces, preserving the caret or selection within the line. Shift-Tab removes one leading tab or up to two spaces; at the root it does nothing and never removes the item. These commands change only the current line’s indentation, not nested children. Moving an item into or out of a numbered list updates the affected sibling sequences: a new nested sequence starts at 1, an existing sequence continues from its preceding sibling, and later siblings are renumbered in the saved and copied Markdown too. Existing standalone starting numbers and each marker’s delimiter are preserved. Indentation and numbering changes undo together. Return on a whitespace-only item outdents to its actual parent, or removes one indent unit when there is no parent; only Return on an empty root item removes the marker. Repeated Return therefore exits even a standalone nested list one level at a time.
|
Tab anywhere on a bullet, numbered, or task item indents that line by two spaces, preserving the caret or selection within the line. Shift-Tab removes one leading tab or up to two spaces; at the root it does nothing and never removes the item. These commands change only the current line’s indentation, not nested children. Moving an item into or out of a numbered list updates the affected sibling sequences: a new nested sequence starts at 1, an existing sequence continues from its preceding sibling, and later siblings are renumbered in the saved and copied Markdown too. Existing standalone starting numbers and each marker’s delimiter are preserved. Indentation and numbering changes undo together. Return on a whitespace-only item outdents to its actual parent, or removes one indent unit when there is no parent; only Return on an empty root item removes the marker. Repeated Return therefore exits even a standalone nested list one level at a time.
|
||||||
|
|
||||||
@@ -155,7 +171,7 @@ When typing a list marker before existing whitespace on an otherwise blank line,
|
|||||||
|
|
||||||
List markers are atomic inline tokens, including nested bullets and numbers; task markers such as `- [ ]` and `- [x]` use native checkboxes. Left/Right crosses a marker and its single required separator (a space or tab) in one visible step, between the marker's start and the item body. Only that separator is hidden: additional spaces and tabs are visible body text with normal cursor stops. Shift-Left/Right uses the same boundaries and preserves the selection anchor when extending or reversing direction. Visible indentation and body whitespace retain normal character stops. Selecting only a token copies only its original marker; a keyboard selection across the marker also includes its required separator. Click a checkbox to toggle completion without moving the selection. Backspace at the start of the body expands the original marker and deletes its final character, preserving the separator; forward Delete immediately before the token expands the marker and deletes its first character. Each edit is one Undo step. Removing an item's preceding newline after prose makes its marker literal inline text; joining onto an empty or indentation-only line keeps a valid list token. Saved notes and copied text retain the original Markdown spelling, numbers, case, and spacing, alongside embedded images; alignment formatting is display-only. Thematic breaks and list syntax inside fenced code or ordinary prose stay literal.
|
List markers are atomic inline tokens, including nested bullets and numbers; task markers such as `- [ ]` and `- [x]` use native checkboxes. Left/Right crosses a marker and its single required separator (a space or tab) in one visible step, between the marker's start and the item body. Only that separator is hidden: additional spaces and tabs are visible body text with normal cursor stops. Shift-Left/Right uses the same boundaries and preserves the selection anchor when extending or reversing direction. Visible indentation and body whitespace retain normal character stops. Selecting only a token copies only its original marker; a keyboard selection across the marker also includes its required separator. Click a checkbox to toggle completion without moving the selection. Backspace at the start of the body expands the original marker and deletes its final character, preserving the separator; forward Delete immediately before the token expands the marker and deletes its first character. Each edit is one Undo step. Removing an item's preceding newline after prose makes its marker literal inline text; joining onto an empty or indentation-only line keeps a valid list token. Saved notes and copied text retain the original Markdown spelling, numbers, case, and spacing, alongside embedded images; alignment formatting is display-only. Thematic breaks and list syntax inside fenced code or ordinary prose stay literal.
|
||||||
|
|
||||||
Unordered markers display as `•`; numbered markers display their original number and delimiter, right-aligned before the item text. The stored and copied Markdown retains the original bullet spelling.
|
Unordered markers display as `•`; bullets and checkboxes are centered in the shared marker column. Checkboxes align vertically with the text’s cap-height center, using its baseline rather than the attachment frame. Numbered markers display their original number and delimiter, right-aligned before the item text and resting on the same text baseline. Item text and wrapped lines retain their shared alignment, including nested lists and columns widened by longer numbers. The stored and copied Markdown retains the original bullet spelling.
|
||||||
|
|
||||||
Typing, pasting, and Return at the visible start of an item use the position after its preserved Markdown separator. Continuing to type after a marker converts therefore keeps the item rendered, rather than inserting characters into the marker syntax.
|
Typing, pasting, and Return at the visible start of an item use the position after its preserved Markdown separator. Continuing to type after a marker converts therefore keeps the item rendered, rather than inserting characters into the marker syntax.
|
||||||
|
|
||||||
|
|||||||
@@ -1245,7 +1245,7 @@ final class LauncherWindow: NSWindow {
|
|||||||
var numberHintsChanged: (() -> Void)?
|
var numberHintsChanged: (() -> Void)?
|
||||||
var launchNumber: ((Int) -> Void)?
|
var launchNumber: ((Int) -> Void)?
|
||||||
var commandNavigation: ((String) -> Bool)?
|
var commandNavigation: ((String) -> Bool)?
|
||||||
var commandShortcut: ((String) -> Bool)?
|
var commandShortcut: ((NSEvent) -> Bool)?
|
||||||
var usesTextNavigation: (() -> Bool)?
|
var usesTextNavigation: (() -> Bool)?
|
||||||
private var numberShortcuts = CommandNumberShortcuts()
|
private var numberShortcuts = CommandNumberShortcuts()
|
||||||
private var commandHoldTask: Task<Void, Never>?
|
private var commandHoldTask: Task<Void, Never>?
|
||||||
@@ -1373,9 +1373,7 @@ final class LauncherWindow: NSWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||||
if attachedSheet == nil,
|
if attachedSheet == nil, commandShortcut?(event) == true {
|
||||||
event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command,
|
|
||||||
commandShortcut?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return super.performKeyEquivalent(with: event)
|
return super.performKeyEquivalent(with: event)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Highlighter
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
enum NotesAppearance {
|
enum NotesAppearance {
|
||||||
static let font = NSFont.monospacedSystemFont(ofSize: 17, weight: .regular)
|
static let font = NSFont.monospacedSystemFont(ofSize: 15, weight: .regular)
|
||||||
static let paragraph: NSParagraphStyle = {
|
static let paragraph: NSParagraphStyle = {
|
||||||
let style = NSMutableParagraphStyle()
|
let style = NSMutableParagraphStyle()
|
||||||
style.lineSpacing = 5
|
style.lineSpacing = 5
|
||||||
@@ -22,6 +22,12 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
private let status = NSTextField(labelWithString: "")
|
private let status = NSTextField(labelWithString: "")
|
||||||
private let noteTitle = NSTextField(labelWithString: "Untitled Note")
|
private let noteTitle = NSTextField(labelWithString: "Untitled Note")
|
||||||
private let formatting = NSStackView()
|
private let formatting = NSStackView()
|
||||||
|
private static let formattingKeys: [(key: String, modifiers: NSEvent.ModifierFlags)] = [
|
||||||
|
("h", [.command, .shift]), ("b", .command), ("i", .command),
|
||||||
|
("x", [.command, .shift]), ("e", .command), ("k", [.command, .shift]),
|
||||||
|
("e", [.command, .shift]), ("b", [.command, .shift]),
|
||||||
|
("o", [.command, .shift]), ("u", [.command, .shift]), ("t", [.command, .shift]),
|
||||||
|
]
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
private var store: NotesStore?
|
private var store: NotesStore?
|
||||||
private var selectedID: UUID?
|
private var selectedID: UUID?
|
||||||
@@ -86,16 +92,14 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
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(_:)))
|
||||||
button.tag = index
|
button.tag = index
|
||||||
|
let shortcut = Self.formattingKeys[index]
|
||||||
|
button.toolTip = "\(tool.1) (⌘\(shortcut.modifiers.contains(.shift) ? "⇧" : "")\(shortcut.key.uppercased()))"
|
||||||
formatting.addArrangedSubview(button)
|
formatting.addArrangedSubview(button)
|
||||||
}
|
}
|
||||||
formatting.spacing = 8
|
formatting.spacing = 8
|
||||||
let toggle = iconButton("xmark.circle.fill", label: "Hide formatting", action: #selector(toggleFormatting(_:)))
|
let footer = NSStackView(views: [formatting, NSView()])
|
||||||
let separator = NSBox()
|
|
||||||
separator.boxType = .separator
|
|
||||||
let footer = NSStackView(views: [formatting, NSView(), separator, toggle])
|
|
||||||
footer.alignment = .centerY
|
footer.alignment = .centerY
|
||||||
footer.spacing = 8
|
footer.spacing = 8
|
||||||
separator.heightAnchor.constraint(equalToConstant: 22).isActive = true
|
|
||||||
editor.delegate = self
|
editor.delegate = self
|
||||||
editor.isRichText = true
|
editor.isRichText = true
|
||||||
editor.importsGraphics = true
|
editor.importsGraphics = true
|
||||||
@@ -188,13 +192,6 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@objc private func toggleFormatting(_ sender: NSButton) {
|
|
||||||
formatting.isHidden.toggle()
|
|
||||||
sender.image = NSImage(systemSymbolName: formatting.isHidden ? "textformat" : "xmark.circle.fill", accessibilityDescription: nil)
|
|
||||||
sender.toolTip = formatting.isHidden ? "Show formatting" : "Hide formatting"
|
|
||||||
sender.setAccessibilityLabel(sender.toolTip)
|
|
||||||
focusEditor()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func formatNote(_ sender: NSButton) {
|
@objc private func formatNote(_ sender: NSButton) {
|
||||||
guard editor.isEditable else { return }
|
guard editor.isEditable else { return }
|
||||||
@@ -210,9 +207,16 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
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)
|
||||||
|
var stars = 0
|
||||||
|
if open == "*" {
|
||||||
|
while selection.location > stars, after + stars < source.length,
|
||||||
|
source.character(at: selection.location - stars - 1) == 42,
|
||||||
|
source.character(at: after + stars) == 42 { stars += 1 }
|
||||||
|
}
|
||||||
if before >= 0, after + close.utf16.count <= source.length,
|
if before >= 0, after + close.utf16.count <= source.length,
|
||||||
source.substring(with: NSRange(location: before, length: open.utf16.count)) == open,
|
source.substring(with: NSRange(location: before, length: open.utf16.count)) == open,
|
||||||
source.substring(with: NSRange(location: after, length: close.utf16.count)) == close {
|
source.substring(with: NSRange(location: after, length: close.utf16.count)) == close,
|
||||||
|
open != "*" || stars % 2 == 1 {
|
||||||
let body = contents.attributedSubstring(from: selection)
|
let body = contents.attributedSubstring(from: selection)
|
||||||
selection = NSRange(location: before, length: selection.length + open.utf16.count + close.utf16.count)
|
selection = NSRange(location: before, length: selection.length + open.utf16.count + close.utf16.count)
|
||||||
replaceMarkdown(body, range: selection, selection: NSRange(location: before, length: body.length))
|
replaceMarkdown(body, range: selection, selection: NSRange(location: before, length: body.length))
|
||||||
@@ -479,8 +483,17 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCommandKey(_ key: String) -> Bool {
|
func handleCommandKey(_ event: NSEvent) -> Bool {
|
||||||
guard store != nil else { return false }
|
guard store != nil, view.window?.attachedSheet == nil else { return false }
|
||||||
|
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
||||||
|
let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
|
||||||
|
if let index = Self.formattingKeys.firstIndex(where: { $0.key == key && $0.modifiers == modifiers }) {
|
||||||
|
guard editor.isEditable, !actions.isShown, view.window?.firstResponder === editor,
|
||||||
|
let button = formatting.arrangedSubviews[index] as? NSButton else { return false }
|
||||||
|
formatNote(button)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard modifiers == .command else { return false }
|
||||||
switch key {
|
switch key {
|
||||||
case "p": browseNotes()
|
case "p": browseNotes()
|
||||||
case "k": showActions()
|
case "k": showActions()
|
||||||
@@ -583,11 +596,10 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class NotesView: NSView {
|
private final class NotesView: NSView {
|
||||||
var commandShortcut: ((String) -> Bool)?
|
var commandShortcut: ((NSEvent) -> Bool)?
|
||||||
|
|
||||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||||
if event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command,
|
if commandShortcut?(event) == true {
|
||||||
commandShortcut?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return super.performKeyEquivalent(with: event)
|
return super.performKeyEquivalent(with: event)
|
||||||
@@ -598,18 +610,23 @@ private final class NotesView: NSView {
|
|||||||
@MainActor
|
@MainActor
|
||||||
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")
|
||||||
|
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: "(?m)^#{1,6} .+$"), [.font: NSFont.monospacedSystemFont(ofSize: 23, weight: .semibold)]),
|
(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: "\\*\\*[^\\n]+?\\*\\*|__[^\\n]+?__"), [.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .bold)]),
|
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>[*_])(?<body>[^\\s*_](?:[^*_\\n]*?[^\\s*_])?)\\k<marker>(?![*_])"), [.font: NSFontManager.shared.convert(NotesAppearance.font, toHaveTrait: .italicFontMask)]),
|
||||||
(try! NSRegularExpression(pattern: "(?<!\\*)\\*(?!\\*)[^*\\n]+\\*(?!\\*)|(?<!_)_(?!_)[^_\\n]+_(?!_)"), [.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: "!?\\[[^\\]\\n]+\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]),
|
(try! NSRegularExpression(pattern: "(?<!\\\\)~~(?<body>\\S(?:[^\\n]*?\\S)?)~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]),
|
||||||
(try! NSRegularExpression(pattern: "~~[^\\n]+?~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]),
|
(try! NSRegularExpression(pattern: "(?m)^(?:>[ \\t]*)+(?<body>[^\\n]+)"), [:]),
|
||||||
(try! NSRegularExpression(pattern: "`[^`\\n]+`"), [.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .regular), .foregroundColor: NSColor.systemGreen]),
|
|
||||||
]
|
]
|
||||||
|
private static let heading = try! NSRegularExpression(pattern: "(?m)^(#{1,6}) (?<body>.+)$")
|
||||||
|
private static let headingFonts = [11.0, 8, 6, 4, 2, 0].map {
|
||||||
|
NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize + $0, weight: .semibold)
|
||||||
|
}
|
||||||
|
|
||||||
private static let highlighter: Highlighter = {
|
private static let highlighter: Highlighter = {
|
||||||
guard let value = Highlighter() else { preconditionFailure("Missing bundled syntax highlighter") }
|
guard let value = Highlighter() else { preconditionFailure("Missing bundled syntax highlighter") }
|
||||||
value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: 17)
|
value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: NotesAppearance.font.pointSize)
|
||||||
return value
|
return value
|
||||||
}()
|
}()
|
||||||
private static let openingFence = try! NSRegularExpression(pattern: "(?m)^ {0,3}(`{3,}|~{3,})([^\\r\\n]*)\\r?\\n")
|
private static let openingFence = try! NSRegularExpression(pattern: "(?m)^ {0,3}(`{3,}|~{3,})([^\\r\\n]*)\\r?\\n")
|
||||||
@@ -649,17 +666,62 @@ enum MarkdownHighlighting {
|
|||||||
if scale < 1 { image.size = NSSize(width: size.width * scale, height: size.height * scale) }
|
if scale < 1 { image.size = NSSize(width: size.width * scale, height: size.height * scale) }
|
||||||
}
|
}
|
||||||
storage.removeAttribute(.strikethroughStyle, range: range)
|
storage.removeAttribute(.strikethroughStyle, 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)
|
||||||
let blocks = codeBlocks(in: text)
|
let blocks = codeBlocks(in: text)
|
||||||
|
func hideSyntax(_ match: NSTextCheckingResult) {
|
||||||
|
let body = match.range(withName: "body")
|
||||||
|
guard body.location != NSNotFound else { return }
|
||||||
|
if body.location > match.range.location {
|
||||||
|
storage.addAttribute(hiddenMarkup, value: true,
|
||||||
|
range: NSRange(location: match.range.location, length: body.location - match.range.location))
|
||||||
|
}
|
||||||
|
if NSMaxRange(body) < NSMaxRange(match.range) {
|
||||||
|
storage.addAttribute(hiddenMarkup, value: true,
|
||||||
|
range: NSRange(location: NSMaxRange(body), length: NSMaxRange(match.range) - NSMaxRange(body)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let codeSpans = inlineCode.matches(in: text, range: range).filter { match in
|
||||||
|
!blocks.contains { NSIntersectionRange($0.range, match.range).length > 0 }
|
||||||
|
}
|
||||||
|
for match in codeSpans {
|
||||||
|
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: NSColor.systemGreen], range: match.range)
|
||||||
|
hideSyntax(match)
|
||||||
|
}
|
||||||
for (expression, attributes) in rules {
|
for (expression, attributes) in rules {
|
||||||
for match in expression.matches(in: text, range: range) {
|
for match in expression.matches(in: text, range: range) {
|
||||||
guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
|
guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }),
|
||||||
|
!codeSpans.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
|
||||||
storage.addAttributes(attributes, range: match.range)
|
storage.addAttributes(attributes, range: match.range)
|
||||||
|
if match.range(withName: "marker").length == 3,
|
||||||
|
let font = attributes[.font] as? NSFont {
|
||||||
|
storage.addAttribute(.font, value: NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask),
|
||||||
|
range: match.range)
|
||||||
|
}
|
||||||
|
hideSyntax(match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for match in heading.matches(in: text, range: range) {
|
||||||
|
guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
|
||||||
|
hideSyntax(match)
|
||||||
|
let font = headingFonts[match.range(at: 1).length - 1]
|
||||||
|
storage.enumerateAttribute(.font, in: match.range) { value, run, _ in
|
||||||
|
let traits = (value as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? []
|
||||||
|
storage.addAttribute(.font, value: NSFontManager.shared.convert(font, toHaveTrait: traits), range: run)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for block in blocks {
|
for block in blocks {
|
||||||
storage.addAttributes([.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .regular),
|
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,
|
||||||
.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)
|
||||||
let code = (text as NSString).substring(with: block.body)
|
let code = (text as NSString).substring(with: block.body)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ private final class ListAttachment: NSTextAttachment {
|
|||||||
let cell = ListAttachmentCell(textCell: isTask ? "" : (source.length == 1 ? "•" : source.string))
|
let cell = ListAttachmentCell(textCell: isTask ? "" : (source.length == 1 ? "•" : source.string))
|
||||||
cell.attributedStringValue = NSAttributedString(string: cell.stringValue,
|
cell.attributedStringValue = NSAttributedString(string: cell.stringValue,
|
||||||
attributes: [.font: NotesAppearance.font, .foregroundColor: SunsetPalette.accent])
|
attributes: [.font: NotesAppearance.font, .foregroundColor: SunsetPalette.accent])
|
||||||
cell.alignment = .right
|
cell.alignment = source.length == 1 ? .center : .right
|
||||||
attachmentCell = cell
|
attachmentCell = cell
|
||||||
bounds = NSRect(x: 0, y: -4, width: 22, height: 21)
|
bounds = NSRect(x: 0, y: -4, width: 22, height: 21)
|
||||||
}
|
}
|
||||||
@@ -35,8 +35,10 @@ private final class ListAttachmentCell: NSTextAttachmentCell {
|
|||||||
guard !stringValue.isEmpty else { return }
|
guard !stringValue.isEmpty else { return }
|
||||||
let label = attributedStringValue
|
let label = attributedStringValue
|
||||||
let size = label.size()
|
let size = label.size()
|
||||||
label.draw(at: NSPoint(x: cellFrame.maxX - 4 - size.width,
|
let x = alignment == .center ? cellFrame.midX - size.width / 2 : cellFrame.maxX - 4 - size.width
|
||||||
y: cellFrame.midY - size.height / 2))
|
let y = alignment == .center ? cellFrame.midY - size.height / 2
|
||||||
|
: cellFrame.maxY + cellBaselineOffset().y - ceil(NotesAppearance.font.ascender)
|
||||||
|
label.draw(at: NSPoint(x: x, y: y))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +63,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)
|
||||||
|
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
|
||||||
if let task = value as? ListAttachment { replacements.append((range, task)) }
|
if let task = value as? ListAttachment { replacements.append((range, task)) }
|
||||||
@@ -195,6 +198,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
attributes[.paragraphStyle] = original as? NSParagraphStyle
|
attributes[.paragraphStyle] = original as? NSParagraphStyle
|
||||||
}
|
}
|
||||||
attributes.removeValue(forKey: listWhitespace)
|
attributes.removeValue(forKey: listWhitespace)
|
||||||
|
attributes.removeValue(forKey: MarkdownHighlighting.hiddenMarkup)
|
||||||
return attributes
|
return attributes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +212,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
|
|
||||||
func markdownInsertionRange(for rendered: NSRange) -> NSRange {
|
func markdownInsertionRange(for rendered: NSRange) -> NSRange {
|
||||||
guard rendered.length == 0 else { return markdownRange(for: rendered) }
|
guard rendered.length == 0 else { return markdownRange(for: rendered) }
|
||||||
let body = listWhitespaceBoundary(from: rendered.location, forward: true)
|
let body = hiddenBoundary(from: rendered.location, forward: true)
|
||||||
return markdownRange(for: NSRange(location: body, length: 0))
|
return markdownRange(for: NSRange(location: body, length: 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,30 +301,35 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
}
|
}
|
||||||
refreshTasks()
|
refreshTasks()
|
||||||
let caret = renderedRange(for: NSRange(location: range.location + replacement.length, length: 0))
|
let caret = renderedRange(for: NSRange(location: range.location + replacement.length, length: 0))
|
||||||
setSelectedRange(NSRange(location: listWhitespaceBoundary(from: caret.location, forward: true), length: 0))
|
setSelectedRange(NSRange(location: hiddenBoundary(from: caret.location, forward: true), length: 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
override func deleteBackward(_ sender: Any?) {
|
override func deleteBackward(_ sender: Any?) {
|
||||||
let selection = selectedRange()
|
let selection = selectedRange()
|
||||||
let boundary = listWhitespaceBoundary(from: selection.location, forward: false)
|
let boundary = hiddenBoundary(from: selection.location, forward: false)
|
||||||
if selection.length == 0, let task = tasks.first(where: { NSMaxRange($0.range) == boundary }) {
|
if selection.length == 0, let task = tasks.first(where: { NSMaxRange($0.range) == boundary }) {
|
||||||
expand(task, backwards: true)
|
expand(task, backwards: true)
|
||||||
} else if selection.length > 0 {
|
} else if selection.length > 0 {
|
||||||
replaceMarkdown("", range: markdownRange(for: selection))
|
replaceMarkdown("", range: markdownRange(for: selection))
|
||||||
} else if selection.location > 0 {
|
} else if selection.location > 0 {
|
||||||
let range = (string as NSString).rangeOfComposedCharacterSequence(at: selection.location - 1)
|
let source = string as NSString
|
||||||
|
let atLineStart = boundary == 0 || source.character(at: boundary - 1) == 10
|
||||||
|
let range = boundary < selection.location && atLineStart
|
||||||
|
? NSRange(location: boundary, length: selection.location - boundary)
|
||||||
|
: source.rangeOfComposedCharacterSequence(at: max(0, boundary - 1))
|
||||||
replaceMarkdown("", range: markdownRange(for: range))
|
replaceMarkdown("", range: markdownRange(for: range))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func deleteForward(_ sender: Any?) {
|
override func deleteForward(_ sender: Any?) {
|
||||||
let selection = selectedRange()
|
let selection = selectedRange()
|
||||||
|
let boundary = hiddenBoundary(from: selection.location, forward: true)
|
||||||
if selection.length == 0, let task = tasks.first(where: { $0.range.location == selection.location }) {
|
if selection.length == 0, let task = tasks.first(where: { $0.range.location == selection.location }) {
|
||||||
expand(task, backwards: false)
|
expand(task, backwards: false)
|
||||||
} else if selection.length > 0 {
|
} else if selection.length > 0 {
|
||||||
replaceMarkdown("", range: markdownRange(for: selection))
|
replaceMarkdown("", range: markdownRange(for: selection))
|
||||||
} else if selection.location < string.utf16.count {
|
} else if boundary < string.utf16.count {
|
||||||
let range = (string as NSString).rangeOfComposedCharacterSequence(at: selection.location)
|
let range = (string as NSString).rangeOfComposedCharacterSequence(at: boundary)
|
||||||
replaceMarkdown("", range: markdownRange(for: range))
|
replaceMarkdown("", range: markdownRange(for: range))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,24 +344,25 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
if !backwards { setSelectedRange(NSRange(location: task.range.location, length: 0)) }
|
if !backwards { setSelectedRange(NSRange(location: task.range.location, length: 0)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only separator glyphs are null; indentation and body whitespace remain native stops.
|
// Skip display-only syntax without changing the source offsets used by copy and save.
|
||||||
private func listWhitespaceBoundary(from position: Int, forward: Bool) -> Int {
|
private func hiddenBoundary(from position: Int, forward: Bool) -> Int {
|
||||||
guard let storage = textStorage else { return position }
|
guard let storage = textStorage else { return position }
|
||||||
let index = forward ? position : position - 1
|
let index = forward ? position : position - 1
|
||||||
guard index >= 0, index < storage.length else { return position }
|
guard index >= 0, index < storage.length else { return position }
|
||||||
var range = NSRange()
|
func boundary(for key: NSAttributedString.Key) -> Int? {
|
||||||
guard storage.attribute(listWhitespace, at: index, effectiveRange: &range) as? Bool == true else {
|
var range = NSRange()
|
||||||
return position
|
guard storage.attribute(key, at: index, effectiveRange: &range) as? Bool == true else { return nil }
|
||||||
|
return forward ? NSMaxRange(range) : range.location
|
||||||
}
|
}
|
||||||
return forward ? NSMaxRange(range) : range.location
|
return boundary(for: listWhitespace) ?? boundary(for: MarkdownHighlighting.hiddenMarkup) ?? position
|
||||||
}
|
}
|
||||||
|
|
||||||
private func visualListBoundary(from position: Int, forward: Bool) -> Int? {
|
private func visualListBoundary(from position: Int, forward: Bool) -> Int? {
|
||||||
let boundary = listWhitespaceBoundary(from: position, forward: forward)
|
let boundary = hiddenBoundary(from: position, forward: forward)
|
||||||
guard let task = tasks.first(where: {
|
guard let task = tasks.first(where: {
|
||||||
forward ? $0.range.location == boundary : NSMaxRange($0.range) == boundary
|
forward ? $0.range.location == boundary : NSMaxRange($0.range) == boundary
|
||||||
}) else { return nil }
|
}) else { return nil }
|
||||||
return forward ? listWhitespaceBoundary(from: NSMaxRange(task.range), forward: true) : task.range.location
|
return forward ? hiddenBoundary(from: NSMaxRange(task.range), forward: true) : task.range.location
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setSelectedRange(_ charRange: NSRange) {
|
override func setSelectedRange(_ charRange: NSRange) {
|
||||||
@@ -412,9 +422,9 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
|
|
||||||
// Native movement supplies composed-character, bidi, and line-boundary behavior.
|
// Native movement supplies composed-character, bidi, and line-boundary behavior.
|
||||||
// Starting inside a null run already means "after the marker", so advance into the body.
|
// Starting inside a null run already means "after the marker", so advance into the body.
|
||||||
let origin = listWhitespaceBoundary(from: head, forward: true)
|
let origin = hiddenBoundary(from: head, forward: true)
|
||||||
if let nativeHead, origin == head {
|
if let nativeHead, origin == head {
|
||||||
let target = listWhitespaceBoundary(from: nativeHead, forward: true)
|
let target = hiddenBoundary(from: nativeHead, forward: true)
|
||||||
if target != nativeHead { setHorizontalSelection(anchor: anchor, head: target) }
|
if target != nativeHead { setHorizontalSelection(anchor: anchor, head: target) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -424,7 +434,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
moveNatively(forward: forward, selecting: selecting, sender: sender)
|
moveNatively(forward: forward, selecting: selecting, sender: sender)
|
||||||
let moved = selectedRange()
|
let moved = selectedRange()
|
||||||
let newHead = selecting && moved.location == origin ? NSMaxRange(moved) : moved.location
|
let newHead = selecting && moved.location == origin ? NSMaxRange(moved) : moved.location
|
||||||
let target = listWhitespaceBoundary(from: newHead, forward: true)
|
let target = hiddenBoundary(from: newHead, forward: true)
|
||||||
if selecting {
|
if selecting {
|
||||||
if tracked != nil || nativeHead != nil || target != newHead || origin != head {
|
if tracked != nil || nativeHead != nil || target != newHead || origin != head {
|
||||||
setHorizontalSelection(anchor: anchor, head: target)
|
setHorizontalSelection(anchor: anchor, head: target)
|
||||||
@@ -579,13 +589,15 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
properties props: UnsafePointer<NSLayoutManager.GlyphProperty>,
|
properties props: UnsafePointer<NSLayoutManager.GlyphProperty>,
|
||||||
characterIndexes charIndexes: UnsafePointer<Int>, font aFont: NSFont,
|
characterIndexes charIndexes: UnsafePointer<Int>, font aFont: NSFont,
|
||||||
forGlyphRange glyphRange: NSRange) -> Int {
|
forGlyphRange glyphRange: NSRange) -> Int {
|
||||||
guard let storage = layoutManager.textStorage,
|
guard let storage = layoutManager.textStorage else { return 0 }
|
||||||
(0..<glyphRange.length).contains(where: {
|
func hidden(_ index: Int) -> Bool {
|
||||||
storage.attribute(listWhitespace, at: charIndexes[$0], effectiveRange: nil) as? Bool == true
|
storage.attribute(listWhitespace, at: index, effectiveRange: nil) as? Bool == true
|
||||||
}) else { return 0 }
|
|| storage.attribute(MarkdownHighlighting.hiddenMarkup, at: index, effectiveRange: nil) as? Bool == true
|
||||||
|
}
|
||||||
|
guard (0..<glyphRange.length).contains(where: { hidden(charIndexes[$0]) }) else { return 0 }
|
||||||
var properties = Array(UnsafeBufferPointer(start: props, count: glyphRange.length))
|
var properties = Array(UnsafeBufferPointer(start: props, count: glyphRange.length))
|
||||||
for index in properties.indices {
|
for index in properties.indices {
|
||||||
if storage.attribute(listWhitespace, at: charIndexes[index], effectiveRange: nil) as? Bool == true {
|
if hidden(charIndexes[index]) {
|
||||||
properties[index] = .null
|
properties[index] = .null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -600,14 +612,19 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func positionCheckboxes() {
|
private func positionCheckboxes() {
|
||||||
guard !checkboxes.isEmpty, let layoutManager, let textContainer else { return }
|
guard !checkboxes.isEmpty, let layoutManager, let textContainer, let textStorage else { return }
|
||||||
layoutManager.ensureLayout(for: textContainer)
|
layoutManager.ensureLayout(for: textContainer)
|
||||||
for checkbox in checkboxes {
|
for checkbox in checkboxes {
|
||||||
let task = tasks[checkbox.tag]
|
let task = tasks[checkbox.tag]
|
||||||
let glyphs = layoutManager.glyphRange(forCharacterRange: task.range, actualCharacterRange: nil)
|
let glyphs = layoutManager.glyphRange(forCharacterRange: task.range, actualCharacterRange: nil)
|
||||||
let rect = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer)
|
let rect = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer)
|
||||||
.offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y)
|
.offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y)
|
||||||
checkbox.setFrameOrigin(NSPoint(x: rect.maxX - 18, y: rect.midY - checkbox.frame.height / 2))
|
let bodyGlyph = layoutManager.glyphIndexForCharacter(at: min(NSMaxRange(task.range) + 1, textStorage.length - 1))
|
||||||
|
let fragment = layoutManager.lineFragmentRect(forGlyphAt: bodyGlyph, effectiveRange: nil)
|
||||||
|
let baseline = textContainerOrigin.y + fragment.minY + layoutManager.location(forGlyphAt: bodyGlyph).y
|
||||||
|
let centerY = baseline - NotesAppearance.font.capHeight / 2
|
||||||
|
checkbox.setFrameOrigin(NSPoint(x: rect.midX - checkbox.frame.width / 2,
|
||||||
|
y: centerY - checkbox.frame.height / 2))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,3 +26,28 @@ import Testing
|
|||||||
#expect(unfinished.attribute(.foregroundColor, at: 10, effectiveRange: nil) as? NSColor == keyword)
|
#expect(unfinished.attribute(.foregroundColor, at: 10, effectiveRange: nil) as? NSColor == keyword)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func noteHeadingsShrinkByLevelWithoutChangingMarkdownOrCode() 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 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,
|
||||||
|
effectiveRange: nil) as? NSFont)
|
||||||
|
}
|
||||||
|
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("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)
|
||||||
|
#expect(try font("Three").pointSize == font("Body").pointSize)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1250,17 +1250,55 @@ func renderedListMarkersPaintBulletsAndNumbersInsideTheirGlyphFrames() throws {
|
|||||||
context.cgContext.clear(CGRect(x: 0, y: 0, width: 160, height: 48))
|
context.cgContext.clear(CGRect(x: 0, y: 0, width: 160, height: 48))
|
||||||
layout.drawGlyphs(forGlyphRange: glyphs, at: NSPoint(x: 4 - frame.minX, y: 10 - frame.minY))
|
layout.drawGlyphs(forGlyphRange: glyphs, at: NSPoint(x: 4 - frame.minX, y: 10 - frame.minY))
|
||||||
NSGraphicsContext.restoreGraphicsState()
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
var painted = false
|
var minX = 160, maxX = -1
|
||||||
for y in 0..<48 {
|
for y in 0..<48 {
|
||||||
for x in 0..<160 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.1 {
|
for x in 0..<160 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.1 {
|
||||||
painted = true
|
minX = min(minX, x)
|
||||||
|
maxX = max(maxX, x)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#expect(painted, "The actual TextKit draw path must paint the marker for \(source)")
|
#expect(maxX >= minX, "The actual TextKit draw path must paint the marker for \(source)")
|
||||||
|
if ["-", "*", "+"].contains(String(source.prefix(1))) {
|
||||||
|
#expect(abs(CGFloat(minX + maxX + 1) / 2 - (4 + frame.width / 2)) <= 1)
|
||||||
|
} else {
|
||||||
|
#expect(abs(CGFloat(maxX + 1) - frame.width) <= 3)
|
||||||
|
}
|
||||||
#expect(editor.markdownString == source)
|
#expect(editor.markdownString == source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func listCheckboxesStayCenteredInTheSharedColumnAtEachIndentation() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 300))
|
||||||
|
let source = "- bullet\n- [ ] task\n12345. wide number\n + nested bullet\n - [x] nested task"
|
||||||
|
editor.setMarkdownContents(NSAttributedString(string: source))
|
||||||
|
MarkdownHighlighting.apply(to: editor)
|
||||||
|
editor.refreshTasks()
|
||||||
|
let layout = try #require(editor.layoutManager)
|
||||||
|
let container = try #require(editor.textContainer)
|
||||||
|
layout.ensureLayout(for: container)
|
||||||
|
editor.layout()
|
||||||
|
let text = editor.string as NSString
|
||||||
|
let checkboxes = editor.subviews.compactMap { $0 as? NSButton }
|
||||||
|
for (index, label) in ["task", "nested task"].enumerated() {
|
||||||
|
let body = text.range(of: label)
|
||||||
|
let line = text.lineRange(for: body)
|
||||||
|
let marker = text.range(of: "\u{fffc}", options: [], range: line)
|
||||||
|
let glyphs = layout.glyphRange(forCharacterRange: marker, actualCharacterRange: nil)
|
||||||
|
let frame = layout.boundingRect(forGlyphRange: glyphs, in: container)
|
||||||
|
.offsetBy(dx: editor.textContainerOrigin.x, dy: editor.textContainerOrigin.y)
|
||||||
|
let checkbox = try #require(checkboxes.indices.contains(index) ? checkboxes[index] : nil)
|
||||||
|
#expect(abs(checkbox.frame.midX - frame.midX) <= 0.5)
|
||||||
|
let bodyGlyph = layout.glyphIndexForCharacter(at: body.location)
|
||||||
|
let fragment = layout.lineFragmentRect(forGlyphAt: bodyGlyph, effectiveRange: nil)
|
||||||
|
let baseline = editor.textContainerOrigin.y + fragment.minY + layout.location(forGlyphAt: bodyGlyph).y
|
||||||
|
let font = try #require(editor.textStorage?.attribute(.font, at: body.location, effectiveRange: nil) as? NSFont)
|
||||||
|
#expect(abs(checkbox.frame.midY - (baseline - font.capHeight / 2)) <= 0.5)
|
||||||
|
}
|
||||||
|
#expect(editor.markdownString == source)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func listBodyInsertionUsesVisiblePositionForTypingPasteAndReplacementRanges() throws {
|
func listBodyInsertionUsesVisiblePositionForTypingPasteAndReplacementRanges() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
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~~ `*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
|
||||||
|
}
|
||||||
|
#expect(visibleText() == "Heading\nSubheading\nitalic bold emphasis strong gone *literal*\nQuote\nLabel\n\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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 🦊")
|
||||||
|
|
||||||
|
let hide = try #require(descendants(content).compactMap { $0 as? NSButton }.first { $0.toolTip == "Hide formatting" })
|
||||||
|
hide.performClick(nil)
|
||||||
|
#expect(try shortcut("x", shift: true))
|
||||||
|
#expect(editor.markdownString == "~~fox 🦊~~")
|
||||||
|
#expect(try shortcut("x", 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 🦊")
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import AppKit
|
||||||
|
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()
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
try #require(bottom >= 0)
|
||||||
|
bottoms.append(bottom)
|
||||||
|
}
|
||||||
|
#expect(abs(bottoms[0] - bottoms[1]) <= 1, "Marker/body ink baseline for \(source)")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user