style: match Raycast notes window

This commit is contained in:
Valdemar Lorenzen
2026-10-19 15:26:42 +02:00
parent 1c3ecb232b
commit 999d44226d
7 changed files with 332 additions and 73 deletions
+6 -4
View File
@@ -66,7 +66,7 @@ Right-click the launcher background for Settings and Quit. Actions include favor
Action and selection popups paint a popup-sized background using the launcher window’s gradient coordinates, keeping the glows continuous without showing the results underneath. Each opening rebuilds the complete native view and layer tree, rather than resizing a retained presentation between the footer and header. Callbacks and selection behavior stay in the controller. Layout finishes before the opening fade, including when switching popup types.
The launcher, emoji picker, Notes, and Translate share `ActionPill` for footer styling, alignment, spacing, and keyboard indicators: primary action first, then Actions with separate Command and K keycaps. Notes uses **Browse Notes** with Command-P for its primary action so Return remains available for editing; the other tools use Return. Controllers supply the buttons and retain their targets, enabled state, and popup behavior.
The launcher, emoji picker, and Translate share `ActionPill` for footer styling, alignment, spacing, and keyboard indicators: primary action first, then Actions with separate Command and K keycaps. Notes instead uses a compact icon-only title bar and formatting strip. Controllers supply the buttons and retain their targets, enabled state, and popup behavior.
The header’s trailing edge is constrained to the footer’s trailing edge. The translation provider pill and footer pill therefore share the same 8-point right inset and 36-point height.
@@ -135,9 +135,11 @@ Commands explicitly declare `Application.Command.presentsView`. The shared dispa
## Notes
Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it a global shortcut in Settings. The title uses full-contrast text. The footer action pill offers **Browse Notes** (Command-P) and **Actions** (Command-K). Browse Notes opens Conjure’s styled, scrollable searchable picker, 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 picker title; duplicate titles remain independently selectable.
Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it a global shortcut in Settings. Notes opens in a compact 570 × 404 dark window inspired by Raycast Notes: rounded corners, a centered muted note title, white text, and coral-red list markers and caret. 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.
Actions opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P) and **New Note** (Command-N). Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its footer pill is covered. Escape, Back, 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) and **New Note** (Command-N). 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 or load 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.
@@ -155,7 +157,7 @@ Typing, pasting, and Return at the visible start of an item use the position aft
Fenced code uses [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) (Highlight.js) for language-aware token colors. Add a language after the opening backticks or tildes, for example `py`, `python`, `swift`, `js`, or `json`. Unlabeled fences use automatic detection; unknown languages remain plain monospace. Highlighting runs entirely offline.
Notes autosave locally as RTFD packages, including embedded images, under `~/Library/Application Support/Conjure/Notes`. No account, network access, or cloud sync is involved. Save failures appear below the editor and prevent switching, closing, or quitting until saving succeeds, preserving the unsaved buffer. The Back chevron restores the launcher search when no popup is open. Command-W dismisses Conjure, including any open popup; leaving Notes or switching destinations removes its popup completely.
Notes autosave locally as RTFD packages, including embedded images, under `~/Library/Application Support/Conjure/Notes`. No account, network access, or cloud sync is involved. Save failures appear below the editor and prevent switching, closing, or quitting until saving succeeds, preserving the unsaved buffer. The red Close button and Command-W dismiss Conjure, including any open popup; leaving Notes or switching destinations removes its popup completely and restores the launcher’s original dimensions.
## Translation
@@ -250,7 +250,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
selectionAccessibilityLabel = "Notes"
currentChoiceAccessibilityLabel = "Current note"
show(title: "Browse Notes", relativeTo: anchor, available: [],
searchAccessibilityLabel: "Search notes",
searchAccessibilityLabel: "Search notes", belowAnchor: true,
selectionChoices: notes.map {
SelectionChoice(id: .note($0.id), title: $0.title, searchText: FuzzySearchText($0.title))
}, selectionName: "notes")
@@ -258,7 +258,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
func showNotesActions(relativeTo anchor: NSView) {
show(title: "Notes", relativeTo: anchor, available: [12, 13],
searchAccessibilityLabel: "Search notes actions")
searchAccessibilityLabel: "Search notes actions", belowAnchor: true)
}
private func show(title: String, relativeTo anchor: NSView, available: [Int], emoji: Bool = false,
+20 -9
View File
@@ -6,7 +6,6 @@ import ApplicationServices
@MainActor
final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
private let searchField = NSSearchField()
private let commandTitle = NSTextField(labelWithString: "Conjure Notes")
private let searchSymbol = IconView(font: .systemFont(ofSize: 20))
private let commandBack = NSButton(title: "", target: nil, action: nil)
private let translationHeader = NSStackView()
@@ -265,9 +264,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont]
)
searchField.textColor = SunsetPalette.text
commandTitle.font = searchFont
commandTitle.textColor = SunsetPalette.text
commandTitle.isHidden = true
searchField.isBordered = false
searchField.drawsBackground = false
searchField.focusRingType = .none
@@ -294,7 +290,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
commandBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
])
translationHeader.isHidden = true
let header = NSStackView(views: [searchSymbol, commandBack, searchField, commandTitle, translationHeader])
let header = NSStackView(views: [searchSymbol, commandBack, searchField, translationHeader])
header.orientation = .horizontal
header.alignment = .centerY
header.spacing = 12
@@ -897,6 +893,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
activeCommand = command
history.recordLaunch(of: Application(command: command))
let previousQuery = searchField.stringValue
let previousFrame = window?.frame
let previousPlaceholder = searchField.placeholderAttributedString
let close: () -> Void = { [weak self, weak controller] in
guard let self, let controller else { return }
@@ -907,7 +904,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
self.emojiPicker = nil
self.notes = nil
self.searchField.isHidden = false
self.commandTitle.isHidden = true
self.launcherViews.first?.isHidden = false
self.launcherViews.first?.setAccessibilityHidden(false)
if command == .notes, let previousFrame {
self.window?.setFrame(previousFrame, display: true)
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 24) }
}
self.activeCommand = nil
self.commandBack.isHidden = true
self.commandBack.target = nil
@@ -943,8 +945,15 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
)
searchField.setAccessibilityLabel(accessibilityLabel)
if command == .notes {
searchField.isHidden = true
commandTitle.isHidden = false
launcherViews.first?.isHidden = true
launcherViews.first?.setAccessibilityHidden(true)
if var frame = window?.frame {
frame.origin.x += (frame.width - 570) / 2
frame.origin.y = frame.maxY - 404
frame.size = NSSize(width: 570, height: 404)
window?.setFrame(frame, display: true)
}
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 14) }
}
for view in launcherViews.dropFirst() {
view.isHidden = true
@@ -959,7 +968,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
NSLayoutConstraint.activate([
controller.view.leadingAnchor.constraint(equalTo: content.leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: content.trailingAnchor),
controller.view.topAnchor.constraint(equalTo: command == .notes ? commandTitle.bottomAnchor : searchField.bottomAnchor, constant: 16),
command == .notes
? controller.view.topAnchor.constraint(equalTo: content.topAnchor)
: controller.view.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 16),
controller.view.bottomAnchor.constraint(equalTo: content.bottomAnchor),
])
if let notes { notes.focusEditor() }
+219 -43
View File
@@ -2,6 +2,21 @@ import AppKit
import ConjureKit
import Highlighter
@MainActor
enum NotesAppearance {
static let background = NSColor(white: 0.035, alpha: 1)
static let text = NSColor(white: 0.94, alpha: 1)
static let muted = NSColor(white: 0.58, alpha: 1)
static let accent = NSColor(srgbRed: 1, green: 0.36, blue: 0.39, alpha: 1)
static let font = NSFont.systemFont(ofSize: 17)
static let paragraph: NSParagraphStyle = {
let style = NSMutableParagraphStyle()
style.lineSpacing = 5
style.paragraphSpacing = 8
return style
}()
}
@MainActor
final class NotesController: NSViewController, NSTextViewDelegate {
private let browseButton = NSButton(title: "Browse Notes", target: nil, action: nil)
@@ -9,6 +24,8 @@ final class NotesController: NSViewController, NSTextViewDelegate {
private let actions = ApplicationActionsController()
private let editor = NotesTextView()
private let status = NSTextField(labelWithString: "")
private let noteTitle = NSTextField(labelWithString: "Untitled Note")
private let formatting = NSStackView()
private let defaults: UserDefaults
private var store: NotesStore?
private var selectedID: UUID?
@@ -32,32 +49,84 @@ final class NotesController: NSViewController, NSTextViewDelegate {
let content = NotesView()
content.commandShortcut = { [weak self] in self?.handleCommandKey($0) ?? false }
view = content
content.wantsLayer = true
content.layer?.backgroundColor = NotesAppearance.background.cgColor
let close = iconButton("circle.fill", label: "Close notes", action: #selector(closeWindow))
close.contentTintColor = NotesAppearance.accent
close.isBordered = false
close.image = close.image?.withSymbolConfiguration(.init(paletteColors: [NotesAppearance.accent]))
let lights = NSStackView(views: [close])
lights.spacing = 8
for _ in 0..<2 {
let dot = NSView()
dot.wantsLayer = true
dot.layer?.backgroundColor = NSColor(white: 0.15, alpha: 1).cgColor
dot.layer?.cornerRadius = 6
dot.setAccessibilityElement(false)
NSLayoutConstraint.activate([dot.widthAnchor.constraint(equalToConstant: 12), dot.heightAnchor.constraint(equalToConstant: 12)])
lights.addArrangedSubview(dot)
}
browseButton.target = self
browseButton.action = #selector(browseNotes)
browseButton.setAccessibilityLabel("Browse notes")
browseButton.toolTip = "Browse notes (Command-P)"
configureIcon(browseButton, symbol: "note.text", label: "Browse notes (⌘P)")
browseButton.keyEquivalent = "p"
actionsButton.target = self
actionsButton.action = #selector(showActions)
actionsButton.setAccessibilityLabel("Notes actions")
actionsButton.toolTip = "Notes actions (Command-K)"
configureIcon(actionsButton, symbol: "command", label: "Notes actions (⌘K)")
let create = iconButton("plus", label: "New note (⌘N)", action: #selector(newNote))
let controls = NSStackView(views: [actionsButton, browseButton, create])
controls.spacing = 2
noteTitle.font = .systemFont(ofSize: 13)
noteTitle.textColor = NotesAppearance.muted
noteTitle.alignment = .center
noteTitle.lineBreakMode = .byTruncatingTail
noteTitle.setAccessibilityLabel("Note title")
let header = NSView()
for control in [lights, noteTitle, controls] {
control.translatesAutoresizingMaskIntoConstraints = false
header.addSubview(control)
control.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true
}
NSLayoutConstraint.activate([
lights.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 10),
controls.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -8),
noteTitle.centerXAnchor.constraint(equalTo: header.centerXAnchor),
noteTitle.leadingAnchor.constraint(greaterThanOrEqualTo: lights.trailingAnchor, constant: 16),
noteTitle.trailingAnchor.constraint(lessThanOrEqualTo: controls.leadingAnchor, constant: -16),
])
actions.closed = { [weak self] in self?.focusEditor() }
actions.selectNote = { [weak self] in self?.switchNote($0) }
actions.newNote = { [weak self] in self?.newNote() }
actions.browseNotes = { [weak self] in self?.browseNotes() }
let pill = ActionPill(primaryButton: browseButton, actionsButton: actionsButton, primaryCommand: "p")
let footer = NSStackView(views: [status, NSView(), pill])
footer.orientation = .horizontal
let tools = [
("textformat.size", "Heading"), ("bold", "Bold"), ("italic", "Italic"),
("strikethrough", "Strikethrough"), ("chevron.left.forwardslash.chevron.right", "Inline code"),
("link", "Link"), ("curlybraces.square", "Code block"), ("text.quote", "Quote"),
("list.number", "Ordered list"), ("list.bullet", "Bullet list"), ("checklist", "Checklist"),
]
for (index, tool) in tools.enumerated() {
let button = iconButton(tool.0, label: tool.1, action: #selector(formatNote(_:)))
button.tag = index
formatting.addArrangedSubview(button)
}
formatting.spacing = 8
let toggle = iconButton("xmark.circle.fill", label: "Hide formatting", action: #selector(toggleFormatting(_:)))
let separator = NSBox()
separator.boxType = .separator
let footer = NSStackView(views: [formatting, NSView(), separator, toggle])
footer.alignment = .centerY
footer.spacing = 12
footer.spacing = 8
separator.heightAnchor.constraint(equalToConstant: 22).isActive = true
editor.delegate = self
editor.isRichText = true
editor.importsGraphics = true
editor.allowsUndo = true
editor.drawsBackground = false
editor.textColor = SunsetPalette.text
editor.insertionPointColor = SunsetPalette.accent
editor.font = .systemFont(ofSize: 14)
editor.textColor = NotesAppearance.text
editor.insertionPointColor = NotesAppearance.accent
editor.selectedTextAttributes = [.backgroundColor: NotesAppearance.accent.withAlphaComponent(0.25)]
editor.font = NotesAppearance.font
editor.defaultParagraphStyle = NotesAppearance.paragraph
editor.isAutomaticQuoteSubstitutionEnabled = false
editor.isAutomaticDashSubstitutionEnabled = false
editor.isAutomaticTextReplacementEnabled = false
@@ -65,39 +134,49 @@ final class NotesController: NSViewController, NSTextViewDelegate {
editor.isHorizontallyResizable = false
editor.autoresizingMask = [.width]
editor.textContainer?.widthTracksTextView = true
editor.textContainerInset = NSSize(width: 10, height: 10)
editor.textContainerInset = NSSize(width: 22, height: 8)
editor.setAccessibilityLabel("Note editor")
let scroll = NSScrollView()
scroll.documentView = editor
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.drawsBackground = false
scroll.wantsLayer = true
scroll.layer?.cornerRadius = 8
scroll.layer?.borderWidth = 0.5
scroll.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
scroll.scrollerStyle = .overlay
status.font = .systemFont(ofSize: 12)
status.textColor = SunsetPalette.secondaryText
status.textColor = NotesAppearance.muted
status.setAccessibilityLabel("Notes save status")
status.lineBreakMode = .byTruncatingTail
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
for control in [scroll, footer] {
status.isHidden = true
let body = NSStackView(views: [scroll, status, footer])
body.orientation = .vertical
body.alignment = .leading
body.spacing = 4
for control in [header, body] {
control.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(control)
}
NSLayoutConstraint.activate([
scroll.topAnchor.constraint(equalTo: view.topAnchor),
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -8),
footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
footer.heightAnchor.constraint(equalToConstant: 36),
header.topAnchor.constraint(equalTo: view.topAnchor),
header.leadingAnchor.constraint(equalTo: view.leadingAnchor),
header.trailingAnchor.constraint(equalTo: view.trailingAnchor),
header.heightAnchor.constraint(equalToConstant: 38),
body.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 4),
body.leadingAnchor.constraint(equalTo: view.leadingAnchor),
body.trailingAnchor.constraint(equalTo: view.trailingAnchor),
body.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -4),
scroll.widthAnchor.constraint(equalTo: body.widthAnchor),
footer.leadingAnchor.constraint(equalTo: body.leadingAnchor, constant: 24),
footer.trailingAnchor.constraint(equalTo: body.trailingAnchor, constant: -6),
status.leadingAnchor.constraint(equalTo: body.leadingAnchor, constant: 28),
status.trailingAnchor.constraint(equalTo: body.trailingAnchor, constant: -12),
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 80),
footer.heightAnchor.constraint(equalToConstant: 32),
])
guard let store else {
editor.isEditable = false
for control in [browseButton, actionsButton] { control.isEnabled = false }
for control in [browseButton, actionsButton, create] { control.isEnabled = false }
formatting.subviews.compactMap { $0 as? NSButton }.forEach { $0.isEnabled = false }
showError(loadError!)
return
}
@@ -111,6 +190,94 @@ final class NotesController: NSViewController, NSTextViewDelegate {
}
}
private func configureIcon(_ button: NSButton, symbol: String, label: String) {
button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)?
.withSymbolConfiguration(.init(pointSize: 16, weight: .regular))
button.imagePosition = .imageOnly
button.bezelStyle = .accessoryBarAction
button.showsBorderOnlyWhileMouseInside = true
button.contentTintColor = NotesAppearance.muted
button.toolTip = label
button.setAccessibilityLabel(label)
NSLayoutConstraint.activate([button.widthAnchor.constraint(equalToConstant: 28), button.heightAnchor.constraint(equalToConstant: 28)])
}
private func iconButton(_ symbol: String, label: String, action: Selector) -> NSButton {
let button = NSButton(title: "", target: self, action: action)
configureIcon(button, symbol: symbol, label: label)
return button
}
@objc private func closeWindow() {
guard save() else { return }
view.window?.performClose(nil)
}
@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) {
guard editor.isEditable else { return }
let contents = editor.markdownContents
let source = contents.string as NSString
var selection = editor.markdownInsertionRange(for: editor.selectedRange())
if sender.tag == 6 {
selection = source.lineRange(for: selection)
if selection.length > 0, source.character(at: NSMaxRange(selection) - 1) == 10 { selection.length -= 1 }
}
let wrappers = [1: ("**", "**"), 2: ("*", "*"), 3: ("~~", "~~"), 4: ("`", "`"),
5: ("[", "](url)"), 6: ("```\n", "\n```")]
if let (open, close) = wrappers[sender.tag] {
let before = selection.location - open.utf16.count
let after = NSMaxRange(selection)
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: after, length: close.utf16.count)) == close {
let body = contents.attributedSubstring(from: selection)
selection = NSRange(location: before, length: selection.length + open.utf16.count + close.utf16.count)
replaceMarkdown(body, range: selection, selection: NSRange(location: before, length: body.length))
} else {
let body = NSMutableAttributedString(string: open, attributes: editor.typingAttributes)
body.append(contents.attributedSubstring(from: selection))
body.append(NSAttributedString(string: close, attributes: editor.typingAttributes))
replaceMarkdown(body, range: selection,
selection: NSRange(location: selection.location + open.utf16.count, length: selection.length))
}
} else {
let prefixes = [0: "# ", 7: "> ", 8: "1. ", 9: "- ", 10: "- [ ] "]
guard let prefix = prefixes[sender.tag] else { return }
let length = selection.length > 0 && source.character(at: NSMaxRange(selection) - 1) == 10
? selection.length - 1 : selection.length
let lines = source.lineRange(for: NSRange(location: selection.location, length: length))
let body = NSMutableAttributedString(attributedString: contents.attributedSubstring(from: lines))
let rows = body.string.components(separatedBy: "\n")
var offset = body.length, firstDelta = 0
for (index, line) in rows.enumerated().reversed() {
offset -= line.utf16.count + (index < rows.count - 1 ? 1 : 0)
if index == rows.count - 1, index > 0, line.isEmpty { continue }
let indent = String(line.prefix { $0 == " " || $0 == "\t" })
let content = String(line.dropFirst(indent.count))
let item = Self.listMatch(in: content as NSString)
let kind = item.map { $0.range(at: 6).location != NSNotFound ? 10 : $0.range(at: 3).location != NSNotFound ? 8 : 9 }
let removing = kind == sender.tag || (item == nil && content.hasPrefix(prefix))
let oldPrefix = item?.range.length ?? (removing ? prefix.utf16.count : 0)
let newPrefix = removing ? "" : sender.tag == 8 ? "\(index + 1). " : prefix
body.replaceCharacters(in: NSRange(location: offset + indent.utf16.count, length: oldPrefix),
with: NSAttributedString(string: newPrefix, attributes: editor.typingAttributes))
if index == 0 { firstDelta = newPrefix.utf16.count - oldPrefix }
}
let start = max(lines.location, selection.location + firstDelta)
let end = max(start, NSMaxRange(selection) + body.length - lines.length)
replaceMarkdown(body, range: lines, selection: NSRange(location: start, length: end - start))
}
focusEditor()
}
func focusEditor() { view.window?.makeFirstResponder(editor) }
func textDidChange(_ notification: Notification) {
@@ -164,6 +331,10 @@ final class NotesController: NSViewController, NSTextViewDelegate {
}
private func replaceListMarkdown(_ replacement: String, range: NSRange, selection: NSRange) {
replaceMarkdown(NSAttributedString(string: replacement, attributes: editor.typingAttributes), range: range, selection: selection)
}
private func replaceMarkdown(_ replacement: NSAttributedString, range: NSRange, selection: NSRange) {
editor.undoManager?.beginUndoGrouping()
// Native text undo restores its diff boundary, not the user's selection.
setSelectionWithUndo(editor.selectedRange())
@@ -246,8 +417,8 @@ final class NotesController: NSViewController, NSTextViewDelegate {
do {
try store.update(id: selectedID, contents: editor.markdownContents)
isDirty = false
status.stringValue = "Saved locally · Markdown · Paste or drop images"
status.textColor = SunsetPalette.secondaryText
noteTitle.stringValue = store.notes.first { $0.id == selectedID }?.title ?? "Untitled Note"
status.isHidden = true
return true
} catch {
showError(error)
@@ -312,12 +483,13 @@ final class NotesController: NSViewController, NSTextViewDelegate {
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
MarkdownHighlighting.apply(to: editor)
editor.refreshTasks()
status.stringValue = "Saved locally · Markdown · Paste or drop images"
status.textColor = SunsetPalette.secondaryText
noteTitle.stringValue = note.title
status.isHidden = true
}
private func showError(_ error: Error) {
status.isHidden = false
status.stringValue = "Couldn’t save or load notes: \(error.localizedDescription)"
status.toolTip = status.stringValue
status.textColor = .systemRed
@@ -353,16 +525,17 @@ private final class NotesView: NSView {
enum MarkdownHighlighting {
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [
(try! NSRegularExpression(pattern: "(?m)^#{1,6} .+$"), [.font: NSFont.systemFont(ofSize: 17, weight: .semibold), .foregroundColor: SunsetPalette.accent]),
(try! NSRegularExpression(pattern: "\\*\\*[^\\n]+?\\*\\*|__[^\\n]+?__"), [.font: NSFont.boldSystemFont(ofSize: 14)]),
(try! NSRegularExpression(pattern: "(?<!\\*)\\*(?!\\*)[^*\\n]+\\*(?!\\*)|(?<!_)_(?!_)[^_\\n]+_(?!_)"), [.font: NSFontManager.shared.convert(.systemFont(ofSize: 14), toHaveTrait: .italicFontMask)]),
(try! NSRegularExpression(pattern: "!?\\[[^\\]\\n]+\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]),
(try! NSRegularExpression(pattern: "`[^`\\n]+`"), [.font: NSFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: NSColor.systemGreen]),
(try! NSRegularExpression(pattern: "(?m)^#{1,6} .+$"), [.font: NSFont.systemFont(ofSize: 23, weight: .semibold)]),
(try! NSRegularExpression(pattern: "\\*\\*[^\\n]+?\\*\\*|__[^\\n]+?__"), [.font: NSFont.boldSystemFont(ofSize: 17)]),
(try! NSRegularExpression(pattern: "(?<!\\*)\\*(?!\\*)[^*\\n]+\\*(?!\\*)|(?<!_)_(?!_)[^_\\n]+_(?!_)"), [.font: NSFontManager.shared.convert(NotesAppearance.font, toHaveTrait: .italicFontMask)]),
(try! NSRegularExpression(pattern: "!?\\[[^\\]\\n]+\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: NotesAppearance.accent]),
(try! NSRegularExpression(pattern: "~~[^\\n]+?~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]),
(try! NSRegularExpression(pattern: "`[^`\\n]+`"), [.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .regular), .foregroundColor: NSColor.systemGreen]),
]
private static let highlighter: Highlighter = {
guard let value = Highlighter() else { preconditionFailure("Missing bundled syntax highlighter") }
value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: 14)
value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: 17)
return value
}()
private static let openingFence = try! NSRegularExpression(pattern: "(?m)^ {0,3}(`{3,}|~{3,})([^\\r\\n]*)\\r?\\n")
@@ -401,7 +574,9 @@ enum MarkdownHighlighting {
let scale = min(1, 460 / max(size.width, 1), 240 / max(size.height, 1))
if scale < 1 { image.size = NSSize(width: size.width * scale, height: size.height * scale) }
}
storage.addAttributes([.font: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.text], range: range)
storage.removeAttribute(.strikethroughStyle, range: range)
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: NotesAppearance.text,
.paragraphStyle: NotesAppearance.paragraph], range: range)
let blocks = codeBlocks(in: text)
for (expression, attributes) in rules {
for match in expression.matches(in: text, range: range) {
@@ -410,9 +585,9 @@ enum MarkdownHighlighting {
}
}
for block in blocks {
storage.addAttributes([.font: NSFont.monospacedSystemFont(ofSize: 14, weight: .regular),
.foregroundColor: SunsetPalette.secondaryText], range: block.range)
storage.addAttribute(.foregroundColor, value: SunsetPalette.text, range: block.body)
storage.addAttributes([.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .regular),
.foregroundColor: NotesAppearance.muted], range: block.range)
storage.addAttribute(.foregroundColor, value: NotesAppearance.text, range: block.body)
let code = (text as NSString).substring(with: block.body)
guard let highlighted = highlighter.highlight(code, as: block.language),
highlighted.string == code else { continue }
@@ -423,6 +598,7 @@ enum MarkdownHighlighting {
}
}
}
editor.typingAttributes = [.font: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.text]
editor.typingAttributes = [.font: NotesAppearance.font, .foregroundColor: NotesAppearance.text,
.paragraphStyle: NotesAppearance.paragraph]
}
}
+14 -8
View File
@@ -18,19 +18,19 @@ private final class ListAttachment: NSTextAttachment {
super.init(data: nil, ofType: nil)
let cell = ListAttachmentCell(textCell: isTask ? "" : (source.length == 1 ? "•" : source.string))
cell.attributedStringValue = NSAttributedString(string: cell.stringValue,
attributes: [.font: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.accent])
attributes: [.font: NotesAppearance.font, .foregroundColor: NotesAppearance.accent])
cell.alignment = .right
attachmentCell = cell
bounds = NSRect(x: 0, y: -3, width: 18, height: 17)
bounds = NSRect(x: 0, y: -4, width: 22, height: 21)
}
required init?(coder: NSCoder) { fatalError("List attachments are serialized as Markdown") }
}
private final class ListAttachmentCell: NSTextAttachmentCell {
nonisolated(unsafe) var columnWidth: CGFloat = 18
nonisolated override func cellSize() -> NSSize { NSSize(width: columnWidth, height: 17) }
nonisolated override func cellBaselineOffset() -> NSPoint { NSPoint(x: 0, y: -3) }
nonisolated(unsafe) var columnWidth: CGFloat = 22
nonisolated override func cellSize() -> NSSize { NSSize(width: columnWidth, height: 21) }
nonisolated override func cellBaselineOffset() -> NSPoint { NSPoint(x: 0, y: -4) }
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {
guard !stringValue.isEmpty else { return }
let label = attributedStringValue
@@ -202,6 +202,10 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
replaceSource(NSAttributedString(string: text, attributes: textTypingAttributes), range: range, semantic: true)
}
func replaceMarkdown(_ contents: NSAttributedString, range: NSRange) {
replaceSource(contents, range: range, semantic: true)
}
func markdownInsertionRange(for rendered: NSRange) -> NSRange {
guard rendered.length == 0 else { return markdownRange(for: rendered) }
let body = listWhitespaceBoundary(from: rendered.location, forward: true)
@@ -506,7 +510,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
while checkboxes.count < taskIndices.count {
let checkbox = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:)))
checkbox.controlSize = .small
checkbox.contentTintColor = SunsetPalette.accent
checkbox.contentTintColor = NotesAppearance.accent
checkbox.sizeToFit()
checkboxes.append(checkbox)
addSubview(checkbox)
@@ -534,8 +538,8 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
layoutManager.delegate = self
storage.beginEditing()
Self.restoreListLayout(in: storage)
let markerFont = NSFont.systemFont(ofSize: 14)
let column = ceil(tasks.reduce(CGFloat(18)) { width, task in
let markerFont = NotesAppearance.font
let column = ceil(tasks.reduce(CGFloat(22)) { width, task in
guard !task.attachment.isTask else { return width }
return max(width, (task.attachment.marker as NSString).size(withAttributes: [.font: markerFont]).width + 4)
})
@@ -552,6 +556,8 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
}
// Keep indentation as native whitespace; null paragraph-leading glyphs lose first-line layout.
style.firstLineHeadIndent = 0
style.lineSpacing = NotesAppearance.paragraph.lineSpacing
style.paragraphSpacing = NotesAppearance.paragraph.paragraphSpacing
style.headIndent = CGFloat(depth) * space + column
style.tabStops = []
style.defaultTabInterval = 4 * space
@@ -18,9 +18,7 @@ import Testing
let comment = try #require(attributes("# comment")[.foregroundColor] as? NSColor)
#expect(keyword != name)
#expect(comment != keyword)
#expect((attributes("# comment")[.font] as? NSFont)?.pointSize == 14)
#expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true)
#expect((attributes("# literal")[.font] as? NSFont)?.pointSize == 14)
#expect((attributes("After")[.font] as? NSFont)?.isFixedPitch == false)
#expect(storage.string == text)
editor.string = "```python\ndef main():\n pass"
+71 -5
View File
@@ -21,8 +21,6 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
let views = descendants(controller.view)
let editor = try #require(views.compactMap { $0 as? NSTextView }.first)
let browse = try #require(views.compactMap { $0 as? NSButton }.first { $0.title == "Browse Notes" })
#expect(browse.keyEquivalent == "p")
#expect(!views.compactMap { $0 as? NSButton }.contains { $0.title == "New Note" })
func selectedID() throws -> UUID { try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:))) }
func picker(_ tool: NotesController) throws -> NSSearchField {
try #require(descendants(tool.view).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == "Search notes" })
@@ -54,7 +52,6 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
#expect(!controller.handleControlKey("j"))
#expect(!controller.handle(#selector(NSResponder.insertNewline(_:))))
editor.insertText("# Second 🦊\n**bold** and `code`", replacementRange: editor.selectedRange())
#expect((editor.textStorage?.attribute(.font, at: 0, effectiveRange: nil) as? NSFont)?.pointSize == 17)
let codeRange = (editor.string as NSString).range(of: "`code`")
let codeFont = try #require(editor.textStorage?.attribute(.font, at: codeRange.location, effectiveRange: nil) as? NSFont)
#expect(codeFont.isFixedPitch)
@@ -99,7 +96,6 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
#expect(reopenedEditor.string == first.contents.string)
// Removing Markdown must also remove its previous visual style without changing the source.
reopenedEditor.insertText("Plain text", replacementRange: NSRange(location: 0, length: reopenedEditor.string.utf16.count))
#expect((reopenedEditor.textStorage?.attribute(.font, at: 0, effectiveRange: nil) as? NSFont)?.pointSize == 14)
#expect(reopenedEditor.string == "Plain text")
let backup = directory.appendingPathExtension("backup")
defer { try? FileManager.default.removeItem(at: backup) }
@@ -183,7 +179,6 @@ func notesLauncherRoutesShortcutsAndTearsDownPickersWithoutTakingEditorNavigatio
let picker = try search("Search notes")
let table = try #require(descendants(content).compactMap { $0 as? NSTableView }.first { $0.accessibilityLabel() == "Notes" })
#expect(window.firstResponder === picker.currentEditor())
#expect(browse.superview?.isAccessibilityHidden() == true)
#expect(tool.handle(#selector(NSResponder.moveUp(_:))))
window.sendEvent(try key("j", code: 38, flags: .control))
#expect(table.selectedRow == 1)
@@ -1282,3 +1277,74 @@ func typingMarkersBeforeExistingWhitespaceWaitsForAnExplicitSeparator() throws {
}
}
}
@Test @MainActor
func notesFormattingPreservesAttachmentsMarkdownSelectionAndUndo() throws {
_ = NSApplication.shared
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let suite = "NotesFormatting.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer {
try? FileManager.default.removeItem(at: directory)
defaults.removePersistentDomain(forName: suite)
}
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 views = descendants(controller.view)
let editor = try #require(views.compactMap { $0 as? NotesTextView }.first)
func click(_ label: String) throws {
let button = try #require(views.compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == label })
button.performClick(nil)
}
window.makeFirstResponder(editor)
editor.insertText("🦊 first\nsecond\nuntouched", replacementRange: editor.selectedRange())
let selection = NSRange(location: 0, length: "🦊 first\nsecond\n".utf16.count)
editor.setSelectedRange(selection)
try click("Bullet list")
#expect(editor.markdownString == "- 🦊 first\n- second\nuntouched")
try click("Ordered list")
#expect(editor.markdownString == "1. 🦊 first\n2. second\nuntouched")
try click("Checklist")
#expect(editor.markdownString == "- [ ] 🦊 first\n- [ ] second\nuntouched")
try click("Checklist")
#expect(editor.markdownString == "🦊 first\nsecond\nuntouched")
let originalSelection = editor.selectedRange()
try click("Bold")
#expect(editor.markdownString == "**🦊 first\nsecond\n**untouched")
try click("Bold")
#expect(editor.markdownString == "🦊 first\nsecond\nuntouched")
#expect(editor.selectedRange() == originalSelection)
editor.setSelectedRange(NSRange(location: 3, length: 0))
try click("Code block")
#expect(editor.markdownString == "```\n🦊 first\n```\nsecond\nuntouched")
try click("Code block")
#expect(editor.markdownString == "🦊 first\nsecond\nuntouched")
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
bitmap.setColor(.red, atX: 0, y: 0)
let file = FileWrapper(regularFileWithContents: try #require(bitmap.representation(using: .png, properties: [:])))
file.preferredFilename = "pixel.png"
let attachment = NSTextAttachment(fileWrapper: file)
editor.insertText(NSAttributedString(attachment: attachment), replacementRange: editor.selectedRange())
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
let before = editor.markdownString
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
try click("Quote")
try #require(editor.markdownString == "> 🦊 first\n> second\n> untouched\u{fffc}")
#expect(editor.markdownContents.attribute(.attachment, at: editor.markdownContents.length - 1, effectiveRange: nil) is NSTextAttachment)
editor.undoManager?.endUndoGrouping()
editor.undoManager?.undo()
try #require(editor.markdownString == before)
#expect(editor.markdownContents.attribute(.attachment, at: editor.markdownContents.length - 1, effectiveRange: nil) is NSTextAttachment)
#expect(controller.save())
let saved = try #require(NotesStore(directory: directory).notes.first)
#expect(saved.contents.string == before)
#expect(saved.contents.attribute(.attachment, at: saved.contents.length - 1, effectiveRange: nil) is NSTextAttachment)
}