style: match Raycast notes window
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user