fix: scale note headings by level

This commit is contained in:
2026-10-19 20:18:14 +02:00
parent 090c9045a4
commit 6f37910797
3 changed files with 38 additions and 2 deletions
+1 -1
View File
@@ -139,7 +139,7 @@ Commands explicitly declare `Application.Command.presentsView`. The shared dispa
Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it a global shortcut in Settings. Notes starts with a compact 570 × 404 layout inspired by Raycast Notes, with rounded corners and a centered muted note title, while using Conjure’s shared warm gradient background, cream text, and amber list markers and caret. Drag a window edge or corner to resize it; Notes remembers its size across reopening and app restarts, independently of other launcher views. The minimum size is 570 × 260, and restored dimensions fit the current screen’s visible area. The title bar offers Close, **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 monospaced text, including Markdown headings, bold, and italic, while retaining Conjure’s warm palette. Heading sizes decrease from H1 through H6 (28, 25, 23, 21, 19, and 17 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.
+12 -1
View File
@@ -599,13 +599,16 @@ private final class NotesView: NSView {
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.
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: "\\*\\*[^\\n]+?\\*\\*|__[^\\n]+?__"), [.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .bold)]), (try! NSRegularExpression(pattern: "\\*\\*[^\\n]+?\\*\\*|__[^\\n]+?__"), [.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .bold)]),
(try! NSRegularExpression(pattern: "(?<!\\*)\\*(?!\\*)[^*\\n]+\\*(?!\\*)|(?<!_)_(?!_)[^_\\n]+_(?!_)"), [.font: NSFontManager.shared.convert(NotesAppearance.font, toHaveTrait: .italicFontMask)]), (try! NSRegularExpression(pattern: "(?<!\\*)\\*(?!\\*)[^*\\n]+\\*(?!\\*)|(?<!_)_(?!_)[^_\\n]+_(?!_)"), [.font: NSFontManager.shared.convert(NotesAppearance.font, toHaveTrait: .italicFontMask)]),
(try! NSRegularExpression(pattern: "!?\\[[^\\]\\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: "~~[^\\n]+?~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]), (try! NSRegularExpression(pattern: "~~[^\\n]+?~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]),
(try! NSRegularExpression(pattern: "`[^`\\n]+`"), [.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .regular), .foregroundColor: NSColor.systemGreen]), (try! NSRegularExpression(pattern: "`[^`\\n]+`"), [.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .regular), .foregroundColor: NSColor.systemGreen]),
] ]
private static let heading = try! NSRegularExpression(pattern: "(?m)^(#{1,6}) .+$")
private static let headingFonts = [28.0, 25, 23, 21, 19, 17].map {
NSFont.monospacedSystemFont(ofSize: $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") }
@@ -658,6 +661,14 @@ enum MarkdownHighlighting {
storage.addAttributes(attributes, range: match.range) storage.addAttributes(attributes, range: match.range)
} }
} }
for match in heading.matches(in: text, range: range) {
guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
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), storage.addAttributes([.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .regular),
.foregroundColor: SunsetPalette.secondaryText], range: block.range) .foregroundColor: SunsetPalette.secondaryText], range: block.range)
@@ -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)
}