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. 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. 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 ## 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. 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. 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 ## Translation
@@ -250,7 +250,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
selectionAccessibilityLabel = "Notes" selectionAccessibilityLabel = "Notes"
currentChoiceAccessibilityLabel = "Current note" currentChoiceAccessibilityLabel = "Current note"
show(title: "Browse Notes", relativeTo: anchor, available: [], show(title: "Browse Notes", relativeTo: anchor, available: [],
searchAccessibilityLabel: "Search notes", searchAccessibilityLabel: "Search notes", belowAnchor: true,
selectionChoices: notes.map { selectionChoices: notes.map {
SelectionChoice(id: .note($0.id), title: $0.title, searchText: FuzzySearchText($0.title)) SelectionChoice(id: .note($0.id), title: $0.title, searchText: FuzzySearchText($0.title))
}, selectionName: "notes") }, selectionName: "notes")
@@ -258,7 +258,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
func showNotesActions(relativeTo anchor: NSView) { func showNotesActions(relativeTo anchor: NSView) {
show(title: "Notes", relativeTo: anchor, available: [12, 13], 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, private func show(title: String, relativeTo anchor: NSView, available: [Int], emoji: Bool = false,
+20 -9
View File
@@ -6,7 +6,6 @@ import ApplicationServices
@MainActor @MainActor
final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate { final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
private let searchField = NSSearchField() private let searchField = NSSearchField()
private let commandTitle = NSTextField(labelWithString: "Conjure Notes")
private let searchSymbol = IconView(font: .systemFont(ofSize: 20)) private let searchSymbol = IconView(font: .systemFont(ofSize: 20))
private let commandBack = NSButton(title: "", target: nil, action: nil) private let commandBack = NSButton(title: "", target: nil, action: nil)
private let translationHeader = NSStackView() private let translationHeader = NSStackView()
@@ -265,9 +264,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont] attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont]
) )
searchField.textColor = SunsetPalette.text searchField.textColor = SunsetPalette.text
commandTitle.font = searchFont
commandTitle.textColor = SunsetPalette.text
commandTitle.isHidden = true
searchField.isBordered = false searchField.isBordered = false
searchField.drawsBackground = false searchField.drawsBackground = false
searchField.focusRingType = .none searchField.focusRingType = .none
@@ -294,7 +290,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
commandBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)), commandBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
]) ])
translationHeader.isHidden = true translationHeader.isHidden = true
let header = NSStackView(views: [searchSymbol, commandBack, searchField, commandTitle, translationHeader]) let header = NSStackView(views: [searchSymbol, commandBack, searchField, translationHeader])
header.orientation = .horizontal header.orientation = .horizontal
header.alignment = .centerY header.alignment = .centerY
header.spacing = 12 header.spacing = 12
@@ -897,6 +893,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
activeCommand = command activeCommand = command
history.recordLaunch(of: Application(command: command)) history.recordLaunch(of: Application(command: command))
let previousQuery = searchField.stringValue let previousQuery = searchField.stringValue
let previousFrame = window?.frame
let previousPlaceholder = searchField.placeholderAttributedString let previousPlaceholder = searchField.placeholderAttributedString
let close: () -> Void = { [weak self, weak controller] in let close: () -> Void = { [weak self, weak controller] in
guard let self, let controller else { return } guard let self, let controller else { return }
@@ -907,7 +904,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
self.emojiPicker = nil self.emojiPicker = nil
self.notes = nil self.notes = nil
self.searchField.isHidden = false 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.activeCommand = nil
self.commandBack.isHidden = true self.commandBack.isHidden = true
self.commandBack.target = nil self.commandBack.target = nil
@@ -943,8 +945,15 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
) )
searchField.setAccessibilityLabel(accessibilityLabel) searchField.setAccessibilityLabel(accessibilityLabel)
if command == .notes { if command == .notes {
searchField.isHidden = true launcherViews.first?.isHidden = true
commandTitle.isHidden = false 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() { for view in launcherViews.dropFirst() {
view.isHidden = true view.isHidden = true
@@ -959,7 +968,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
controller.view.leadingAnchor.constraint(equalTo: content.leadingAnchor), controller.view.leadingAnchor.constraint(equalTo: content.leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: content.trailingAnchor), 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), controller.view.bottomAnchor.constraint(equalTo: content.bottomAnchor),
]) ])
if let notes { notes.focusEditor() } if let notes { notes.focusEditor() }
+219 -43
View File
@@ -2,6 +2,21 @@ import AppKit
import ConjureKit import ConjureKit
import Highlighter 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 @MainActor
final class NotesController: NSViewController, NSTextViewDelegate { final class NotesController: NSViewController, NSTextViewDelegate {
private let browseButton = NSButton(title: "Browse Notes", target: nil, action: nil) 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 actions = ApplicationActionsController()
private let editor = NotesTextView() private let editor = NotesTextView()
private let status = NSTextField(labelWithString: "") private let status = NSTextField(labelWithString: "")
private let noteTitle = NSTextField(labelWithString: "Untitled Note")
private let formatting = NSStackView()
private let defaults: UserDefaults private let defaults: UserDefaults
private var store: NotesStore? private var store: NotesStore?
private var selectedID: UUID? private var selectedID: UUID?
@@ -32,32 +49,84 @@ final class NotesController: NSViewController, NSTextViewDelegate {
let content = NotesView() let content = NotesView()
content.commandShortcut = { [weak self] in self?.handleCommandKey($0) ?? false } content.commandShortcut = { [weak self] in self?.handleCommandKey($0) ?? false }
view = content 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.target = self
browseButton.action = #selector(browseNotes) browseButton.action = #selector(browseNotes)
browseButton.setAccessibilityLabel("Browse notes") configureIcon(browseButton, symbol: "note.text", label: "Browse notes (⌘P)")
browseButton.toolTip = "Browse notes (Command-P)" browseButton.keyEquivalent = "p"
actionsButton.target = self actionsButton.target = self
actionsButton.action = #selector(showActions) actionsButton.action = #selector(showActions)
actionsButton.setAccessibilityLabel("Notes actions") configureIcon(actionsButton, symbol: "command", label: "Notes actions (⌘K)")
actionsButton.toolTip = "Notes actions (Command-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.closed = { [weak self] in self?.focusEditor() }
actions.selectNote = { [weak self] in self?.switchNote($0) } actions.selectNote = { [weak self] in self?.switchNote($0) }
actions.newNote = { [weak self] in self?.newNote() } actions.newNote = { [weak self] in self?.newNote() }
actions.browseNotes = { [weak self] in self?.browseNotes() } actions.browseNotes = { [weak self] in self?.browseNotes() }
let pill = ActionPill(primaryButton: browseButton, actionsButton: actionsButton, primaryCommand: "p") let tools = [
let footer = NSStackView(views: [status, NSView(), pill]) ("textformat.size", "Heading"), ("bold", "Bold"), ("italic", "Italic"),
footer.orientation = .horizontal ("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.alignment = .centerY
footer.spacing = 12 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
editor.allowsUndo = true editor.allowsUndo = true
editor.drawsBackground = false editor.drawsBackground = false
editor.textColor = SunsetPalette.text editor.textColor = NotesAppearance.text
editor.insertionPointColor = SunsetPalette.accent editor.insertionPointColor = NotesAppearance.accent
editor.font = .systemFont(ofSize: 14) editor.selectedTextAttributes = [.backgroundColor: NotesAppearance.accent.withAlphaComponent(0.25)]
editor.font = NotesAppearance.font
editor.defaultParagraphStyle = NotesAppearance.paragraph
editor.isAutomaticQuoteSubstitutionEnabled = false editor.isAutomaticQuoteSubstitutionEnabled = false
editor.isAutomaticDashSubstitutionEnabled = false editor.isAutomaticDashSubstitutionEnabled = false
editor.isAutomaticTextReplacementEnabled = false editor.isAutomaticTextReplacementEnabled = false
@@ -65,39 +134,49 @@ final class NotesController: NSViewController, NSTextViewDelegate {
editor.isHorizontallyResizable = false editor.isHorizontallyResizable = false
editor.autoresizingMask = [.width] editor.autoresizingMask = [.width]
editor.textContainer?.widthTracksTextView = true editor.textContainer?.widthTracksTextView = true
editor.textContainerInset = NSSize(width: 10, height: 10) editor.textContainerInset = NSSize(width: 22, height: 8)
editor.setAccessibilityLabel("Note editor") editor.setAccessibilityLabel("Note editor")
let scroll = NSScrollView() let scroll = NSScrollView()
scroll.documentView = editor scroll.documentView = editor
scroll.hasVerticalScroller = true scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true scroll.autohidesScrollers = true
scroll.drawsBackground = false scroll.drawsBackground = false
scroll.wantsLayer = true scroll.scrollerStyle = .overlay
scroll.layer?.cornerRadius = 8
scroll.layer?.borderWidth = 0.5
scroll.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
status.font = .systemFont(ofSize: 12) status.font = .systemFont(ofSize: 12)
status.textColor = SunsetPalette.secondaryText status.textColor = NotesAppearance.muted
status.setAccessibilityLabel("Notes save status") status.setAccessibilityLabel("Notes save status")
status.lineBreakMode = .byTruncatingTail status.lineBreakMode = .byTruncatingTail
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 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 control.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(control) view.addSubview(control)
} }
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
scroll.topAnchor.constraint(equalTo: view.topAnchor), header.topAnchor.constraint(equalTo: view.topAnchor),
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), header.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), header.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -8), header.heightAnchor.constraint(equalToConstant: 38),
footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), body.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 4),
footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8), body.leadingAnchor.constraint(equalTo: view.leadingAnchor),
footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8), body.trailingAnchor.constraint(equalTo: view.trailingAnchor),
footer.heightAnchor.constraint(equalToConstant: 36), 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 { guard let store else {
editor.isEditable = false 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!) showError(loadError!)
return 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 focusEditor() { view.window?.makeFirstResponder(editor) }
func textDidChange(_ notification: Notification) { func textDidChange(_ notification: Notification) {
@@ -164,6 +331,10 @@ final class NotesController: NSViewController, NSTextViewDelegate {
} }
private func replaceListMarkdown(_ replacement: String, range: NSRange, selection: NSRange) { 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() editor.undoManager?.beginUndoGrouping()
// Native text undo restores its diff boundary, not the user's selection. // Native text undo restores its diff boundary, not the user's selection.
setSelectionWithUndo(editor.selectedRange()) setSelectionWithUndo(editor.selectedRange())
@@ -246,8 +417,8 @@ final class NotesController: NSViewController, NSTextViewDelegate {
do { do {
try store.update(id: selectedID, contents: editor.markdownContents) try store.update(id: selectedID, contents: editor.markdownContents)
isDirty = false isDirty = false
status.stringValue = "Saved locally · Markdown · Paste or drop images" noteTitle.stringValue = store.notes.first { $0.id == selectedID }?.title ?? "Untitled Note"
status.textColor = SunsetPalette.secondaryText status.isHidden = true
return true return true
} catch { } catch {
showError(error) showError(error)
@@ -312,12 +483,13 @@ final class NotesController: NSViewController, NSTextViewDelegate {
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
MarkdownHighlighting.apply(to: editor) MarkdownHighlighting.apply(to: editor)
editor.refreshTasks() editor.refreshTasks()
status.stringValue = "Saved locally · Markdown · Paste or drop images" noteTitle.stringValue = note.title
status.textColor = SunsetPalette.secondaryText status.isHidden = true
} }
private func showError(_ error: Error) { private func showError(_ error: Error) {
status.isHidden = false
status.stringValue = "Couldn’t save or load notes: \(error.localizedDescription)" status.stringValue = "Couldn’t save or load notes: \(error.localizedDescription)"
status.toolTip = status.stringValue status.toolTip = status.stringValue
status.textColor = .systemRed status.textColor = .systemRed
@@ -353,16 +525,17 @@ 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.systemFont(ofSize: 17, weight: .semibold), .foregroundColor: SunsetPalette.accent]), (try! NSRegularExpression(pattern: "(?m)^#{1,6} .+$"), [.font: NSFont.systemFont(ofSize: 23, weight: .semibold)]),
(try! NSRegularExpression(pattern: "\\*\\*[^\\n]+?\\*\\*|__[^\\n]+?__"), [.font: NSFont.boldSystemFont(ofSize: 14)]), (try! NSRegularExpression(pattern: "\\*\\*[^\\n]+?\\*\\*|__[^\\n]+?__"), [.font: NSFont.boldSystemFont(ofSize: 17)]),
(try! NSRegularExpression(pattern: "(?<!\\*)\\*(?!\\*)[^*\\n]+\\*(?!\\*)|(?<!_)_(?!_)[^_\\n]+_(?!_)"), [.font: NSFontManager.shared.convert(.systemFont(ofSize: 14), 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: NotesAppearance.accent]),
(try! NSRegularExpression(pattern: "`[^`\\n]+`"), [.font: NSFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: NSColor.systemGreen]), (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 = { 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: 14) value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: 17)
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")
@@ -401,7 +574,9 @@ enum MarkdownHighlighting {
let scale = min(1, 460 / max(size.width, 1), 240 / max(size.height, 1)) 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) } 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) let blocks = codeBlocks(in: text)
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) {
@@ -410,9 +585,9 @@ enum MarkdownHighlighting {
} }
} }
for block in blocks { for block in blocks {
storage.addAttributes([.font: NSFont.monospacedSystemFont(ofSize: 14, weight: .regular), storage.addAttributes([.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .regular),
.foregroundColor: SunsetPalette.secondaryText], range: block.range) .foregroundColor: NotesAppearance.muted], range: block.range)
storage.addAttribute(.foregroundColor, value: SunsetPalette.text, range: block.body) storage.addAttribute(.foregroundColor, value: NotesAppearance.text, range: block.body)
let code = (text as NSString).substring(with: block.body) let code = (text as NSString).substring(with: block.body)
guard let highlighted = highlighter.highlight(code, as: block.language), guard let highlighted = highlighter.highlight(code, as: block.language),
highlighted.string == code else { continue } 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) super.init(data: nil, ofType: nil)
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: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.accent]) attributes: [.font: NotesAppearance.font, .foregroundColor: NotesAppearance.accent])
cell.alignment = .right cell.alignment = .right
attachmentCell = cell 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") } required init?(coder: NSCoder) { fatalError("List attachments are serialized as Markdown") }
} }
private final class ListAttachmentCell: NSTextAttachmentCell { private final class ListAttachmentCell: NSTextAttachmentCell {
nonisolated(unsafe) var columnWidth: CGFloat = 18 nonisolated(unsafe) var columnWidth: CGFloat = 22
nonisolated override func cellSize() -> NSSize { NSSize(width: columnWidth, height: 17) } nonisolated override func cellSize() -> NSSize { NSSize(width: columnWidth, height: 21) }
nonisolated override func cellBaselineOffset() -> NSPoint { NSPoint(x: 0, y: -3) } nonisolated override func cellBaselineOffset() -> NSPoint { NSPoint(x: 0, y: -4) }
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) { override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {
guard !stringValue.isEmpty else { return } guard !stringValue.isEmpty else { return }
let label = attributedStringValue let label = attributedStringValue
@@ -202,6 +202,10 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
replaceSource(NSAttributedString(string: text, attributes: textTypingAttributes), range: range, semantic: true) 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 { 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 = listWhitespaceBoundary(from: rendered.location, forward: true)
@@ -506,7 +510,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
while checkboxes.count < taskIndices.count { while checkboxes.count < taskIndices.count {
let checkbox = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:))) let checkbox = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:)))
checkbox.controlSize = .small checkbox.controlSize = .small
checkbox.contentTintColor = SunsetPalette.accent checkbox.contentTintColor = NotesAppearance.accent
checkbox.sizeToFit() checkbox.sizeToFit()
checkboxes.append(checkbox) checkboxes.append(checkbox)
addSubview(checkbox) addSubview(checkbox)
@@ -534,8 +538,8 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
layoutManager.delegate = self layoutManager.delegate = self
storage.beginEditing() storage.beginEditing()
Self.restoreListLayout(in: storage) Self.restoreListLayout(in: storage)
let markerFont = NSFont.systemFont(ofSize: 14) let markerFont = NotesAppearance.font
let column = ceil(tasks.reduce(CGFloat(18)) { width, task in let column = ceil(tasks.reduce(CGFloat(22)) { width, task in
guard !task.attachment.isTask else { return width } guard !task.attachment.isTask else { return width }
return max(width, (task.attachment.marker as NSString).size(withAttributes: [.font: markerFont]).width + 4) 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. // Keep indentation as native whitespace; null paragraph-leading glyphs lose first-line layout.
style.firstLineHeadIndent = 0 style.firstLineHeadIndent = 0
style.lineSpacing = NotesAppearance.paragraph.lineSpacing
style.paragraphSpacing = NotesAppearance.paragraph.paragraphSpacing
style.headIndent = CGFloat(depth) * space + column style.headIndent = CGFloat(depth) * space + column
style.tabStops = [] style.tabStops = []
style.defaultTabInterval = 4 * space style.defaultTabInterval = 4 * space
@@ -18,9 +18,7 @@ import Testing
let comment = try #require(attributes("# comment")[.foregroundColor] as? NSColor) let comment = try #require(attributes("# comment")[.foregroundColor] as? NSColor)
#expect(keyword != name) #expect(keyword != name)
#expect(comment != keyword) #expect(comment != keyword)
#expect((attributes("# comment")[.font] as? NSFont)?.pointSize == 14)
#expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true) #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((attributes("After")[.font] as? NSFont)?.isFixedPitch == false)
#expect(storage.string == text) #expect(storage.string == text)
editor.string = "```python\ndef main():\n pass" editor.string = "```python\ndef main():\n pass"
+71 -5
View File
@@ -21,8 +21,6 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
let views = descendants(controller.view) let views = descendants(controller.view)
let editor = try #require(views.compactMap { $0 as? NSTextView }.first) let editor = try #require(views.compactMap { $0 as? NSTextView }.first)
let browse = try #require(views.compactMap { $0 as? NSButton }.first { $0.title == "Browse Notes" }) 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 selectedID() throws -> UUID { try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:))) }
func picker(_ tool: NotesController) throws -> NSSearchField { func picker(_ tool: NotesController) throws -> NSSearchField {
try #require(descendants(tool.view).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == "Search notes" }) 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.handleControlKey("j"))
#expect(!controller.handle(#selector(NSResponder.insertNewline(_:)))) #expect(!controller.handle(#selector(NSResponder.insertNewline(_:))))
editor.insertText("# Second 🦊\n**bold** and `code`", replacementRange: editor.selectedRange()) 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 codeRange = (editor.string as NSString).range(of: "`code`")
let codeFont = try #require(editor.textStorage?.attribute(.font, at: codeRange.location, effectiveRange: nil) as? NSFont) let codeFont = try #require(editor.textStorage?.attribute(.font, at: codeRange.location, effectiveRange: nil) as? NSFont)
#expect(codeFont.isFixedPitch) #expect(codeFont.isFixedPitch)
@@ -99,7 +96,6 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
#expect(reopenedEditor.string == first.contents.string) #expect(reopenedEditor.string == first.contents.string)
// Removing Markdown must also remove its previous visual style without changing the source. // 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)) 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") #expect(reopenedEditor.string == "Plain text")
let backup = directory.appendingPathExtension("backup") let backup = directory.appendingPathExtension("backup")
defer { try? FileManager.default.removeItem(at: backup) } defer { try? FileManager.default.removeItem(at: backup) }
@@ -183,7 +179,6 @@ func notesLauncherRoutesShortcutsAndTearsDownPickersWithoutTakingEditorNavigatio
let picker = try search("Search notes") let picker = try search("Search notes")
let table = try #require(descendants(content).compactMap { $0 as? NSTableView }.first { $0.accessibilityLabel() == "Notes" }) let table = try #require(descendants(content).compactMap { $0 as? NSTableView }.first { $0.accessibilityLabel() == "Notes" })
#expect(window.firstResponder === picker.currentEditor()) #expect(window.firstResponder === picker.currentEditor())
#expect(browse.superview?.isAccessibilityHidden() == true)
#expect(tool.handle(#selector(NSResponder.moveUp(_:)))) #expect(tool.handle(#selector(NSResponder.moveUp(_:))))
window.sendEvent(try key("j", code: 38, flags: .control)) window.sendEvent(try key("j", code: 38, flags: .control))
#expect(table.selectedRow == 1) #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)
}