From 999d44226da2011e99a8469185ee4ab64e22efaa Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Mon, 19 Oct 2026 15:26:42 +0200 Subject: [PATCH] style: match Raycast notes window --- README.md | 10 +- .../ApplicationActionsController.swift | 4 +- .../Conjure/LauncherWindowController.swift | 29 +- Sources/Conjure/NotesController.swift | 262 +++++++++++++++--- Sources/Conjure/NotesTextView.swift | 22 +- .../CodeHighlightingTests.swift | 2 - Tests/ConjureKitTests/NotesEditingTests.swift | 76 ++++- 7 files changed, 332 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 20f822f..eea9f8a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/Conjure/ApplicationActionsController.swift b/Sources/Conjure/ApplicationActionsController.swift index 351f929..eae9d9b 100644 --- a/Sources/Conjure/ApplicationActionsController.swift +++ b/Sources/Conjure/ApplicationActionsController.swift @@ -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, diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index 8cfc1b5..9697b87 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -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() } diff --git a/Sources/Conjure/NotesController.swift b/Sources/Conjure/NotesController.swift index 4146bd3..2e17f91 100644 --- a/Sources/Conjure/NotesController.swift +++ b/Sources/Conjure/NotesController.swift @@ -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: "(? 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 diff --git a/Tests/ConjureKitTests/CodeHighlightingTests.swift b/Tests/ConjureKitTests/CodeHighlightingTests.swift index a913224..e5a7a76 100644 --- a/Tests/ConjureKitTests/CodeHighlightingTests.swift +++ b/Tests/ConjureKitTests/CodeHighlightingTests.swift @@ -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" diff --git a/Tests/ConjureKitTests/NotesEditingTests.swift b/Tests/ConjureKitTests/NotesEditingTests.swift index 1726d90..a1fcdc9 100644 --- a/Tests/ConjureKitTests/NotesEditingTests.swift +++ b/Tests/ConjureKitTests/NotesEditingTests.swift @@ -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) +}